Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,184 @@
# 固定式计划栏(Pinned Plan Bar)设计
- 日期:2026-06-28
- 状态:已批准,待实现
- 范围:会话页 UI`ui/src/renderer/pages/conversation`
## 背景与问题
会话过程中 Agent 产出的「计划 / 待办清单」(`type: 'plan'` 消息,组件
`Messages/components/MessagePlan.tsx`)目前作为普通消息内联渲染在滚动消息流里。
随着会话继续,它会被新消息推到上方、滚出可视区进入历史记录,用户无法随时看到
当前计划进度,体验不佳。
现状关键事实:
- 计划数据形态:`IMessagePlan.content.entries: Array<{ content: string;
status: 'pending' | 'in_progress' | 'completed'; priority? }>`(见
`common/types/platform/acpTypes.ts` 的 `PlanUpdate`)。
- 计划消息由 `transformMessage``common/chat/chatLib.ts`)构造,**所有平台**
acp / nomi / nanobot / openclaw / remote)都可能产生。
- 计划更新逻辑(`Messages/hooks.ts` 的 `composeMessageWithIndex`):同 `msg_id`
的新计划会替换旧内容并被移动到列表末尾,因此「列表中最后一条 plan」即为当前计划。
- 布局:每个平台 chat wrapper 结构统一为
```
<div className='flex-1 flex flex-col px-20px min-h-0'>
<FlexFullContainer><MessageList/></FlexFullContainer> // 填满剩余高度
{!hideSendBox && <XSendBox/>} // 贴底发送框
</div>
```
`FlexFullContainer` 将子节点以 `absolute size-full` 填充。
## 目标
把当前计划固定为一个**贴在输入框上方**的常驻栏:
- 默认折叠,仅显示一行进度摘要;点击展开完整清单。
- 滚动消息流不再重复显示该计划(去重)。
- 适用于所有会话类型。
非目标(YAGNI):
- 不做计划的编辑 / 手动勾选。
- 不做多计划并存的切换器(一个会话以「最新计划」为准)。
- 不做「智能自动展开 / 完成后自动折叠」(用户选择默认折叠 + 手动切换)。
- 不改动后端 / `hooks.ts` 的计划合并与排序逻辑。
## 用户已确认的决策
1. **位置**:紧贴输入框上方(消息区与发送框之间)。
2. **折叠默认态**:默认折叠,显示进度摘要,点击展开。
3. **消息流去重**:计划只在固定栏显示,从滚动消息流中移除。
## 方案
采用「独立 `PinnedPlan` 组件 + 各平台 wrapper 插入 + `MessageList` 过滤」的方案
(隔离性好、对滚动组件零侵入;代价是 5 处一行相同插入)。
### 1. 新组件 `Messages/components/PinnedPlan.tsx`
职责:渲染当前计划的固定栏。自包含,无 plan 时不渲染。
- 数据来源:`useMessageList()`(组件位于 `MessageListProvider` 内,各平台 chat
wrapper 均被 `HOC.Wrapper(MessageListProvider, ...)` 包裹,满足条件)。
- **纯逻辑抽离**:把「从消息列表派生固定栏数据」抽成纯函数 `derivePinnedPlan(list)`
返回 `{ entries, done, total } | null`,与组件解耦以便单测(见「测试」)。建议放在
同目录 `pinnedPlanModel.ts`。
- 选取当前计划:从列表末尾向前找第一条 `type === 'plan'` 的消息,断言为
`IMessagePlan`。
- 隐藏条件:无 plan,或 `entries.length === 0` → 返回 `null`。
- 进度计算:`done = entries.filter(e => e.status === 'completed').length`
`total = entries.length`。
- 组件消费 `derivePinnedPlan(useMessageList())`,为 `null` 时返回 `null`(隐藏)。
- 本地状态:`expanded`**初始 `false`(折叠)**。
- 折叠态(一行,整行可点击切换):
- 「待办列表」徽标(复用 `messages.planTodoList`
- 进度文本(`messages.planProgress`,含 `done` / `total`
- 一条细进度条(`done/total` 宽度)
- 展开 / 折叠箭头(`IconRight` / `IconDown`
- 展开态:在摘要行下方渲染完整 `entries` 列表,条目图标按状态区分:
- `completed`:实心对勾(沿用现 `MessagePlan` 的 `IconCheckCircle` 绿色)
- `in_progress`:进行中样式(高亮 / 半填充圈,与 pending 区分)
- `pending`:空心圈
- 列表容器 `max-h-[30vh] overflow-y-auto`,避免长清单把输入框顶出屏幕。
- 样式:顶部分隔线 + 轻背景(`--color-fill-1` 等主题变量,过 `check:theme`),
宽度与消息列 / 发送框一致(`md:max-w-780px mx-auto`),`shrink-0`。
### 2. `MessageList.tsx` 去重
在 `processedList` 构建循环顶部(与 `available_commands` 跳过同处)加入:
```ts
if (message.type === 'plan') continue;
```
计划不再进入渲染流。原始 `list`(含 plan)保持不变,`PinnedPlan` 仍可读取。
`useAutoScroll`keyed by `messages: list`、`itemCount: processedList.length`
不再因计划移动到末尾而抖动。
### 3. 清理内联计划组件
内联渲染移除后 `MessagePlan.tsx` 不再被使用:
- 将其条目渲染逻辑并入 `PinnedPlan`(展开态)。
- 删除 `Messages/components/MessagePlan.tsx`。
- 移除 `MessageList.tsx` 中 `MessagePlan` 的 import 与 `MessageItem` 的
`case 'plan'` 分支(由 step 2 的过滤兜底,计划永不到达 `renderItem`,无死代码)。
`hooks.ts` 中计划合并 / 移到末尾的逻辑**保持不变**(仅变为不可见,固定栏读取
其最新内容)。
### 4. 插入点
在以下 5 个 wrapper 中,于 `</FlexFullContainer>` 之后、发送框之前插入
`<PinnedPlan />`
- `platforms/acp/AcpChat.tsx`
- `platforms/nomi/NomiChat.tsx`
- `platforms/nanobot/NanobotChat.tsx`
- `platforms/openclaw/OpenClawChat.tsx`
- `platforms/remote/RemoteChat.tsx`
`PinnedPlan` 无条件渲染(不受 `hideSendBox` 影响);无 plan 时自身返回 `null`
不占布局。
### 5. i18n
- 复用:`messages.planTodoList`。
- 新增:`messages.planProgress`
- en-US`"{{done}}/{{total}} done"`
- zh-CN`"已完成 {{done}}/{{total}}"`
- 同步 en-US 与 zh-CN 两套 `messages.json`,运行 `bun run gen:i18n` 更新类型,
过 `check:i18n`。
## 数据流
```
backend update ──► transformMessage ──► addOrUpdateMessage
│ │
│ ▼
│ useMessageList() 列表(含最新 plan
│ 同 msg_id 计划被移到末尾)
│ │
├──────────────► MessageList: processedList 过滤掉 plan(不内联)
│ │
└──────────────► PinnedPlan: 取末尾最新 plan → 固定栏渲染
```
## 边界情形
- 无 plan / `entries` 为空:固定栏隐藏(`null`)。
- 窗口化历史(nomi,初始仅加载最新窗口):若计划早于已加载窗口则暂不显示;
活跃计划必在近窗口内,可接受。
- `hideSendBox` 锁定 / 嵌入面板:固定栏仍显示(只读信息)。
- 长清单:展开态 `max-h-[30vh]` 内部滚动,不挤压输入框。
## 测试
项目 UI 测试约定为 **`bun:test` + 纯逻辑测试**(无 testing-library / jsdom
现有 `.test.tsx` 也仅测纯函数、不挂载 DOM)。因此对 `derivePinnedPlan` 纯函数
做单测(`pinnedPlanModel.test.ts``import { describe, expect, test } from 'bun:test'`):
- 列表无 plan → 返回 `null`。
- `entries` 为空 → 返回 `null`。
- 单条 plan → `done` / `total` 计数正确(含 `in_progress` 不计入 done)。
- 多条 plan → 取最后一条(最新)。
折叠 / 展开等交互行为无 DOM 测试框架支撑,**通过手动验证**(运行应用观察固定栏
默认折叠、点击展开 / 折叠、计划更新时进度刷新、无 plan 时隐藏)。
回归校验:`bun run check`typecheck + i18n + theme)通过;`bun test` 现有用例
不回归。
## 改动清单
- 新增:`Messages/components/PinnedPlan.tsx`
- 新增:`Messages/components/pinnedPlanModel.ts`(纯函数 `derivePinnedPlan`
- 新增:`Messages/components/pinnedPlanModel.test.ts``bun:test`
- 修改:`Messages/MessageList.tsx`(过滤 plan、移除 MessagePlan import/case
- 修改:5 个平台 wrapper(各插入一行 `<PinnedPlan />`
- 修改:`locales/en-US/messages.json`、`locales/zh-CN/messages.json`
- 删除:`Messages/components/MessagePlan.tsx`
- 生成:i18n 类型(`gen:i18n`
规模较小,在主流程直接实现,无需 Workflow 编排。
@@ -0,0 +1,89 @@
# 需求列表排序 + 体验打磨 设计
日期:2026-06-28
状态:已批准,实施中
## 背景与问题
用户反馈需求平台列表页(需求工作区 `WorkspacePage`,卡片行式列表)使用体验差,期望具备:ID 排序、指定字段排序、批量删除、指定字段过滤、分页;并指出分页图标配色饱和度过高、不美观。
探索结论:该列表**已具备** 标签/状态/关键词搜索筛选、服务端分页(Arco `Pagination`)、批量删除(勾选行 + 批量操作条)。**真正缺失的是排序**——仓储层 `list()``ORDER BY` 写死为 `sort_seq ASC, priority DESC, created_at ASC`,且 `ListRequirementsParams` / `ListRequirementsQuery` 无任何排序参数。分页配色直接采用 Arco 默认主色(高饱和),无自定义覆盖;`Pagination` 全应用仅 `RequirementListView.tsx` 一处使用。
## 范围(用户确认)
- 核心:新增**排序**——卡片行外观不变,在筛选行右侧加「排序字段下拉 + 升/降序切换」。字段:ID、创建时间、更新时间、状态。
- 打磨:分页激活态/箭头配色调柔和;批量删除新增「全选本页 / 清除」;分页大数据量开启快速跳页。
- 维持现状:筛选字段不扩展(标签/状态/搜索);不改回表格;分页配色改动作用域限于需求列表,不全局。
## 架构与数据流
排序为**服务端排序**(只排当前页无意义)。新增查询参数 `order_by` + `order`,沿现有链路打通:
```
UI 排序下拉/升降序
→ WorkspacePage orderBy/order state
→ useRequirements({ ..., order_by, order })
→ ipcBridge.requirements.list(拼进 URL query
→ axum list_requirements → ListRequirementsQuery{ order_by, order }
→ requirement_service.list → ListRequirementsParams{ order_by, order }
→ SqliteRequirementRepository::list → 白名单 ORDER BY <col> <dir>[, id <dir>]
```
UI 列表请求走 HTTP`httpGet``fetch(baseUrl + path)`,桌面/网页均透传 query 到 axum)。gateway 的 `caps_requirement.rs` 仅服务 AI 工具 `nomi_requirement_list`,不在 UI 列表路径上。
### 安全
`order_by` 在仓储层用**白名单**映射为真实列名(`id|created_at|updated_at|status`),非法值/缺省回退默认队列序;`order` 仅接受 `asc|desc`,缺省 `desc`。绝不把用户输入拼进 SQL。
### 稳定分页
按非唯一列(如 `status`)排序时追加 `id <同向>` 作为最终 tiebreaker,保证翻页确定、无重复/漏项。`id` 唯一,无需 tiebreaker。
### 默认行为不变
排序下拉默认值「默认顺序」时不发送 `order_by/order`,后端保持 `sort_seq ASC, priority DESC, created_at ASC`(AutoWork 队列序)。看板视图始终用默认序,不受影响。
## 改动清单
### 后端(Rust
| 文件 | 改动 |
|---|---|
| `nomifun-api-types/src/requirement.rs` | `ListRequirementsQuery``order_by: Option<String>``order: Option<String>``#[serde(default)]` |
| `nomifun-db/src/repository/requirement.rs` | `ListRequirementsParams``order_by``order` 字段(结构体已 `#[derive(Default)]` |
| `nomifun-db/src/repository/sqlite_requirement.rs` | 新增 `build_order_clause(order_by, order)` 白名单构造;`list()` 用它替换写死的 ORDER BY;新增单测 |
| `nomifun-requirement/src/service.rs` | `list()``query.order_by/order` 映射进 `ListRequirementsParams` |
| `nomifun-gateway/src/caps_requirement.rs` | 两处 `ListRequirementsQuery { .. }` 字面量补 `order_by: None, order: None`(仅编译兼容) |
`build_order_clause` 逻辑:
- 方向:`asc``ASC``desc``DESC`,其余→`DESC`
- 列:`id|created_at|updated_at|status` 命中 → 对应列;否则(含 None)→ 返回默认队列序整句。
- `id``ORDER BY id <dir>`;其它命中列 → `ORDER BY <col> <dir>, id <dir>`
### 前端(TS / React
| 文件 | 改动 |
|---|---|
| `ui/src/common/adapter/ipcBridge.ts` | `IListRequirementsParams``order_by?: RequirementOrderBy``order?: 'asc'\|'desc'`query 构造补两行;导出 `RequirementOrderBy` 类型 |
| `WorkspacePage/index.tsx` | 新增 `orderBy`/`order` 本地 state(不入 URL,变更重置 page=1);`selectAllOnPage`/`clearSelection` 处理;传参给 `useRequirements``RequirementFilters``RequirementListView` |
| `WorkspacePage/RequirementFilters.tsx` | 右侧加排序字段 `Select`(默认顺序/ID/创建/更新/状态)+ 升降序切换 `Button`(选默认顺序时禁用) |
| `WorkspacePage/RequirementListView.tsx` | 行上方加纤细 header:全选本页 `Checkbox`indeterminate+「共 N 条 / 已选 M / 清除」;`Pagination``className='requirements-pagination'``showJumper` |
| `ui/src/renderer/styles/arco-override.css` | 追加 `.requirements-pagination` 作用域样式:激活页码改柔和填充 + text-1 文字,箭头用 text-2,hover 轻微提亮(明暗主题均用 token) |
### i18n
`requirements.json`zh-CN + en-US)新增:
- `sort`: `label / default / byId / byCreatedAt / byUpdatedAt / byStatus / asc / desc`
- `selection`: `selectAllPage / clear / totalCount({{count}}) / selectedCount({{count}})`
随后 `bun run gen:i18n` 重新生成 `i18n-keys.d.ts``bun run check:i18n` 校验。
## 测试与验证
- 仓储层单测(in-memory sqlite,沿用现有模式):各排序字段升/降序结果顺序、`status` 排序的 `id` tiebreaker 稳定性、非法 `order_by` 回退默认、与过滤/分页组合。
- 前端:`bun run typecheck``bun run check`i18n/theme 契约)。
- 人工:实跑确认排序生效、分页配色变柔和、全选本页/清除可用。
## 范围之外
扩展筛选字段、列表改回表格、分页配色全局化——本次均不做。
@@ -0,0 +1,154 @@
# 会话输入框交互优化:Enter 发送防误触 + 暂停后编辑重发
- 日期:2026-06-29
- 范围:UI`ui/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 同时被会话框 `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 先于 Enter `keydown`"的浏览器(同一物理按键,间隔≈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>`**与 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
- **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_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`
- **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)。
- **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` 先于 Enter `keydown``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 编辑按钮显示条件。
## 不在本次范围
- 编辑中间任意消息(受架构约束,需忠实重建,本质不可行)。
- 非 Nomi 平台的编辑重发。
- 助手消息的"重新生成"按钮。
- 截断点之后 artifacts 的清理。
@@ -0,0 +1,129 @@
# 桌面伙伴记忆:共享/私有作用域 + 编辑 + 多入口 设计
> 状态:已确认(方案 A 整体交付)。日期:2026-06-29。
## 背景与问题
桌面伙伴的记忆有两套互不相干的系统:
| 系统 | 存储 | 类别 | 用途 |
|---|---|---|---|
| **伙伴记忆**(本设计目标) | SQLite `companion_memories``crates/backend/nomifun-companion` | profile/preference/knowledge/episode/task/affective 六类 | 注入伙伴人格 prompt + 对话中 `recall_memories` |
| Agent 编码记忆 | 文件 `MEMORY.md``crates/agent/nomi-memory` | user/feedback/project/reference | Claude-Code 式长期记忆,不在本设计范围 |
排查发现"找不到记忆编辑入口"是三个问题叠加:
1. **入口埋得深**:记忆 UI`MemoriesTab.tsx`)藏在 nomi 页"共享(Shared)"域下,页面默认落"伙伴/概览"域,需先切换无"记忆"字样的域单选才看得到(`nomi/index.tsx:53,167-171`)。
2. **悬浮伙伴到不了记忆**:悬浮窗右键菜单只有 4 项(打开对话/打开设置/清除未读/隐藏),"打开设置"跳 `tab=settings` 而非 memories`companionNativeMenu.ts:16-23``companion/index.tsx:1304-1321`)。
3. **没有编辑按钮**`MemoriesTab` 只能 增/置顶/归档/删除,内容是只读 `<div>``MemoriesTab.tsx:146`)。但 `updateMemory` IPC 已支持 `content?`,后端 PUT 已用 COALESCE 落库(`store.rs` update_memory)——所以"编辑内容"本是纯前端缺口。
附带发现的代码设计问题(本设计一并修复):
- **脱敏不对称**`insert_memory``nomi_redact::redact_secrets``update_memory` 不跑。
- **更新无内容校验**`add_memory` trim/拒空,`update_memory` 不会。
- **事件不对称**:只有 `companion.memory-created` 且只在对话保存路径发;HTTP 新增/编辑/删除都不发事件,前端只监听 created。
- **作用域列是死列**`companion_memories``scope_kind TEXT DEFAULT 'user'` + `scope_companion_id TEXT`v2→v3 迁移加),但 struct 不含、`row_to_memory` 不读、所有写/读/注入/recall 都不用 → 记忆事实上全局共享。
- **`companion_skills` 已把同一套作用域机制完整接通**`scope_kind` `'user'`/`'companion'` + `scope_companion_id` `''`=共享,查询 `WHERE scope_companion_id=? OR scope_kind='user'``SkillScope{Shared,Companion(id)}` 枚举)——本设计照搬该蓝图到记忆。
## 目标
1. 伙伴记忆区分**共享**(所有伙伴可见)与**私有**(仅归属伙伴可见),两者皆可编辑。
2. 编辑可改 **内容 + 共享/私有归属**(不改 kind/tags/importance/strength)。
3. 三个入口:**悬浮伙伴右键菜单**、**侧边栏/nomi 页一级可见**、**对话窗口内**。
4. 顺带修复上述后端安全/事件隐患。
非目标:语义检索/embedding;手动编辑 strength/importance/kind/tags;回溯改写进行中对话已烘焙的 prompt。
## 概念模型
| 作用域 | `scope_kind` | `scope_companion_id` | 可见范围 |
|---|---|---|---|
| **共享** | `'user'` | `''` | 所有伙伴 |
| **私有** | `'companion'` | 归属伙伴 id | 仅该伙伴 |
伙伴 C 可见记忆 = `scope_kind='user' OR scope_companion_id = C`(与 `companion_skills` 一致)。
**默认归属**
- 对话保存(`CompanionStoreSink::save`source=`chat`)→ **私有给该伙伴**(复用 owning-companion 解析)。
- 学习中枢(`learner`source=`learn`)→ **共享**
- 手动新增(route/UI)→ 请求携带,UI 选择,默认共享。
## 实现分层
### A. 数据层 `crates/backend/nomifun-companion/src/store.rs`
- 新增 `MemoryScope { Shared, Companion(String) }` 枚举 + `scope_columns()`/`from_columns()` 辅助(镜像 `SkillScope` / `service.rs:scope_for`)。
- `CompanionMemory` 加字段 `scope_kind: String``scope_companion_id: String`
- `row_to_memory`:读出两列,`scope_companion_id``COALESCE(...,'')` 归一 NULL→`''`
- 迁移:新增幂等回填步骤 `UPDATE companion_memories SET scope_companion_id='' WHERE scope_companion_id IS NULL`(旧行 `scope_kind='user'` 即共享,符合现状)。
- `insert_memory`:签名加 `scope: MemoryScope`INSERT 列含 scope_kind/scope_companion_id。
- `insert_memory_raw`import):INSERT 列含两列(struct 已带,import 可往返)。
- `memories_for_injection`:签名加 `companion_id: &str`,两个 SELECT 加 `AND (scope_kind='user' OR scope_companion_id = ?)`
- `MemoryFilter`:加 `scope_companion_id: Option<String>``list_memories` 据此加同款谓词。
- `update_memory`:除 content/pinned/status,接受可选 `scope: Option<MemoryScope>`**对 content 重跑 `nomi_redact::redact_secrets`****trim 拒空**。
### B. 服务/路由/事件 `service.rs` / `routes.rs` / `events.rs` / `companion.rs` / `learner.rs` / `export.rs`
- `service.add_memory`:加 `scope` 参数并下传;保留 kind/content 校验。
- `service.update_memory`:下传 scopestatus 枚举校验(已有)+ content trim;若 `scope_kind='companion'`,校验 companion_id 存在。
- `routes.rs`
- `AddMemoryRequest``scope_companion_id: Option<String>``''`/缺省=共享)。
- `UpdateMemoryRequest``scope_kind: Option<String>` / `scope_companion_id: Option<String>`
- `events.rs`:新增 `emit_memory_updated(memory)``companion.memory-updated``emit_memory_deleted(id)``companion.memory-deleted`。HTTP add 路径补发 `memory-created`PUT 发 updatedDELETE 发 deleted。
- 默认归属接线:`CompanionStoreSink::save``Companion(owning_id)``learner` insert→`Shared`route add→请求。
- `build_companion_system_prompt`:加 `companion_id` 形参并下传 `memories_for_injection`;更新全部调用点。
- recall:把伙伴 id 传进 `MemoryFilter.scope_companion_id``recall` 返回 共享+该伙伴私有。
### C. IPC/TS `ui/src/common/adapter/ipcBridge.ts`
- `ICompanionMemory``scope_kind: 'user'|'companion'``scope_companion_id: string`
- `addMemory`/`updateMemory`/`listMemories` payload 加作用域字段。
- 新增 `onMemoryUpdated``onMemoryDeleted` WS 监听。
### D. 前端
- **D1 编辑(`MemoriesTab.tsx`**:每行加"编辑"按钮 → 弹窗改 content + 归属选择器(共享/私有给某伙伴);工具栏加作用域筛选(全部/共享/仅当前伙伴);每行作用域徽标;新增弹窗也带归属选择器;订阅 updated/deleted 实时刷新;加"编辑只影响新对话+实时 recall"的说明文案。
- **D2 入口① 悬浮伙伴右键菜单**(`companionNativeMenu.ts` / `companion/index.tsx`):`CompanionMenuAction``'open-memories'`;菜单加"打开记忆"项 → `openMainAt('/nomi?companion={id}&tab=memories')`;更新 `companionNativeMenu.test.ts`
- **D3 入口② 侧边栏/nomi 可发现性**(`nomi/index.tsx`):把 `memories` 从"共享"域移到"**伙伴**"域,成为一级"记忆"标签;scope-aware:选中伙伴时默认显示 共享+该伙伴私有,作用域筛选保留"全部伙伴"。
- **D4 入口③ 对话窗口内**`companion/index.tsx`):对话中 `onMemoryCreated`/updated 触发显示低调小条"记下了:<摘要>"+"编辑/管理"动作 → `openMainAt('/nomi?companion={id}&tab=memories')`;聊天栏加"记忆"小入口同样跳主窗口。不在 240×214 小窗内行内编辑。
- **i18n**:新 key`nomi.memories.edit`/`saved`/`scope`/`scopeShared`/`scopePrivate`/`scopeFilterAll|Shared|Private`/`editHint``nomi.menuOpenMemories` 等)补 en-US + zh-CN `nomi.json`,重新生成 `i18n-keys.d.ts`
## 数据流
- 新增(手动)UI 弹窗(content+scope) → `POST /api/companion/memories``service.add_memory(scope)``insert_memory(..,scope)` → emit created → 各面板刷新。
- 新增(对话):伙伴 `save_memory` 工具 → `CompanionStoreSink::save` 解析归属伙伴 → `insert_memory(scope=Companion(id))` → emit created。
- 新增(学习)`learner``insert_memory(scope=Shared)`
- 编辑:UI 弹窗 → `PUT /:id {content?,scope_kind?,scope_companion_id?}``service.update_memory`(redact+trim) → `store.update_memory`(COALESCE) → emit updated → 刷新。
- 注入:`build_companion_system_prompt(companion_id)``memories_for_injection(companion_id,..)` → 共享+该伙伴私有,烘焙进新对话。
- recall`recall_memories``list_memories` 带 scope 过滤。
## 错误处理 & 安全
- updatetrim 拒空(400);重跑脱敏;status 枚举校验;scope_kind='companion' 时校验 companion_id 存在(否则 400)。
- 迁移回填幂等。
- 持久化快照:编辑只影响新对话 + 实时 recall,不回溯改写在飞对话——UI 文案说明。
- DELETE 未知 id 返回 404(与 PUT 一致)——可选清理项。
- `insert_memory_raw`(import)保持不脱敏(高保真导入),文档注明。
## 测试
**Rust**
- store:带 scope 插入写两列;`row_to_memory` 读出;NULL→`''` 归一;`memories_for_injection` 过滤(伙伴见共享+自己私有,不见他人私有);`list_memories` scope 过滤;`update_memory` 脱敏 + 拒空 + 改 scope。
- service:三种默认归属(对话→私有、学习→共享、手动→请求)。
- eventscreated/updated/deleted 在对应路径发射。
- 迁移幂等。
**TS / 集成**
- `companionNativeMenu.test.ts`:菜单含新"打开记忆"项与顺序。
- `MemoriesTab`:编辑流调用 `updateMemory` 带 content+scope;作用域筛选;徽标渲染(若有组件测试)。
- `bun run check`typecheck + i18n 双语 key + theme contract。
## 涉及文件
后端:`store.rs / service.rs / routes.rs / events.rs / companion.rs / learner.rs / export.rs`+ 迁移)。
IPC`ipcBridge.ts`
前端:`MemoriesTab.tsx / nomi/index.tsx / companion/index.tsx / companionNativeMenu.ts(+test) /`(必要时 Sider)。
i18n`locales/en-US/nomi.json``locales/zh-CN/nomi.json` + 重新生成 `i18n-keys.d.ts`
## 实现备注(落地时的取舍,与上文设计一致,细节微调)
- **无需新增迁移**:旧行 `scope_companion_id` 为 NULL`row_to_memory``try_get::<Option<String>>` 归一成 `''`,配合 `scope_kind='user' OR scope_companion_id=?` 查询即正确;省去回填迁移、不动 `STORE_VERSION`
- **`insert_memory` 保留为共享包装**:新增 `insert_memory_scoped(..., scope)``insert_memory(...)` = 共享包装,最小化对学习器/既有测试的冲击。
- **私有写入跳过去重**`add_memory`/对话保存在私有作用域下跳过 `find_similar_active` 合并,避免把私有记忆误并进共享或他人记忆。
- **recall 作用域**`CompanionMemorySink::recall``conversation_id`,由会话归属伙伴解析作用域(与注入一致)。
- **对话内入口(5d)**:实现为悬浮窗「空闲时」低调气泡提示「📝 已记住:…」(避免覆盖进行中的回复气泡),编辑/管理经右键「打开记忆」直达 scope-aware 记忆页。
- **预存且无关的破损**:集成测试 `nomifun-ai-agent/tests/factory_provider_integration.rs` 因更早提交给 `BuildTaskOptions` 增了 `conversation_created_at` 字段而未同步,本就编译失败(已 stash 验证与本改动无关),不在本次范围内修复。
@@ -0,0 +1,104 @@
# 终端三项改进 设计文档(自动标题 + 回退 Shell + 退出清理)
> 状态:已与用户确认,进入实现。语言随上下文用中文;代码标识符用英文。
## 目标
1. **自动标题**:新建终端会话的侧边栏标题太固定,改为在用户首次交互后,自动总结为与工作内容相关的标题。
2. **回退 Shell**claude/codex 终端会话「失去激活态」后出现乱码卡死、无法输入、进程生死不明的情况,改为可靠回退到一个干净的 shell(而非卡死页面),并允许用户持续 Ctrl+C 脱身。
3. **退出清理**:整个 App 退出时清理所有终端会话(删除全部会话行 + 滚屏),不残留到下次启动。
## 现状架构(与改动相关)
- 每个会话 = `portable-pty` 子进程(Windows ConPTY)。`TerminalService.live: DashMap<i64, Arc<PtyHandle>>` 管理活进程,`next_epoch` 区分代际,`on_exit` 回调按 epoch 守卫。子进程退出后 `last_status='exited'`,PTY 被销毁,**无自动重启**。
- `relaunch(id)`service.rs:837= kill 旧 PTY → 同一 id、新 epoch 用**原命令**重启 → `clear_scrollback``update_status('running')``emit_updated`
- 标题就是 `terminal_sessions.name``default_name(command, backend)`service.rs:989)机械决定:backend 标签(`Claude`/`Codex`> `Shell``$SHELL` 哨兵)> 原始命令。重命名链路 `update_meta → repo.update_meta → emit terminal.updated → 前端 onUpdated → 侧边栏重渲染` **已全链路工作**
- 终端会话**无 conversation/provider/model 关联**DB 行、`CreateTerminalParams`、API DTO、IPC、UI 均无相关列)。
- 全部输入经 `TerminalService::input(id, data_b64)`service.rs:743)汇聚后写入 PTY`pty.rs::write` 不留记录。`input()` 在无活 handle 时返回 `NotFound`
- 生命周期事件:`LifecycleKind{TurnEnd, ToolUse, Notification, SessionStart}`lifecycle.rs)。claude 用 Stop→TurnEndpayload 含 `last_assistant_message`),codex 同有 TurnEnd。`subscribe_lifecycle(id)` 已存在;spawn_pty 内已有一个示例消费者(service.rs:515-529)。
- 既有「prompt→短文本」LLM 路径:`nomifun_ai_agent::one_shot_completion(cfg, system, messages, max_tokens)` + `resolve_provider_config(provider_repo, encryption_key, provider_id, model, workspace)` + `user_message(text)``LiveKnowledgeCompleter`knowledge_completer.rs)是「持有 provider_repo + encryption_key + workspace、`resolve_default_model()` 取第一个有效 provider/model」的拷贝模板。`provider_repo`/`encryption_key``AppServices`services.rs:173/176),`terminal_service` 在 services.rs:440-459 接线,但二者**未**传给 `TerminalService`
- 前端:`XtermView.tsx` 的 xterm 网格**从不**按 status 禁用(`onData` 始终转发输入);仅 `TerminalSendBox``isExited``last_status!=='running'`)时 disableTerminalSessionPage.tsx:408)。`term.clear()` 只清普通缓冲、**不能**退出 alt-screen;需 `term.reset()`。WebGL 上下文丢失(XtermView.tsx:89-95)是「失去激活态后乱码」的诱因之一。
- **WS 重连间隙**`httpBridge.ts` 的 WS 单例会重连(退避 1s~30s),监听器按事件名存于模块级 map 故重连后存活;但服务端只做无 replay 的 `broadcast_all`,重连 open 回调**不重新拉取 scrollback** → 断线期间的重绘帧永久丢失 = 持久乱码。
- App 生命周期钩子仅在 `apps/desktop/src/main.rs``on_window_event`CloseRequested 隐藏到托盘;Destroyed→exit(0))、tray-quit(设 `QuitFlag``app.exit(0)`)、`handle_run_event`(仅 macOS Reopen)。无任何终端清理;`TerminalService`/`PtyHandle` 无 Drop。后端运行在独立 tokio runtime 线程,`DesktopServer` 暴露 runtime Handledesktop.rs:106)但无阻塞 shutdown 方法。
---
## 功能① 自动标题
**与原始需求的偏差(已确认)**:终端无 conversation 关联,无法「取对应对话的 provider/model」。改为:用 **App 默认有效 provider/model**(首个有效 provider 的首个有效 model,复用 knowledge 自动生成模式)做 LLM 总结;**模型未配置或调用失败 → 兜底取用户输入内容前 N 字**。
### 触发与范围
- **仅一次**,首次交互时填充;**含 shell**。
- **claude/codex**:订阅生命周期,首个 `TurnEnd` 事件读 `payload.last_assistant_message`,结合已捕获的首行用户输入,调 `one_shot_completion` 总结为短标题(≤ ~30 字)。
- **shell / 非 agent / 无模型 / LLM 失败**:取首行用户输入前 N 字(默认 N=40,去除控制字符)作为标题。
- **捕获首行输入**:在 `TerminalService::input()` 内累积该会话首个输入直到首个 `\r`/`\n`,存内存。
### 一次性 + 不覆盖手动改名
- 写入前判定 `当前 name == default_name(command, backend)`;不等说明用户已改名或已自动命名过 → 跳过(自身幂等)。
- 另加内存 `titled: DashMap<i64, ()>` 防止首批按键竞态重复触发。
- **无需 migration**。
### 落库
- 统一走现有 `update_meta(id, Some(title), None)`trim/校验/持久化/emit `terminal.updated`),**前端零改动**。
### 后端接线
- 新增 `LiveTerminalTitleCompleter { provider_repo, encryption_key, workspace }`(仿 `LiveKnowledgeCompleter`),late-wire 注入 `TerminalService`(新增 `with_title_completer`)。`None` 时只走兜底截断,绝不阻塞。
- `crates/backend/nomifun-terminal/Cargo.toml``nomifun-ai-agent` 依赖。
- `services.rs:440-459` 旁注入,复用 `provider_repo.clone()` / `encryption_key` / `data_dir.clone()`
### 主要文件
- `crates/backend/nomifun-terminal/src/{service.rs, title.rs(新), Cargo.toml}`
- `crates/backend/nomifun-app/src/services.rs`
---
## 功能② 回退 Shell(修复乱码卡死 + 持续 Ctrl+C 脱身)
三个独立缺陷,一套协同修复:
1. **后端 `relaunch_as_shell(id)`**:克隆 `relaunch()` 逻辑,但用 `SHELL_SENTINEL`+`[]` 替代 `row.command`/`row.args` 来 spawn。先 tree-kill 卡住的 agent → 同一 id、新 epoch 重启干净登录 shell → 持久化 `command=$SHELL, args=[], backend=None`(这样退出/重启后续仍是 shell,且 `default_name``Shell`)→ `update_status('running')``emit_updated`(前端 `onUpdated` 自动恢复送信框)。
- 新路由 `POST /api/terminals/{id}/relaunch-shell`(独立于现有 relaunch,语义清晰)。
- IPC`ipcBridge.terminal.relaunchShell(id)`
2. **前端 `term.reset()`**:给 `XtermViewHandle``reset:()=>term.reset()`(退出 alt-screen、复位模式、清屏)。在「回退 Shell」与重连重放时调用。
3. **常驻「回退 Shell」入口(不受 isExited 限制)**:会话头部加按钮;点击 = `relaunchShell` + `term.reset()`。即便会话仍 `running` 卡死也能脱身。
4. **持续 Ctrl+C 升级**`XtermView` 检测短时间内连续 N 次 Ctrl+C(`\x03`,默认 3 次 / 1.5s)→ 显示提示条「再次 Ctrl+C 回退到 Shell」并在阈值后触发 `relaunchShell`。Ctrl+C 仍原样转发(不拦截单次)。
5. **WS 重连重放(乱码主因修复)**`httpBridge.ts` WS open 时若为重连,通知监听者(新增 `terminal.__reconnected` 内部事件或重连回调);`XtermView` 收到后 `term.reset()` 然后重新 `ipcBridge.terminal.get(id)` 用同一 decoder 重放当前 scrollback。
### 主要文件
- `crates/backend/nomifun-terminal/src/{service.rs, routes.rs}`
- `ui/src/renderer/pages/terminal/{XtermView.tsx, TerminalSessionPage.tsx, TerminalSendBox.tsx}`
- `ui/src/common/adapter/{ipcBridge.ts, httpBridge.ts}`
- i18n`ui/.../locales/{zh-CN,en-US}/terminal.json`
---
## 功能③ 退出清理(删除全部会话行 + 滚屏)
1. **repo**`ITerminalRepository``delete_all(&self) -> Result<u64>`SQLite 实现 `DELETE FROM terminal_sessions``terminal_scrollback` 经 FK CASCADE 自动删)。MemRepo 同步实现(供测试)。
2. **service**`TerminalService::shutdown_cleanup()`:遍历 `self.live` 逐个 `kill()`,清 `pending_spawn`,再 `repo.delete_all()`
3. **desktop**`DesktopServer::shutdown_terminals()`:把 `shutdown_cleanup()` 编排到后端 runtime 上**阻塞执行(带超时上限,如 3s)**,供 Tauri 主线程在 `app.exit(0)` 前同步调用。
4. **main.rs**:仅在 **real-quit 路径**调用:
- tray-quit handler`QuitFlag` 已置位,main.rs:697-702`app.exit(0)` 之前;
- 新增 `RunEvent::ExitRequested`/`Exit` 分支(`handle_run_event`,仅当 `QuitFlag` 置位时)。
- **绝不在 close-to-tray(隐藏窗口)路径调用**。
### 主要文件
- `crates/backend/nomifun-terminal/src/service.rs`
- `crates/backend/nomifun-db/src/repository/{terminal.rs, sqlite_terminal.rs}`
- `crates/backend/nomifun-app/src/desktop.rs`
- `apps/desktop/src/main.rs`
---
## 测试策略
- **①** `default_name` 一致性守卫的幂等(改名后不再触发);无 completer 注入时的截断兜底;注入 fake completer 时 TurnEnd→update_meta 链路;首行输入捕获。
- **②** `relaunch_as_shell` 单测:同一 id、`command` 变为 `$SHELL`、status 回 `running`、发 `terminal.updated`。前端 reset / 重连重放 / 连续 Ctrl+C 升级以手动 + 单元(纯函数)验证。
- **③** `delete_all` 删行 + scrollbackCASCADE);`shutdown_cleanup` kill 活进程后行清空;close-to-tray 不触发(QuitFlag 守卫,逻辑断言)。
## 风险与缓解
- **退出清理的破坏性**:用户已确认要删全部;严格只在 QuitFlag 真退出路径执行,close-to-tray 绝不删。阻塞清理带 3s 超时上限,避免退出卡住。
- **回退 shell 改变语义**:原 agent 退出→可重启项;改 shell 后命令被改写为 `$SHELL`。这是用户明确要的「回退到 shell」,且仅在用户主动点按/连续 Ctrl+C 触发,不做静默自动改写。
- **标题 LLM 成本/噪声**:仅首次、仅一次、`name==default` 守卫;agent 才调 LLMshell 走零成本截断。
- **Windows 进程组**`kill()` 在 Windows 无进程组 SIGKILL,孙进程可能残留——沿用既有 `kill()` 能力,不在本次扩大范围。