Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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 编排。
|
||||
+89
@@ -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 契约)。
|
||||
- 人工:实跑确认排序生效、分页配色变柔和、全选本页/清除可用。
|
||||
|
||||
## 范围之外
|
||||
|
||||
扩展筛选字段、列表改回表格、分页配色全局化——本次均不做。
|
||||
+154
@@ -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()`;持久化进 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)。
|
||||
- **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 的清理。
|
||||
+129
@@ -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`:下传 scope;status 枚举校验(已有)+ 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 发 updated;DELETE 发 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 过滤。
|
||||
|
||||
## 错误处理 & 安全
|
||||
- update:trim 拒空(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:三种默认归属(对话→私有、学习→共享、手动→请求)。
|
||||
- events:created/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→TurnEnd(payload 含 `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'`)时 disable(TerminalSessionPage.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 Handle(desktop.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` 删行 + scrollback(CASCADE);`shutdown_cleanup` kill 活进程后行清空;close-to-tray 不触发(QuitFlag 守卫,逻辑断言)。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
- **退出清理的破坏性**:用户已确认要删全部;严格只在 QuitFlag 真退出路径执行,close-to-tray 绝不删。阻塞清理带 3s 超时上限,避免退出卡住。
|
||||
- **回退 shell 改变语义**:原 agent 退出→可重启项;改 shell 后命令被改写为 `$SHELL`。这是用户明确要的「回退到 shell」,且仅在用户主动点按/连续 Ctrl+C 触发,不做静默自动改写。
|
||||
- **标题 LLM 成本/噪声**:仅首次、仅一次、`name==default` 守卫;agent 才调 LLM,shell 走零成本截断。
|
||||
- **Windows 进程组**:`kill()` 在 Windows 无进程组 SIGKILL,孙进程可能残留——沿用既有 `kill()` 能力,不在本次扩大范围。
|
||||
Reference in New Issue
Block a user