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

46 KiB
Raw Permalink Blame History

会话输入框交互优化 实现计划(Enter 防误触 + 暂停后编辑重发)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修复输入法上屏 Enter 被误发送、新增发送键偏好;并为 Nomi 原生会话支持「编辑最近一条用户消息并截断重跑」。

Architecture: Part A(前端)集中在 useCompositionInput 的 IME 守卫与提交手势判定,配置项走现有 configService。Part B 后端新增 keyset 截断删除(repo)、引擎"回退最后一个 turn"原语、service edit_and_resubmit + 路由;前端给最近一条用户气泡加「编辑」入口,复用发送路径截断重跑。

Tech Stack: React + TypeScriptArco Design、bun:test)、Rustaxum、sqlx 运行时查询、tokio、async-trait)。

Global Constraints

  • 注释/文案随上下文语言;交流用中文。提交以 nomifun <rika00@qq.com> 署名,Co-Authored-By trailergit -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m ...
  • 前端测试运行器是 bun:testcd ui && bun test <path>;导入 import { describe, expect, test } from 'bun:test'
  • Rust 测试:单测跑 cargo test -p <crate> <filter>
  • sqlx 用运行时查询(sqlx::query/query_as::<_, T>),占位符是匿名 ?,按顺序 .bind(...)
  • 引擎 AgentEngine 的所有构造点都是 Self { ... } 字面量(无 ..Default):新增字段必须同时改 3 处——new_with_providerengine.rs:230-265)、resume_with_provider:305-340)、测试 make_engine:1500-1535)。
  • 编辑重发仅 Nomi仅最近一条用户消息;语义为截断重跑。
  • 分支:feat/chat-input-ime-and-edit-resend(已建,设计文档已提交)。

Part A — Enter 发送(IME 防误触 + 发送键偏好)

Task A1useCompositionInput 健壮 IME 守卫 + 提交手势纯函数

Files:

  • Modify: ui/src/renderer/hooks/chat/useCompositionInput.ts
  • Test: ui/src/renderer/hooks/chat/useCompositionInput.test.tsCreate

Interfaces:

  • Produces:

    • export type SendKeyMode = 'enter' | 'mod-enter'
    • export function isImeComposingKey(e, state): boolean
    • export function isSubmitGesture(e: { key: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean }, mode: SendKeyMode): boolean
    • hook 返回新增 isImeActive: (e) => booleancreateKeyDownHandler(onEnterPress, intercept?, sendKey?: SendKeyMode)
  • Step 1: 写失败测试ui/src/renderer/hooks/chat/useCompositionInput.test.ts

/**
 * @license
 * Copyright 2025-2026 NomiFun (nomifun.com)
 * SPDX-License-Identifier: Apache-2.0
 */
import { describe, expect, test } from 'bun:test';
import { isImeComposingKey, isSubmitGesture } from './useCompositionInput';

describe('isImeComposingKey', () => {
  const base = { key: 'Enter' as const };
  test('true when composing ref set', () => {
    expect(isImeComposingKey(base, { composing: true, justComposed: false })).toBe(true);
  });
  test('true right after compositionend (justComposed window)', () => {
    expect(isImeComposingKey(base, { composing: false, justComposed: true })).toBe(true);
  });
  test('true when native isComposing', () => {
    expect(isImeComposingKey({ ...base, nativeEvent: { isComposing: true } }, { composing: false, justComposed: false })).toBe(true);
  });
  test('true when keyCode 229', () => {
    expect(isImeComposingKey({ ...base, keyCode: 229 }, { composing: false, justComposed: false })).toBe(true);
  });
  test('false for a clean Enter', () => {
    expect(isImeComposingKey({ ...base, keyCode: 13, nativeEvent: { isComposing: false } }, { composing: false, justComposed: false })).toBe(false);
  });
});

describe('isSubmitGesture', () => {
  test('enter mode: plain Enter submits, Shift+Enter does not', () => {
    expect(isSubmitGesture({ key: 'Enter' }, 'enter')).toBe(true);
    expect(isSubmitGesture({ key: 'Enter', shiftKey: true }, 'enter')).toBe(false);
  });
  test('enter mode: Cmd+Enter still submits (legacy compatible)', () => {
    expect(isSubmitGesture({ key: 'Enter', metaKey: true }, 'enter')).toBe(true);
  });
  test('mod-enter mode: plain Enter does NOT submit', () => {
    expect(isSubmitGesture({ key: 'Enter' }, 'mod-enter')).toBe(false);
  });
  test('mod-enter mode: Cmd/Ctrl+Enter submits, Shift+Cmd+Enter does not', () => {
    expect(isSubmitGesture({ key: 'Enter', metaKey: true }, 'mod-enter')).toBe(true);
    expect(isSubmitGesture({ key: 'Enter', ctrlKey: true }, 'mod-enter')).toBe(true);
    expect(isSubmitGesture({ key: 'Enter', metaKey: true, shiftKey: true }, 'mod-enter')).toBe(false);
  });
  test('non-Enter never submits', () => {
    expect(isSubmitGesture({ key: 'a' }, 'enter')).toBe(false);
  });
});
  • Step 2: 跑测试确认失败

Run: cd ui && bun test src/renderer/hooks/chat/useCompositionInput.test.ts Expected: FAILisImeComposingKey/isSubmitGesture 未导出)

  • Step 3: 实现 — 用以下完整内容替换 ui/src/renderer/hooks/chat/useCompositionInput.ts
import { useCallback, useRef, useState } from 'react';

export type SendKeyMode = 'enter' | 'mod-enter';

type ComposingState = { composing: boolean; justComposed: boolean };
type ImeKeyLike = { key?: string; keyCode?: number; nativeEvent?: { isComposing?: boolean } };
type SubmitKeyLike = { key: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean };

/**
 * 纯函数:判断这次 keydown 是否处于输入法合成中(绝不能触发发送)。
 * 组合多重信号以覆盖各浏览器/输入法的事件时序差异:
 * - composingcompositionstart→true / compositionend→false 的 ref
 * - justComposedcompositionend 后的一帧兜底窗口(覆盖"compositionend 先于 Enter keydown"
 * - nativeEvent.isComposingW3C 原生属性
 * - keyCode === 229IME 处理中的 keydown
 */
export function isImeComposingKey(e: ImeKeyLike, state: ComposingState): boolean {
  return state.composing || state.justComposed || e.nativeEvent?.isComposing === true || e.keyCode === 229;
}

/**
 * 纯函数:在给定发送键偏好下,这次 keydown 是否为"提交"手势。
 * - 'enter'(默认,兼容旧行为):Enter 且非 Shift 即提交(Cmd/Ctrl+Enter 也提交)
 * - 'mod-enter':必须 Cmd/Ctrl+Enter;裸 Enter 不提交(留给 textarea 换行)
 */
export function isSubmitGesture(e: SubmitKeyLike, mode: SendKeyMode): boolean {
  if (e.key !== 'Enter' || e.shiftKey) return false;
  if (mode === 'mod-enter') return Boolean(e.metaKey || e.ctrlKey);
  return true;
}

/**
 * 共享的输入法合成事件处理 hook。消除 SendBox 与引导页中的 IME 处理重复代码。
 */
export const useCompositionInput = () => {
  const isComposing = useRef(false);
  const justComposedRef = useRef(false);
  const [isComposingState, setIsComposingState] = useState(false);

  const compositionHandlers = {
    onCompositionStartCapture: () => {
      isComposing.current = true;
      justComposedRef.current = false;
      setIsComposingState(true);
    },
    onCompositionEndCapture: () => {
      isComposing.current = false;
      setIsComposingState(false);
      // 一帧兜底:覆盖 compositionend 同 tick 先于 Enter keydown 的浏览器。
      justComposedRef.current = true;
      requestAnimationFrame(() => {
        justComposedRef.current = false;
      });
    },
  };

  const isImeActive = useCallback(
    (e: ImeKeyLike) => isImeComposingKey(e, { composing: isComposing.current, justComposed: justComposedRef.current }),
    []
  );

  const createKeyDownHandler = (
    onEnterPress: () => void,
    onKeyDownIntercept?: (e: React.KeyboardEvent) => boolean,
    sendKey: SendKeyMode = 'enter'
  ) => {
    return (e: React.KeyboardEvent) => {
      if (isImeActive(e)) return;
      if (onKeyDownIntercept?.(e)) return;
      if (isSubmitGesture(e, sendKey)) {
        e.preventDefault();
        onEnterPress();
      }
    };
  };

  return {
    isComposing,
    isComposingState,
    compositionHandlers,
    createKeyDownHandler,
    isImeActive,
  };
};
  • Step 4: 跑测试确认通过

Run: cd ui && bun test src/renderer/hooks/chat/useCompositionInput.test.ts Expected: PASS(全部用例)

  • Step 5: 提交
git add ui/src/renderer/hooks/chat/useCompositionInput.ts ui/src/renderer/hooks/chat/useCompositionInput.test.ts
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "fix(chat): 输入法上屏 Enter 防误发送,提取提交手势纯函数"

Task A2:新增 chat.sendKey 配置 + SendBox/引导页接入偏好

Files:

  • Modify: ui/src/common/config/configKeys.tsConfigKeyMap,约 :55 区域)
  • Modify: ui/src/renderer/components/chat/SendBox/index.tsx:984 引入 useConfig、:1714-1733 onKeyDown
  • Modify: ui/src/renderer/pages/guid/components/GuidInputCard.tsx:83-89 用 isImeActive
  • Modify: ui/src/renderer/pages/guid/GuidPage.tsx:288 用 isSubmitGesture

Interfaces:

  • Consumes: A1 的 isSubmitGestureSendKeyMode、hook 的 isImeActivecreateKeyDownHandler(onEnterPress, intercept?, sendKey?)

  • Step 1: 加配置 keyui/src/common/config/configKeys.ts,在 'system.autoPreviewOfficeFiles' 行后新增:

  // 发送键偏好:'enter'=Enter 发送/Shift+Enter 换行(默认);'mod-enter'=Ctrl/⌘+Enter 发送、Enter 换行
  'chat.sendKey': 'enter' | 'mod-enter' | undefined;
  • Step 2: SendBox 接入偏好ui/src/renderer/components/chat/SendBox/index.tsx

在文件顶部 import 区加(与其它 hook 同组):

import { useConfig } from '@renderer/hooks/config/useConfig';

useCompositionInput() 调用处(:984)下方加:

  const [sendKeyPref] = useConfig('chat.sendKey');
  const sendKey = sendKeyPref ?? 'enter';

把 textarea 的 onKeyDown:1714 起)改为传入 sendKey,并让 Mod+Enter steer 拦截仅在 'enter' 模式生效:

              onKeyDown={createKeyDownHandler(
                sendMessageHandler,
                (event) => {
                  if (handleAtFileMenuKeyDown(event) || handleOverlayKeyDown(event) || handleHistoryKeyDown(event)) {
                    return true;
                  }
                  // Mod(Ctrl/Cmd)+Enter steers the draft into the running turn.
                  // Only in 'enter' mode — in 'mod-enter' mode Mod+Enter IS the submit gesture.
                  if (
                    sendKey === 'enter' &&
                    onSteer &&
                    steerAvailable &&
                    event.key === 'Enter' &&
                    !event.shiftKey &&
                    (event.metaKey || event.ctrlKey)
                  ) {
                    event.preventDefault();
                    steerMessageHandler();
                    return true;
                  }
                  return false;
                },
                sendKey
              )}
  • Step 3: 引导页输入卡用健壮 IME 守卫ui/src/renderer/pages/guid/components/GuidInputCard.tsx:83-89
  const { compositionHandlers, isImeActive } = useCompositionInput();
  const textareaAutoSize = isMobile ? { minRows: 2, maxRows: 8 } : { minRows: 2, maxRows: 20 };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (isImeActive(e)) return;
    onKeyDown(e);
  };
  • Step 4: 引导页发送判定按偏好ui/src/renderer/pages/guid/GuidPage.tsx

顶部 import 加:

import { isSubmitGesture } from '@/renderer/hooks/chat/useCompositionInput';
import { useConfig } from '@renderer/hooks/config/useConfig';

handleInputKeyDown 所在组件体内取偏好:

  const [sendKeyPref] = useConfig('chat.sendKey');
  const sendKey = sendKeyPref ?? 'enter';

handleInputKeyDown 末尾的发送分支(:288-292)改为:

      if (isSubmitGesture(event, sendKey)) {
        event.preventDefault();
        if (!guidInput.input.trim()) return;
        send.sendMessageHandler();
      }

并把 sendKey 加入 useCallback 依赖数组(:294)。

  • Step 5: 类型检查 + 提交

Run: cd ui && bun run typecheck Expected: 无报错

git add ui/src/common/config/configKeys.ts ui/src/renderer/components/chat/SendBox/index.tsx ui/src/renderer/pages/guid/components/GuidInputCard.tsx ui/src/renderer/pages/guid/GuidPage.tsx
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(chat): 新增发送键偏好(chat.sendKey)并在会话框/引导页生效"

Task A3:设置面板「发送键」一行 + i18n

Files:

  • Modify: ui/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/index.tsx

  • Modify: ui/src/renderer/services/i18n/locales/en-US/settings.json

  • Modify: ui/src/renderer/services/i18n/locales/zh-CN/settings.json

  • Step 1: 加 i18n key(两文件,紧邻 keepAwake 键)

en-US/settings.json

  "sendKey": "Send with",
  "sendKeyDesc": "Choose which key sends a message. \"Enter\" sends on Enter (Shift+Enter for a new line). \"Ctrl/⌘+Enter\" makes Enter insert a new line and sends only with the modifier.",
  "sendKeyEnter": "Enter",
  "sendKeyModEnter": "Ctrl/⌘+Enter",

zh-CN/settings.json

  "sendKey": "发送快捷键",
  "sendKeyDesc": "选择用哪个键发送消息。「Enter」按 Enter 发送(Shift+Enter 换行);「Ctrl/⌘+Enter」让 Enter 换行、仅在按住修饰键时发送。",
  "sendKeyEnter": "Enter 发送",
  "sendKeyModEnter": "Ctrl/⌘+Enter 发送",
  • Step 2: 设置面板加状态 + 读取 + change 回调SystemModalContent/index.tsx

在其它 useState 旁(约 :55-58)加:

  const [sendKey, setSendKey] = useState<'enter' | 'mod-enter'>('enter');

在启动读取 useEffect:85-90)内加一行:

    setSendKey(configService.get('chat.sendKey') ?? 'enter');

在 change 回调附近(:179-185 样板旁)加:

  const handleSendKeyChange = useCallback((value: 'enter' | 'mod-enter') => {
    setSendKey(value);
    configService.set('chat.sendKey', value).catch(() => {
      setSendKey((prev) => (prev === 'enter' ? 'mod-enter' : 'enter'));
      configService.setLocal('chat.sendKey', value === 'enter' ? 'mod-enter' : 'enter');
    });
  }, []);
  • Step 3: 加 preferenceItems 一行 — 在 preferenceItems 数组(:216 起,建议紧跟 language 项后)插入:
    {
      key: 'sendKey',
      label: t('settings.sendKey'),
      description: t('settings.sendKeyDesc'),
      component: (
        <NomiSelect className='w-200px' value={sendKey} onChange={(v) => handleSendKeyChange(v as 'enter' | 'mod-enter')}>
          <NomiSelect.Option value='enter'>{t('settings.sendKeyEnter')}</NomiSelect.Option>
          <NomiSelect.Option value='mod-enter'>{t('settings.sendKeyModEnter')}</NomiSelect.Option>
        </NomiSelect>
      ),
    },

确认顶部已 import NomiSelect(若无):import NomiSelect from '@renderer/components/base/NomiSelect';(按该文件既有 import 风格;LanguageSwitcher 同目录用法可参照)。

  • Step 4: 类型检查 + i18n 校验 + 提交

Run: cd ui && bun run typecheck 然后(仓库根)bun run check:i18n Expected: 均通过

git add ui/src/renderer/components/settings/SettingsModal/contents/SystemModalContent/index.tsx ui/src/renderer/services/i18n/locales/en-US/settings.json ui/src/renderer/services/i18n/locales/zh-CN/settings.json
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(settings): 系统设置新增「发送快捷键」偏好项"

Part B — 暂停后编辑重发(仅 Nomi、仅最近一条、截断重跑)

Task B1repo 新增 delete_messages_fromkeyset 截断删除)

Files:

  • Modify: crates/backend/nomifun-db/src/repository/conversation.rstrait,加默认体)
  • Modify: crates/backend/nomifun-db/src/repository/sqlite_conversation.rsimpl + 测试)

Interfaces:

  • Produces: async fn delete_messages_from(&self, conv_id: i64, from_created_at: i64, from_id: &str) -> Result<u64, DbError>(删除 (created_at,id) >= (from_created_at, from_id) 的行,返回删除条数)。

  • Step 1: trait 加带默认体的方法conversation.rs,紧邻 delete_messages_by_conversation:115-116)后:

    /// Deletes the message at the `(created_at, id)` keyset cursor (inclusive)
    /// and every newer message in the conversation. Returns the number of rows
    /// deleted. Default no-op so mock repos compile; SQLite overrides it.
    async fn delete_messages_from(
        &self,
        _conv_id: i64,
        _from_created_at: i64,
        _from_id: &str,
    ) -> Result<u64, DbError> {
        Ok(0)
    }
  • Step 2: 写失败测试sqlite_conversation.rsmod tests:801)末尾加:
    #[tokio::test]
    async fn delete_messages_from_removes_cursor_and_newer() {
        let (repo, _db) = setup().await;
        let conv = repo.create(&sample_conversation(SYSTEM_USER_ID)).await.unwrap();

        let mk = |id: &str, created_at: i64| MessageRow {
            id: id.to_string(),
            conversation_id: conv.id,
            msg_id: Some(id.to_string()),
            r#type: "text".to_string(),
            content: r#"{"content":"x"}"#.to_string(),
            position: Some("right".to_string()),
            status: Some("finish".to_string()),
            hidden: false,
            created_at,
        };
        // 三条:t=100,200,300
        repo.insert_message(&mk("m1", 100)).await.unwrap();
        repo.insert_message(&mk("m2", 200)).await.unwrap();
        repo.insert_message(&mk("m3", 300)).await.unwrap();

        // 从 m2 (t=200) 起(含)删除 → 删 m2、m3,留 m1
        let deleted = repo.delete_messages_from(conv.id, 200, "m2").await.unwrap();
        assert_eq!(deleted, 2);

        assert!(repo.get_message(conv.id, "m1").await.unwrap().is_some());
        assert!(repo.get_message(conv.id, "m2").await.unwrap().is_none());
        assert!(repo.get_message(conv.id, "m3").await.unwrap().is_none());
    }
  • Step 3: 跑测试确认失败

Run: cargo test -p nomifun-db delete_messages_from_removes_cursor_and_newer Expected: FAIL(默认体返回 0,断言 deleted == 2 失败)

  • Step 4: SQLite 实现sqlite_conversation.rs,紧邻 delete_messages_by_conversation:459-466)后:
    async fn delete_messages_from(
        &self,
        conv_id: i64,
        from_created_at: i64,
        from_id: &str,
    ) -> Result<u64, DbError> {
        // Keyset 截断:删除 (created_at, id) >= 游标 的所有消息。
        // 命中复合索引 idx_messages_conv_created_id。
        let result = sqlx::query(
            "DELETE FROM messages \
             WHERE conversation_id = ? \
               AND (created_at > ? OR (created_at = ? AND id >= ?))",
        )
        .bind(conv_id)
        .bind(from_created_at)
        .bind(from_created_at)
        .bind(from_id)
        .execute(&self.pool)
        .await?;

        Ok(result.rows_affected())
    }
  • Step 5: 跑测试确认通过

Run: cargo test -p nomifun-db delete_messages_from_removes_cursor_and_newer Expected: PASS

  • Step 6: 提交
git add crates/backend/nomifun-db/src/repository/conversation.rs crates/backend/nomifun-db/src/repository/sqlite_conversation.rs
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(db): 新增 delete_messages_from 按 keyset 游标截断删除消息"

Task B2:引擎 turn 起始锚点 + rewind_last_turn

Files:

  • Modify: crates/agent/nomi-agent/src/engine.rs

Interfaces:

  • Produces: pub fn rewind_last_turn(&mut self) -> bool(把内存 transcript 截断到最后一个 turn 起始,成功 true)。锚点字段 last_turn_start_len: Option<usize>

  • Step 1: 加字段(3 处构造点)

结构体 AgentEngine:192 steering_inbox 后、} 前)加:

    /// transcript 长度锚点:最近一个 turn 的用户消息 push 之前的 messages.len()。
    /// 供 rewind_last_turn 把内存历史回退到最后一个用户 turn 之前。压缩会重写
    /// 整个 messages,使下标失效,故压缩时清空;clear_context 时一并清空。
    /// 仅内存态,不持久化到 session。
    last_turn_start_len: Option<usize>,

new_with_provider 初始化器尾部(:264 steering_inbox: None, 后)加 last_turn_start_len: None,resume_with_provider 初始化器尾部(:339 steering_inbox: None, 后)加 last_turn_start_len: None,。 测试 make_engine 尾部(:1534 steering_inbox: None, 后)加 last_turn_start_len: None,

  • Step 2: 写失败测试engine.rsmod set_config_tests:1371)内加(紧邻 make_engine 后):
    #[test]
    fn rewind_last_turn_truncates_to_marker() {
        use nomi_types::message::{ContentBlock, Message, Role};
        let mut engine = make_engine("rewind");
        // 既有历史:U0, A0
        engine.messages.push(Message::now(Role::User, vec![ContentBlock::Text { text: "u0".into() }]));
        engine.messages.push(Message::now(Role::Assistant, vec![ContentBlock::Text { text: "a0".into() }]));
        // 标记最后一个 turn 起始 = 当前长度(2),再 push U1(被中断的 turn
        engine.last_turn_start_len = Some(engine.messages.len());
        engine.messages.push(Message::now(Role::User, vec![ContentBlock::Text { text: "u1".into() }]));
        assert_eq!(engine.messages.len(), 3);

        assert!(engine.rewind_last_turn());
        assert_eq!(engine.messages.len(), 2); // U1 被回退
        assert!(engine.last_turn_start_len.is_none()); // 锚点被消费

        // 再次回退无锚点 → false
        assert!(!engine.rewind_last_turn());
    }

    #[test]
    fn rewind_last_turn_rejects_stale_marker() {
        let mut engine = make_engine("rewind-stale");
        // 锚点越界(如压缩后未清理的极端情况)→ 拒绝
        engine.last_turn_start_len = Some(5);
        assert!(!engine.rewind_last_turn());
    }
  • Step 3: 跑测试确认失败

Run: cargo test -p nomi-agent rewind_last_turn Expected: FAILrewind_last_turn 不存在,编译错误)

  • Step 4: 实现 rewind + 设/清锚点

clear_context:1305)旁新增方法:

    /// 把内存 transcript 回退到最近一个 turn 的用户消息之前(丢弃最后一个用户
    /// turn 及其后内容),用于"编辑最近一条用户消息并重跑"。成功返回 true;
    /// 无有效锚点(如已被压缩清空)返回 false,调用方应回退处理。
    pub fn rewind_last_turn(&mut self) -> bool {
        let Some(start) = self.last_turn_start_len else {
            return false;
        };
        if start > self.messages.len() {
            self.last_turn_start_len = None;
            return false;
        }
        self.messages.truncate(start);
        self.last_turn_start_len = None;
        self.save_session();
        true
    }

run_inner 用户消息 push 处(:670之前记录锚点:

        self.current_msg_id = msg_id.to_string();
        self.output.emit_stream_start(msg_id);
        self.last_turn_start_len = Some(self.messages.len());
        self.messages.push(Message::now(

压缩重写处(:1158 self.messages = result.messages; 后)清空锚点:

                    self.messages = result.messages;
                    self.last_turn_start_len = None;
                    compacted = true;

clear_context:1306 self.messages.clear(); 后)清空锚点:

    pub fn clear_context(&mut self) {
        self.messages.clear();
        self.last_turn_start_len = None;
        self.compact_state = CompactState::new();
  • Step 5: 跑测试确认通过

Run: cargo test -p nomi-agent rewind_last_turn Expected: PASS(两个用例)

  • Step 6: 提交
git add crates/agent/nomi-agent/src/engine.rs
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(engine): 新增 turn 起始锚点与 rewind_last_turn 回退最后一个用户 turn"

Task B3manager + AgentInstance 暴露 rewind_last_turnNomi-only

Files:

  • Modify: crates/backend/nomifun-ai-agent/src/manager/nomi/agent.rs
  • Modify: crates/backend/nomifun-ai-agent/src/agent_task.rs

Interfaces:

  • Consumes: B2 的 engine.rewind_last_turn()

  • Produces:

    • NomiAgentManager::rewind_last_turn(&self) -> Result<(), AppError>inherent
    • AgentInstance::rewind_last_turn(&self) -> Result<(), AppError>Nomi-only match
  • Step 1: manager 方法manager/nomi/agent.rs,在 inherent impl 块内(与 clear_context/steer 同块,约 :906-921 旁)加:

    /// 回退最后一个用户 turn(编辑最近一条用户消息重跑):先停掉在飞 turn,
    /// 再锁引擎截断到该 turn 起始。无有效锚点(已压缩等)返回 BadRequest。
    pub async fn rewind_last_turn(&self) -> Result<(), AppError> {
        self.request_stop(None, "rewind_last_turn");
        let mut engine = self.engine.lock().await;
        if !engine.rewind_last_turn() {
            return Err(AppError::BadRequest(
                "无法回退上一轮(上下文可能已被压缩),请清空上下文后重试".into(),
            ));
        }
        Ok(())
    }
  • Step 2: AgentInstance 委派(Nomi-onlyagent_task.rs,仿 steer:378-393)在 steer 方法后加:
    /// 回退最后一个用户 turn。仅 Nomi 原生引擎支持;其它外部 agent 上下文不可控,
    /// 返回 BadRequest(前端不会对非 Nomi 暴露入口)。
    pub async fn rewind_last_turn(&self) -> Result<(), AppError> {
        match self {
            Self::Nomi(m) => m.rewind_last_turn().await,
            Self::Acp(_) | Self::OpenClaw(_) | Self::Nanobot(_) | Self::Remote(_) => Err(
                AppError::BadRequest("Edit & resubmit is not supported for this agent type".into()),
            ),
            #[cfg(any(test, feature = "test-support"))]
            Self::Mock(_) => Ok(()),
        }
    }
  • Step 3: 编译确认

Run: cargo build -p nomifun-ai-agent Expected: 成功(无类型/match 缺臂错误)

  • Step 4: 提交
git add crates/backend/nomifun-ai-agent/src/manager/nomi/agent.rs crates/backend/nomifun-ai-agent/src/agent_task.rs
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(agent): manager/AgentInstance 暴露 rewind_last_turn(仅 Nomi)"

Task B4service edit_and_resubmit + 路由

Files:

  • Modify: crates/backend/nomifun-conversation/src/service.rs
  • Modify: crates/backend/nomifun-conversation/src/routes.rs

Interfaces:

  • Consumes: conversation_repo.{get, get_messages_keyset, delete_messages_from}task_manager.get_taskAgentInstance::rewind_last_turnself.send_message

  • Produces:

    • ConversationService::edit_and_resubmit(&self, user_id, conversation_id, message_id: &str, req: SendMessageRequest, task_manager) -> Result<String, AppError>
    • 路由 POST /api/conversations/{id}/messages/{messageId}/edit-resubmit
  • Step 1: service 方法service.rs,在 steer_message 后(约 :1976 之后)新增。校验链:归属 → Nomi → 目标是最近一条 right/text → 取游标 → 拿在飞 agent → rewind → 删 DB → 复用 send_message。

    /// 编辑最近一条用户消息并截断重跑(仅 Nomi)。
    #[tracing::instrument(skip_all, fields(user_id = %user_id, conversation_id = %conversation_id, message_id = %message_id))]
    pub async fn edit_and_resubmit(
        &self,
        user_id: &str,
        conversation_id: &str,
        message_id: &str,
        req: SendMessageRequest,
        task_manager: &Arc<dyn IWorkerTaskManager>,
    ) -> Result<String, AppError> {
        if req.content.trim().is_empty() {
            return Err(AppError::BadRequest("Message content must not be empty".into()));
        }
        let conv_id = parse_conv_id(conversation_id)?;

        // 1. 归属校验
        let row = self
            .conversation_repo
            .get(conv_id)
            .await?
            .filter(|r| r.user_id == user_id)
            .ok_or_else(|| AppError::NotFound(format!("Conversation {conversation_id} not found")))?;

        // 2. 仅 Nomi
        if row.r#type != "nomi" {
            return Err(AppError::BadRequest("Edit & resubmit is only supported for Nomi conversations".into()));
        }

        // 3. 目标必须是最近一条用户(right/text)消息(保证引擎"回退最后一个 turn"
        //    与 DB"删除该条及其后"对齐)。
        let recent = self
            .conversation_repo
            .get_messages_keyset(conv_id, None, 50)
            .await?;
        let latest_user = recent
            .items
            .iter()
            .find(|m| m.position.as_deref() == Some("right") && m.r#type == "text");
        let Some(target) = latest_user else {
            return Err(AppError::BadRequest("No editable user message found".into()));
        };
        if target.id != message_id {
            return Err(AppError::BadRequest("Only the most recent user message can be edited".into()));
        }
        let (from_created_at, from_id) = (target.created_at, target.id.clone());

        // 4. 取在飞 agent 并回退最后一个 turn(内部会先停掉在飞 turn)。
        let agent = self.task(conversation_id)?;
        agent.rewind_last_turn().await?;

        // 5. 截断 DB:删除目标(含)及其后所有消息。
        self.conversation_repo
            .delete_messages_from(conv_id, from_created_at, &from_id)
            .await?;

        // 6. 复用正常发送:重新插入用户消息行 + 起新 turn。
        self.send_message(user_id, conversation_id, req, task_manager).await
    }

说明:self.task(conversation_id) 在无在飞 agent 时返回 NotFound;前端在该错误下提示用户重试(暂停后 agent 仍存活,常态可用)。

  • Step 2: 路由注册routes.rsconversation_routes:22)内,messages/{messageId} 路由旁加:
        .route(
            "/api/conversations/{id}/messages/{messageId}/edit-resubmit",
            post(edit_resubmit),
        )
  • Step 3: 路由 handlerroutes.rs,仿 send_msg:197-216)加(注意双 Path 参数):
async fn edit_resubmit(
    State(state): State<ConversationRouterState>,
    Extension(user): Extension<CurrentUser>,
    Path((id, message_id)): Path<(String, String)>,
    body: Result<Json<SendMessageRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<SendMessageResponse>>), AppError> {
    let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
    let msg_id = state
        .service
        .edit_and_resubmit(&user.id, &id, &message_id, req, &state.task_manager)
        .await?;
    Ok((
        StatusCode::ACCEPTED,
        Json(ApiResponse::ok(SendMessageResponse { msg_id })),
    ))
}
  • Step 4: 写测试service 层,复用既有 mock;验证"非最近一条 → BadRequest"与"非 Nomi → BadRequest"

crates/backend/nomifun-conversation/src/service_test.rs 末尾,仿既有 service 测试新增两条:一条插入一个非 Nomi 会话调用 edit_and_resubmit 断言 Err(BadRequest);一条 Nomi 会话但传入非最新 message_id 断言 Err(BadRequest)。(沿用该文件既有 mock 构造 service 的 helper —— 实现时参照文件内最近的 send_message/steer_message 测试样板复制其 service 搭建部分。)

  • Step 5: 跑测试 + 编译

Run: cargo test -p nomifun-conversation edit_and_resubmit Expected: PASS(两条新测试),且 cargo build -p nomifun-conversation 成功

  • Step 6: 提交
git add crates/backend/nomifun-conversation/src/service.rs crates/backend/nomifun-conversation/src/routes.rs crates/backend/nomifun-conversation/src/service_test.rs
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(conversation): 新增 edit_and_resubmit 服务与 /edit-resubmit 路由(仅 Nomi、仅最近一条)"

Task B5:前端 ipcBridge + emitter 事件

Files:

  • Modify: ui/src/common/adapter/ipcBridge.ts
  • Modify: ui/src/renderer/utils/emitter.ts

Interfaces:

  • Produces:

    • ipcBridge.conversation.editResubmit.invoke({ conversation_id, msg_id, input, files? }) -> { msg_id }
    • emitter 事件 'sendbox.edit': [{ msgId: string; createdAt: number; content: string }]
  • Step 1: ipcBridge 方法ipcBridge.ts,紧邻 steer:270-277)后加:

  editResubmit: httpPost<ISendMessageResult, { conversation_id: number; msg_id: string; input: string; files?: string[] }>(
    (p) => `/api/conversations/${p.conversation_id}/messages/${p.msg_id}/edit-resubmit`,
    (p) => ({
      content: p.input,
      files: p.files,
    })
  ),
  • Step 2: emitter 事件类型emitter.ts,在 'sendbox.reply.clear':66)后加:
  'sendbox.edit': [{ msgId: string; createdAt: number; content: string }]; // edit a sent user message
  • Step 3: 类型检查 + 提交

Run: cd ui && bun run typecheck Expected: 通过

git add ui/src/common/adapter/ipcBridge.ts ui/src/renderer/utils/emitter.ts
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(chat): ipcBridge.editResubmit 与 sendbox.edit 事件"

Task B6hooks 新增 useRemoveMessagesFrom

Files:

  • Modify: ui/src/renderer/pages/conversation/Messages/hooks.ts

Interfaces:

  • Produces: export const useRemoveMessagesFrom: () => (createdAt: number) => void(移除本地 created_at >= createdAt 的消息)。

  • Step 1: 实现hooks.ts,紧邻 useRemoveMessageByMsgId:401-410)后加:

export const useRemoveMessagesFrom = () => {
  const update = useUpdateMessageList();

  return useCallback(
    (createdAt: number) => {
      update((list) => list.filter((message) => (message.created_at ?? 0) < createdAt));
    },
    [update]
  );
};
  • Step 2: 类型检查 + 提交

Run: cd ui && bun run typecheck Expected: 通过

git add ui/src/renderer/pages/conversation/Messages/hooks.ts
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(chat): 新增 useRemoveMessagesFrom 本地按时间截断消息"

Task B7SendBox「编辑模式」

Files:

  • Modify: ui/src/renderer/components/chat/SendBox/index.tsx

Interfaces:

  • Consumes: emitter 'sendbox.edit';新增可选 prop onEditResubmit?: (msgId: string, message: string) => Promise<void>

  • Produces: 编辑模式下提交走 onEditResubmit,并显示"编辑中"提示条。

  • Step 1: 加 prop 与状态 — 在 SendBox props 类型里(onSteer 旁,:164 区域)加:

  /** When provided (Nomi only), enables "edit a sent message" mode: the message text is
   *  recalled into the composer and submitting calls this instead of onSend. */
  onEditResubmit?: (msgId: string, message: string) => Promise<void>;

在解构参数列表加 onEditResubmit,。在状态区(replyQuote 旁,:253)加:

  const [editingMsgId, setEditingMsgId] = useState<string | null>(null);
  const editPrevDraftRef = useRef<string | null>(null);
  • Step 2: 监听 sendbox.edit — 在其它 useAddEventListener:268-269)旁加:
  useAddEventListener(
    'sendbox.edit',
    (payload) => {
      if (!onEditResubmit) return;
      editPrevDraftRef.current = latestInputRef.current;
      setEditingMsgId(payload.msgId);
      setReplyQuote(null);
      setInputRef.current(payload.content);
      requestAnimationFrame(() => {
        const textarea = containerRef.current?.querySelector('textarea');
        if (textarea instanceof HTMLTextAreaElement) {
          textarea.focus();
          const caret = textarea.value.length;
          textarea.setSelectionRange(caret, caret);
        }
      });
    },
    [onEditResubmit]
  );
  • Step 3: 取消编辑 helper + 提交分流 — 在 sendMessageHandler:1234)开头分流;并加取消函数:
  const cancelEdit = () => {
    setEditingMsgId(null);
    const prev = editPrevDraftRef.current ?? '';
    editPrevDraftRef.current = null;
    setInput(prev);
  };

sendMessageHandler 函数体最前(if (isUploading) return; 之后)加:

    if (editingMsgId && onEditResubmit) {
      if (!input.trim()) return;
      const finalMessage = input;
      const targetId = editingMsgId;
      setEditingMsgId(null);
      editPrevDraftRef.current = null;
      setInput('');
      setIsLoading(true);
      onEditResubmit(targetId, finalMessage)
        .catch(() => {})
        .finally(() => setIsLoading(false));
      return;
    }
  • Step 4: 编辑提示条 UI — 在 replyQuote 预览卡块(:1561-1577)后加:
          {editingMsgId && (
            <div className='flex items-center gap-10px mb-8px px-12px py-8px rd-10px bg-fill-1 b-1 b-solid b-border-2'>
              <span className='text-13px text-t-primary'>{t('conversation.editMessage.banner')}</span>
              <div
                className='ml-auto flex-shrink-0 p-2px rd-full cursor-pointer hover:bg-fill-3 transition-colors'
                onClick={cancelEdit}
                style={{ lineHeight: 0 }}
              >
                <CloseSmall theme='outline' size='14' />
              </div>
            </div>
          )}
  • Step 5: 类型检查 + 提交

Run: cd ui && bun run typecheck Expected: 通过

git add ui/src/renderer/components/chat/SendBox/index.tsx
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(chat): SendBox 支持编辑模式(回填+提示条+提交分流)"

Task B8NomiSendBox 接 onEditResubmit + 还原附件

Files:

  • Modify: ui/src/renderer/pages/conversation/platforms/nomi/NomiSendBox.tsx

Interfaces:

  • Consumes: ipcBridge.conversation.editResubmituseRemoveMessagesFromemitter('sendbox.edit') 由 MessageText 触发。

  • Produces: <SendBox onEditResubmit={handleEditResubmit} />

  • Step 1: 引入 hook — 顶部按既有 import 风格加 useRemoveMessagesFrom(与 removeMessageByMsgId 同源 Messages/hooks),并在组件体取:

  const removeMessagesFrom = useRemoveMessagesFrom();
  • Step 2: handleEditResubmit — 仿 executeCommand:223)加:
  const handleEditResubmit = useCallback(
    async (msgId: string, message: string) => {
      const filesToSend = collectSelectedFiles(uploadFile, atPath);
      clearFiles();
      emitter.emit('nomi.selected.file.clear');
      setWaitingResponse(true);
      try {
        const res = await ipcBridge.conversation.editResubmit.invoke({
          conversation_id,
          msg_id: msgId,
          input: buildDisplayMessage(message, filesToSend, workspacePath),
          files: filesToSend,
        });
        // 截断点之后本地立即移除旧消息,避免 DB 对齐前的闪烁。
        // 用新插入用户消息的 msg_id 之外,统一靠 history.refresh 兜底。
        emitter.emit('chat.history.refresh');
        if (filesToSend.length > 0) emitter.emit('nomi.workspace.refresh');
        setActiveMsgId(res.msg_id);
      } catch (error) {
        setWaitingResponse(false);
        Message.error(getConversationRuntimeWorkspaceErrorMessage(error, t));
        throw error;
      }
    },
    [atPath, conversation_id, uploadFile, workspacePath, setActiveMsgId, setWaitingResponse, t]
  );

注:removeMessagesFrom 用于进入编辑时即时清旧消息(见 Step 3 的 MessageText 触发链);此处提交后以 chat.history.refresh 与后端对齐为准。

  • Step 3: 传给 SendBox — 在 <SendBox ... />:696-801)属性中、onSend={onSendHandler} 旁加:
        onEditResubmit={handleEditResubmit}
  • Step 4: 类型检查 + 提交

Run: cd ui && bun run typecheck Expected: 通过

git add ui/src/renderer/pages/conversation/platforms/nomi/NomiSendBox.tsx
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(nomi): NomiSendBox 接入 editResubmit 提交编辑重跑"

Task B9MessageText 用户气泡「编辑」按钮(Nomi + 最近一条 + idle

Files:

  • Modify: ui/src/renderer/pages/conversation/Messages/components/MessageText.tsx
  • Modify: ui/src/renderer/services/i18n/locales/en-US/conversation.json
  • Modify: ui/src/renderer/services/i18n/locales/zh-CN/conversation.json

Interfaces:

  • Consumes: useConversationContextSafe()(取 typeconversation_idworkspace)、useMessageList()(判断最近一条)、emitter('sendbox.edit')useRemoveMessagesFromparseFileMarker

  • 触发:点编辑 → 本地移除该条及其后 → emit 'sendbox.edit'

  • Step 1: i18n(两文件,conversation 命名空间)

en-US/conversation.json 加:

  "editMessage": { "banner": "Editing message — sending will re-run from here", "action": "Edit" },

zh-CN/conversation.json 加:

  "editMessage": { "banner": "正在编辑消息——发送后将从这里重新生成", "action": "编辑" },
  • Step 2: MessageText 计算可编辑 + 编辑按钮 — 在 MessageText 组件体内(isUserMessage 定义后,:120 区域)加:
  const conversationCtx = useConversationContextSafe();
  const messageList = useMessageList();
  const removeMessagesFrom = useRemoveMessagesFrom();

  // 仅 Nomi、用户文本消息、且为最近一条用户消息时可编辑。
  const isLatestUserMessage = useMemo(() => {
    if (!isUserMessage) return false;
    const lastRight = [...messageList].reverse().find((m) => m.position === 'right' && m.type === 'text');
    return lastRight?.msg_id != null && lastRight.msg_id === message.msg_id;
  }, [isUserMessage, messageList, message.msg_id]);

  const canEdit = conversationCtx?.type === 'nomi' && isUserMessage && message.type === 'text' && isLatestUserMessage;

  const handleEdit = () => {
    if (!message.msg_id || !message.created_at) return;
    const { text } = parseFileMarker(typeof message.content?.content === 'string' ? message.content.content : '');
    removeMessagesFrom(message.created_at);
    emitter.emit('sendbox.edit', { msgId: message.msg_id, createdAt: message.created_at, content: text });
  };

  const editButton = canEdit ? (
    <Tooltip content={t('conversation.editMessage.action', { defaultValue: 'Edit' })}>
      <div
        className='p-4px rd-4px cursor-pointer hover:bg-3 transition-colors opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto'
        onClick={handleEdit}
        style={{ lineHeight: 0 }}
      >
        <Edit theme='outline' size='16' fill={iconColors.secondary} />
      </div>
    </Tooltip>
  ) : null;

顶部 import 补:import { Edit } from '@icon-park/react';emitter@/renderer/utils/emitter)、useConversationContextSafeuseMessageListuseRemoveMessagesFrom(按文件既有 import 路径风格)。

  • Step 3: 放进悬浮工具行 — 把 hover 工具行(:232-245)里 {copyButton} 旁加上 {editButton}
            {copyButton}
            {editButton}
  • Step 4: 类型检查 + i18n 校验 + 提交

Run: cd ui && bun run typecheck 然后(仓库根)bun run check:i18n Expected: 均通过

git add ui/src/renderer/pages/conversation/Messages/components/MessageText.tsx ui/src/renderer/services/i18n/locales/en-US/conversation.json ui/src/renderer/services/i18n/locales/zh-CN/conversation.json
git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "feat(chat): 用户消息悬浮工具行新增「编辑」按钮(仅 Nomi+最近一条)"

Task B10:端到端联调与回归校验

Files: 无新增(验证)

  • Step 1: 全量类型检查 + i18n + 主题契约

Run: cd ui && bun run typecheck ;仓库根 bun run check:i18n Expected: 通过

  • Step 2: Rust 相关 crate 测试

Run: cargo test -p nomifun-db -p nomi-agent -p nomifun-conversation Expected: 全绿(含 B1/B2/B4 新测试)

  • Step 3: 手动联调(devbun run dev 后在 Nomi 会话:

    • 发消息→暂停→悬浮最近一条用户气泡→点编辑→输入框回填且出现"编辑中"条→改文本→Enter 提交→旧的被中断回复消失、新内容重新生成。
    • 切到 mod-enter 偏好:会话框裸 Enter 换行、Ctrl/⌘+Enter 发送;中文输入法上屏 Enter 不误发送。
    • 取消编辑恢复原草稿;非 Nomi 会话与非最近一条用户消息无编辑入口。
  • Step 4: 收尾提交(如有联调微调)

git add -A && git -c user.name=nomifun -c user.email=rika00@qq.com commit --author="nomifun <rika00@qq.com>" -m "chore(chat): 编辑重发与发送键偏好联调收尾"

Self-Review 记录

  • 覆盖:问题一(IME 守卫 A1 / 发送键偏好 A2 / 设置 UI A3);问题二(repo B1 / 引擎 B2 / manager B3 / service+route B4 / bridge+emitter B5 / hooks B6 / SendBox B7 / NomiSendBox B8 / MessageText B9 / 联调 B10)。设计文档各节均有对应任务。
  • 类型一致性isSubmitGesture/isImeComposingKey/SendKeyModeA1)→ A2 使用一致;delete_messages_from(conv_id,i64,&str)B1)→ B4 调用一致;rewind_last_turnB2→B3→B4)签名一致;editResubmit 参数 {conversation_id,msg_id,input,files?}B5)→ B8 调用一致;'sendbox.edit' 负载 {msgId,createdAt,content}(B5)→ B7 监听 / B9 触发一致。
  • 已知边界:编辑需在飞 agent(暂停后常态存活);agent 被回收/重启后 edit_and_resubmit 返回 NotFound(前端提示重试)。锚点被压缩清空时 rewind_last_turn 返回 false → service 返回 BadRequest。截断点之后 artifacts 不清理(MVP)。