- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
13 KiB
会话输入框交互优化:Enter 发送防误触 + 暂停后编辑重发
- 日期:2026-06-29
- 范围:UI(
ui/src)+ Rust 后端(crates/,仅 Nomi 原生引擎) - 背景:解决两个会话页面交互问题
- 输入框一感知到 Enter 就发送,输入法(IME)上屏候选词的 Enter 会被误当成发送,体验差。
- 已发出的消息在暂停后无法编辑重新提交。
问题一:Enter 发送
目标
- 核心 bug:彻底堵住"输入法上屏的 Enter 被误判为发送"。
- 可配置发送键:新增用户偏好,可选「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 同时被会话框 SendBox(index.tsx:984,1714)与引导页 GuidInputCard(GuidInputCard.tsx:83)使用,集中修复即可一并受益。
设计
A. 健壮的 IME 守卫(useCompositionInput.ts)
在 hook 内新增多重判定,任一为真即视为"输入法占用中",跳过发送:
isComposing.current === true(现有,compositionstart→true / compositionend→false)。- 新增
justComposedRef:compositionend时置true,并在下一帧requestAnimationFrame清回false。用于覆盖"compositionend同 tick 先于 Enterkeydown"的浏览器(同一物理按键,间隔≈0ms;一帧后清除,保证之后用户主动再按 Enter 仍能正常发送)。 e.nativeEvent?.isComposing === true(W3C 原生属性)。(e as any).keyCode === 229(IME 处理中的 keydown)。
对外暴露 isImeActive(e): boolean 供其它自定义 keydown(GuidPage.handleInputKeyDown、GuidInputCard)复用,替换它们裸用 isComposing.current 的判断。
B. 发送键偏好
- 配置项:在
ui/src/common/config/configKeys.ts的ConfigKeyMap新增'chat.sendKey': 'enter' | 'mod-enter' | undefined;(缺省按'enter'处理)。 读写经现有单例configService(GET/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 插入换行)。- 调用方(
SendBox、GuidInputCard/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.tsx的preferenceItems增一行,复用PreferenceRow+NomiSelect(两项)。仿language/keepAwake的useState+ 启动configService.get(...) ?? 'enter'+ change 乐观写入/失败setLocal回滚样板。 - i18n:
settings.json(en-US + zh-CN)新增sendKey/sendKeyDesc/sendKeyEnter/sendKeyModEnter。
受影响文件(问题一)
ui/src/renderer/hooks/chat/useCompositionInput.ts(IME 守卫 +sendKey提交判定 +isImeActive)ui/src/renderer/components/chat/SendBox/index.tsx(传入sendKey;steer 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>,与 DBmessages表解耦(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)
- Repo(
crates/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。
- Engine(
crates/agent/nomi-agent/src/engine.rs)- 新增字段
last_turn_start_len: Option<usize>,在run_innerpush 用户消息前(约:670)记录self.messages.len();持久化进 session(restart 后 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。
- 新增字段
- Manager(
nomifun-ai-agent的NomiAgentManager)- 暴露
rewind_last_turn()透传到引擎(仿clear_context的"先 request_stop 再操作"模式)。
- 暴露
- Service(
crates/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 不会触发)。
- Route(
routes.rs):POST /api/conversations/{id}/messages/{messageId}/edit-resubmit。
前端设计
- emitter(
utils/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)。
- 新增可选 prop
- NomiSendBox.tsx:提供
onEditResubmit→handleEditResubmit:先本地移除 ≥ 该消息的行(新增useRemoveMessagesFrom(createdAt)助手,仿useRemoveMessageByMsgId),调用ipcBridge.conversation.editResubmit,再 emit'chat.history.refresh';进入编辑态时还原附件。 - ipcBridge.ts:
conversation.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.rs、repository/sqlite_conversation.rs、agent/nomi-agent/src/engine.rs、nomifun-ai-agent(manager)、nomifun-conversation/src/{service.rs,routes.rs}。
前端:utils/emitter.ts、Messages/components/MessageText.tsx、components/chat/SendBox/index.tsx、platforms/nomi/NomiSendBox.tsx、pages/conversation/Messages/hooks.ts(useRemoveMessagesFrom)、common/adapter/ipcBridge.ts、locales/{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先于 Enterkeydown、keyCode===229、nativeEvent.isComposing、'mod-enter'模式下裸 Enter 不发送/Mod+Enter 发送、'enter'模式回归。 - 问题二:
- Rust:
delete_messages_from删除区间正确(含/不含边界);rewind_last_turn截断到锚点且 sanity 失败返回 false;edit_and_resubmit非最近一条/非 Nomi 返回 4xx;端到端"暂停→编辑→重发"上下文不含旧消息但含更早历史。 - 前端:SendBox 编辑模式进入/取消/提交走
onEditResubmit;MessageText 编辑按钮显示条件。
- Rust:
不在本次范围
- 编辑中间任意消息(受架构约束,需忠实重建,本质不可行)。
- 非 Nomi 平台的编辑重发。
- 助手消息的"重新生成"按钮。
- 截断点之后 artifacts 的清理。