Files
MyAiDesk/nomifun-tauri/docs/superpowers/specs/2026-06-29-chat-input-ime-and-edit-resend-design.md
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

13 KiB
Raw Permalink Blame History

会话输入框交互优化:Enter 发送防误触 + 暂停后编辑重发

  • 日期:2026-06-29
  • 范围:UIui/src+ Rust 后端(crates/,仅 Nomi 原生引擎)
  • 背景:解决两个会话页面交互问题
    1. 输入框一感知到 Enter 就发送,输入法(IME)上屏候选词的 Enter 会被误当成发送,体验差。
    2. 已发出的消息在暂停后无法编辑重新提交。

问题一:Enter 发送

目标

  1. 核心 bug:彻底堵住"输入法上屏的 Enter 被误判为发送"。
  2. 可配置发送键:新增用户偏好,可选「Enter 发送 / Shift+Enter 换行」(默认)或「Ctrl/⌘+Enter 发送 / Enter 换行」。

根因

ui/src/renderer/hooks/chat/useCompositionInput.ts 仅用 compositionstart/end 维护一个 isComposing ref。存在经典竞态:部分输入法/浏览器在"上屏候选词"时 compositionend先于 Enter 的 keydown 触发,此时 ref 已被置 false,于是该次 Enter 落入发送分支(useCompositionInput.ts:26)。缺少 nativeEvent.isComposing / keyCode===229 / 时间窗兜底。该 hook 同时被会话框 SendBoxindex.tsx:984,1714)与引导页 GuidInputCardGuidInputCard.tsx:83)使用,集中修复即可一并受益。

设计

A. 健壮的 IME 守卫(useCompositionInput.ts

在 hook 内新增多重判定,任一为真即视为"输入法占用中",跳过发送:

  • isComposing.current === true(现有,compositionstart→true / compositionend→false)。
  • 新增 justComposedRefcompositionend 时置 true,并在下一帧 requestAnimationFrame 清回 false。用于覆盖"compositionend 同 tick 先于 Enter keydown"的浏览器(同一物理按键,间隔≈0ms;一帧后清除,保证之后用户主动再按 Enter 仍能正常发送)。
  • e.nativeEvent?.isComposing === trueW3C 原生属性)。
  • (e as any).keyCode === 229IME 处理中的 keydown)。

对外暴露 isImeActive(e): boolean 供其它自定义 keydownGuidPage.handleInputKeyDownGuidInputCard)复用,替换它们裸用 isComposing.current 的判断。

B. 发送键偏好

  • 配置项:在 ui/src/common/config/configKeys.tsConfigKeyMap 新增 'chat.sendKey': 'enter' | 'mod-enter' | undefined;(缺省按 'enter' 处理)。 读写经现有单例 configServiceGET/PUT /api/settings/client,对新 key 透明),读取用 useConfig('chat.sendKey')
  • 生效点createKeyDownHandler(onSubmit, intercept?, sendKey?) 增加 sendKey 参数;IME 守卫与 intercept 之后判定提交手势:
    • 'enter'Enter && !shift && !meta && !ctrl && !alt → 提交;Shift+Enter → 换行(默认行为不变)。
    • 'mod-enter'Enter && (meta||ctrl) && !shift → 提交;裸 Enter → 换行(不拦截,交由 textarea 插入换行)。
    • 调用方(SendBoxGuidInputCard/GuidPage)从 useConfig 取值传入;hook 自身不依赖 config,便于测试与复用。
  • 与既有 Mod+Enter「steer」共存SendBox/index.tsx:1718-1731):
    • 'enter' 模式:保持现状——Enter 提交,Mod+Enter 在 steerAvailable 且 turn 运行时执行 steer。
    • 'mod-enter' 模式:Mod+Enter 即主提交手势;键盘 steer 快捷键在此模式下不挂载steer 仍可经 steer 按钮触发)。即 SendBox 中处理 Mod+Enter→steer 的 intercept 分支仅在 sendKey==='enter' 时生效。
  • 设置 UI:在 SettingsModal/contents/SystemModalContent/index.tsxpreferenceItems 增一行,复用 PreferenceRow + NomiSelect(两项)。仿 language/keepAwakeuseState + 启动 configService.get(...) ?? 'enter' + change 乐观写入/失败 setLocal 回滚样板。
  • i18nsettings.jsonen-US + zh-CN)新增 sendKey / sendKeyDesc / sendKeyEnter / sendKeyModEnter

受影响文件(问题一)

  • ui/src/renderer/hooks/chat/useCompositionInput.tsIME 守卫 + sendKey 提交判定 + isImeActive
  • ui/src/renderer/components/chat/SendBox/index.tsx(传入 sendKeysteer intercept 仅 'enter' 模式)
  • ui/src/renderer/pages/guid/components/GuidInputCard.tsx + pages/guid/GuidPage.tsx(复用 isImeActive,最终 Enter→send 分支按 sendKey 判定)
  • ui/src/common/config/configKeys.ts(新 key
  • ui/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/index.tsx(设置行)
  • ui/src/renderer/services/i18n/locales/{en-US,zh-CN}/settings.json(文案)

问题二:暂停后编辑重发(仅 Nomi、仅最近一条、回填输入框)

锁定的范围决策

  • 语义:截断重跑(编辑后删除该消息及其后全部消息并重新生成)。
  • 可编辑对象:仅最近一条用户消息(最后一个用户 turn)。
  • 平台:仅 Nomi 原生引擎
  • 编辑交互:回填输入框——点"编辑"把原文本(含附件)放回 SendBox,输入框进入"编辑模式",提交即截断重跑。

关键架构约束(决定为何只做"最近一条")

  • Nomi 引擎的模型上下文是内存 AgentEngine.messages: Vec<Message>与 DB messages 表解耦(DB 仅供 UI 展示/持久化)。引擎自持文件型 session 持久化,从不回读 DB 构建上下文。
  • 引擎 transcript 里 tool 结果、steering 注入、目标续跑都以 Role::User 入栈(engine.rs:1075,927,939),且会被 microcompaction 整体重写(engine.rs:1158)。因此"DB 某条消息 ↔ transcript 某下标"无稳定映射;thinking 签名、tool_use 配对不持久化,中间点无法忠实重建
  • 暂停(mid-stream cancel)时引擎保留该 turn 起始 push 的用户消息(engine.rs:670),但不 push 助手回复(engine.rs:848-859)。
  • 结论:只有"最后一个用户 turn"可被干净地从内存 transcript 弹出而保全之前的完整上下文。

数据流

用户在某条最近的用户消息(position='right', type='text')上点「编辑」(仅 Nomi、仅最近一条、仅 idle)
  → MessageText 触发 emitter 事件 'sendbox.edit' { msgId, createdAt, content }
  → SendBox 进入"编辑模式":回填文本(经 parseFileMarker 拆出纯文本与附件) + 顶部"编辑中"提示条(可取消)
  → 用户改完点提交
  → NomiSendBox.handleEditResubmit(msgId, input, files):
       ipcBridge.conversation.editResubmit.invoke({ conversation_id, msg_id, input, files })
  → 后端 service.edit_and_resubmit:
       1. 鉴权 + 校验 msg_id 属于该会话且为最近一条用户消息
       2. cancel 任何在飞 turn(防御)
       3. 引擎 rewind_last_turn():把内存 transcript 截断到该 turn 起始锚点(保全之前上下文)
       4. repo.delete_messages_from(conv_id, created_at, id)DB 删除该条(含)及其后所有行
       5. 复用 send_message 正常流程发送新内容 → 新 turn 流式回来
  → 前端:调用前先本地移除 ≥ 该 createdAt 的消息(snappy),再 emit 'chat.history.refresh' 对齐 DB;流式渲染新回复

后端设计(Rust,仅 Nomi

  • Repocrates/backend/nomifun-db
    • IConversationRepository::delete_messages_from(conversation_id, created_at, id) -> Result<u64>repository/conversation.rs+ SQLite 实现(repository/sqlite_conversation.rs): DELETE FROM messages WHERE conversation_id=?1 AND (created_at>?2 OR (created_at=?2 AND id>=?3)),命中 keyset 索引 idx_messages_conv_created_id
  • Enginecrates/agent/nomi-agent/src/engine.rs
    • 新增字段 last_turn_start_len: Option<usize>,在 run_inner push 用户消息前(约 :670)记录 self.messages.len();持久化进 sessionrestart 后 resume 可用)。
    • microcompaction 重写 transcript 时(:1158)置 last_turn_start_len = None(失效)。
    • pub fn rewind_last_turn(&mut self) -> bool:若锚点存在且 start <= messages.len()messages[start]Role::User 文本(sanity),则 self.messages.truncate(start) + 清锚点 + save_session(),返回 true;否则 false
  • Managernomifun-ai-agentNomiAgentManager
    • 暴露 rewind_last_turn() 透传到引擎(仿 clear_context 的"先 request_stop 再操作"模式)。
  • Servicecrates/backend/nomifun-conversation/src/service.rs
    • edit_and_resubmit(conversation_id, msg_id, input, files) -> Result<{ msg_id }> 鉴权 → 校验该 msg_id 是该会话最近一条 position='right' 文本消息(否则 4xx)→ cancel 在飞 turn → 取该消息 (created_at,id)agent.rewind_last_turn()(失败则回退:返回可读错误,提示"上下文已压缩,无法精确回退")→ repo.delete_messages_from(...) → 复用 send_message 发送新内容并返回新 msg_id
    • 仅当会话 agent 类型为 Nomi 时可用,其它类型返回 4xx(UI 不会触发)。
  • Routeroutes.rs):POST /api/conversations/{id}/messages/{messageId}/edit-resubmit

前端设计

  • emitterutils/emitter.ts):新增 'sendbox.edit': [{ msgId: string; createdAt: number; content: string }]
  • MessageText.tsx:用户消息(isUserMessage && type==='text')的悬浮工具行(:232-245,桌面端)在 copyButton 旁加「编辑」图标按钮,复用其样式。显示条件:会话 type==='nomi' && 非运行中 && 该消息是最近一条用户消息。点击 emit 'sendbox.edit'
    • 移动端:当前无 per-message 工具行。最近一条用户气泡长按 → 轻量动作菜单(复制/编辑)。此为次优先项,可在实现期决定是否随首版交付。
  • SendBox/index.tsx(通用、平台无关):
    • 新增可选 prop onEditResubmit?: (msgId: string, message: string) => Promise<void>
    • 监听 'sendbox.edit':设 editingMessage={msgId},回填文本、还原附件(经平台 setUploadFile,仿 handleEditQueuedCommand)、显示"编辑中"提示条(复用 replyQuote 预览卡样式)+ 取消按钮(取消恢复原草稿)。
    • 编辑模式下提交:调用 onEditResubmit(editingMessage, finalMessage) 而非 onSend,完成后清除编辑态。发送按钮图标/提示切换为"保存并重发"。
    • 仅当宿主提供 onEditResubmit 时进入编辑模式(即仅 Nomi)。
  • NomiSendBox.tsx:提供 onEditResubmithandleEditResubmit:先本地移除 ≥ 该消息的行(新增 useRemoveMessagesFrom(createdAt) 助手,仿 useRemoveMessageByMsgId),调用 ipcBridge.conversation.editResubmit,再 emit 'chat.history.refresh';进入编辑态时还原附件。
  • ipcBridge.tsconversation.editResubmit.invoke({ conversation_id, msg_id, input, files? }) -> { msg_id },映射上面的 HTTP 路由。
  • i18n:复用 common.edit;新增 conversation.editMessage.{banner,cancel,save} 等(en-US + zh-CN)。

受影响文件(问题二)

后端:repository/conversation.rsrepository/sqlite_conversation.rsagent/nomi-agent/src/engine.rsnomifun-ai-agentmanager)、nomifun-conversation/src/{service.rs,routes.rs}。 前端:utils/emitter.tsMessages/components/MessageText.tsxcomponents/chat/SendBox/index.tsxplatforms/nomi/NomiSendBox.tsxpages/conversation/Messages/hooks.tsuseRemoveMessagesFrom)、common/adapter/ipcBridge.tslocales/{en-US,zh-CN}/conversation.json


错误处理与边界

  • 编辑入口仅在 idle 显示:turn 运行中不显示编辑按钮(feature 命名即"暂停后")。
  • "最近一条"判定:前端按消息列表里最后一个 position==='right' 文本消息判断;后端二次校验,防止竞态/伪造。
  • 锚点失效(已压缩)rewind_last_turn 返回 false 时,service 返回可读错误,前端 toast 提示并保留输入内容,不破坏 DB。
  • 附件还原:文本必还原;附件路径尽力还原(来自 parseFileMarker 的展示路径,可能有损),实现期评估。
  • artifacts:MVP 不删除截断点之后产生的 artifacts(属工作区文件,重跑可能覆盖)。后续可加 delete_artifacts_from
  • 多端一致性DB 删除 + 引擎 transcript + 文件 session 三处需在 service 内顺序保证;任一步失败需返回明确错误且不留中间态(DB 删除应在引擎 rewind 成功之后执行)。

测试

  • 问题一useCompositionInput 单测——模拟 compositionend 先于 Enter keydownkeyCode===229nativeEvent.isComposing'mod-enter' 模式下裸 Enter 不发送/Mod+Enter 发送、'enter' 模式回归。
  • 问题二
    • Rustdelete_messages_from 删除区间正确(含/不含边界);rewind_last_turn 截断到锚点且 sanity 失败返回 falseedit_and_resubmit 非最近一条/非 Nomi 返回 4xx;端到端"暂停→编辑→重发"上下文不含旧消息但含更早历史。
    • 前端:SendBox 编辑模式进入/取消/提交走 onEditResubmitMessageText 编辑按钮显示条件。

不在本次范围

  • 编辑中间任意消息(受架构约束,需忠实重建,本质不可行)。
  • 非 Nomi 平台的编辑重发。
  • 助手消息的"重新生成"按钮。
  • 截断点之后 artifacts 的清理。