docs: 巡检简报+todo 回写(2026-06-15 第2轮)

巡检发现:
- 2196c77 workflow 整文件替换回退破坏(AiChat+Ideas 12项功能)
- B-260615-03 truncated 标志已落地
- AR-8-scroll scrollToBottom smooth 已补
- CR-260615-09 .ai-md 残余:4详情页各21处 scoped .ai-md
  (全局 ai-md.css 75行已建,旧副本待清理但非阻塞)
This commit is contained in:
2026-06-15 17:23:57 +08:00
parent 672d677046
commit f30df333b3
17 changed files with 2184 additions and 15 deletions

View File

@@ -0,0 +1,295 @@
# AIChat 交互体验改进方案
> 创建: 2025-07-15 | 状态: 待讨论
> 范围: 消息发送、流式渲染、对话管理、错误恢复、技能/Provider、窗口布局等非授权类交互
---
## 一、消息输入与发送
### 1.1 无法编辑已发送消息
**现象**:用户发出消息后发现措辞有误,只能重新打一条新消息。无法像 ChatGPT/Claude 那样编辑上一条用户消息并重新生成。
**根因**`sendMessage`useAiSend.tspush 后的消息是只追加不可变的。前端没有编辑入口,后端 `ai_chat_send` 也没有"替换最后一条 user 消息并重跑"的语义。
**方案**
- 用户消息气泡 hover 显示"编辑"按钮
- 点击后消息内容回填到输入框,用户修改后发送时后端截断该消息之后的所有历史(含 AI 回复),重新跑 agentic loop
- 后端新增 `ai_chat_edit` 命令,接收 `message + 截断位置`,替换 messages 数组中对应 user 消息并清掉后续消息,然后走正常 `run_agentic_loop`
### 1.2 无法重新生成 AI 回复
**现象**AI 回答不满意时没有"重新生成"按钮,只能重新措辞追问。
**根因**`AiChat.vue` 的 AI 消息气泡上没有任何操作按钮。后端也没有 `ai_regenerate` 命令。
**方案**
- AI 消息气泡 hover 显示操作栏(复制 | 重新生成)
- "重新生成":后端删除最后一条 AI 消息,用倒数第二条 user 消息重新触发 `run_agentic_loop`
- 后端新增 `ai_regenerate` 命令
### 1.3 无法一键复制消息内容
**现象**:代码或文本只能手动选中复制。流式渲染中选中文字会被后续 delta 打断。
**根因**:消息气泡上没有复制按钮。
**方案**
- AI 消息气泡 hover 显示"复制"按钮
- 点击后 `navigator.clipboard.writeText(msg.content)`toast 提示"已复制"
- 代码块单独提供"复制代码"按钮hover 代码块右上角浮出)
### 1.4 缺少 `@` 实体引用
**现象**用户无法在输入时引用某个项目、任务或文件。描述需求时无法精确指定上下文AI 可能猜错对象。
**根因**:输入框只有 `/` 技能联想,没有 `@` 实体引用机制。
**方案**
- 输入框支持 `@` 触发实体联想浮层(复用技能联想的 popover 架构)
- 联想源:项目列表、任务列表、最近编辑的文件
- 选中后在消息中展开为 `[项目: u-desk]` 等标记文本,后端 system prompt 注入对应实体的上下文摘要
### 1.5 输入框高度过紧
**现象**textarea 最大高度 120px约 5-6 行),超过后内部滚动。用户写长提示词时看不到全貌。
**根因**`autoResize``Math.min(el.scrollHeight, 120)` 限制太紧。
**方案**
- 最大高度提升到 200px约 10 行),超过后再内部滚动
- 或改为可拖拽调整高度(底部 resize handle
---
## 二、流式渲染与消息展示
### 2.1 流式渲染中无法稳定选中文字
**现象**AI 正在流式输出时,用户尝试选中已渲染的文字,新的 delta 触发 DOM 更新导致选区丢失。
**根因**`renderContent` 在流式时返回 `streamingHtml.value`rAF 节流重 parse每次更新都 `v-html` 替换整个 DOM 子树,浏览器选区被清除。
**方案**
- 方案 A推荐检测到用户正在选择文字时`selectionchange` 事件 + 选区非空且在消息容器内),暂停 rAF 流式 parse选区结束后恢复
- 方案 B流式渲染时在已完成的块blockCache 命中的块)上使用独立 DOM 节点不参与 v-html 替换,仅末块动态更新
### 2.2 代码块无语法高亮、无复制按钮
**现象**Markdown 代码块只有纯文本渲染,没有语法高亮,也没有一键复制按钮。
**根因**`useMarkdown` composable 配置了 marked + DOMPurify但没有集成 highlight.js / Prism 等高亮器。
**方案**
- 集成 highlight.js体积小、语言全在 marked renderer 的 `code` 回调中调用 `hljs.highlightAuto` 或按 info string 指定语言
- 代码块右上角浮出"复制"按钮纯前端hover 显示)
- 高亮器按需加载(与 marked 一样后台预热,不阻塞首屏)
### 2.3 历史消息无分页懒加载
**现象**:切换对话时 `switchConversation` 一次性加载全部 messages 到 `state.messages`。超长对话可能一次加载几百条消息。
**根因**`switchConversation`useAiConversations.ts直接 `state.messages = rawMsgs.filter().map()`,无分页。
**方案**
- 首次加载最近 50 条,滚动到顶部时加载更多(前端 slice + 后端支持 offset/limit 查询 messages
- 或前端全量加载但虚拟滚动只渲染可视区域(见 7.2
### 2.4 消息不显示时间戳
**现象**:消息气泡上不显示发送时间。用户无法判断某条消息是多久前发的。
**根因**:模板中 AI/用户消息都没有渲染 `msg.timestamp`
**方案**
- 消息气泡下方或侧边以极小字号9px+ dim 颜色展示相对时间(`formatRelativeZh`
- hover 时 tooltip 展示完整时间
---
## 三、对话管理
### 3.1 对话搜索缺失
**现象**:侧栏对话列表只能滚动浏览,没有搜索框。对话多了之后找不到特定对话。
**根因**:侧栏 `.ai-conv-list` 直接渲染 `groupedActive`,上方只有"新建"按钮,没有搜索输入框。
**方案**
- 侧栏 header 下方增加搜索输入框(实时过滤 `state.conversations`,匹配 title
- 搜索时取消分组,按相关度/时间平铺展示
- 支持 `Ctrl+K` 快捷键聚焦搜索框
### 3.2 对话无法置顶
**现象**:没有置顶或收藏功能。重要对话会被新对话挤到下面。
**根因**:对话列表只按 `updated_at` 排序,无 `pinned` 字段。
**方案**
- `ai_conversations` 表增加 `pinned INTEGER DEFAULT 0`
- 排序逻辑改为 `pinned DESC, updated_at DESC`
- 侧栏对话项 hover 显示"置顶/取消置顶"按钮(图钉图标)
### 3.3 对话无法导出
**现象**:无法将对话导出为 Markdown / JSON / 文本文件。
**根因**:没有导出 IPC 命令和前端入口。
**方案**
- 后端新增 `ai_conversation_export(conv_id, format)` 命令,支持 `markdown` / `json` / `txt` 三种格式
- 前端侧栏对话项 hover 显示"导出"按钮,或对话操作菜单中提供
- Markdown 格式:`## 用户` / `## 助手` 交替,代码块保留围栏
### 3.4 新建对话强制中断已有生成
**现象**:用户在 AI 生成中点"新建对话",后端 `ai_conversation_create` 会强制复位 `generating=false` + 置 `stop_flag`,中断当前生成。
**根因**`commands.rs ai_conversation_create``if session.generating` 分支主动结束生成B-260615-10 设计)。
**方案**
- 生成中点"新建对话"时弹 ConfirmDialog"当前对话正在生成,确定要新建对话并中断吗?"
- 用户确认后才执行中断+新建;取消则不操作
- 或改为不中断:新对话仅切换视图,旧对话后台继续生成(需 AiSession 多实例化支持,属远期方案)
---
## 四、错误处理与恢复
### 4.1 错误气泡无操作入口
**现象**:错误气泡只显示一行文字(如 `[GLM-4] AI 调用失败(HTTP 401): Unauthorized`),没有"重试"按钮或"去设置"链接。
**根因**`AiError` 事件只携带 `error: String`,前端 `handleEvent` push 一条 `isError: true` 的消息气泡,无操作按钮。
**方案**
- 错误气泡底部增加操作按钮区:
- **重试**:取上一条 user 消息重新发送(调 `ai_chat_send`
- **去设置**(仅 401/403/Provider 未配置时显示):跳转到 Settings → AI Tab
- 错误消息结构扩展:后端 `AiError` 增加 `error_type: Option<ErrorType>` 枚举(`auth` / `network` / `timeout` / `provider_config` / `unknown`),前端据此决定显示哪些按钮
### 4.2 流式中断丢失已生成文本
**现象**网络波动导致流式中断idle timeout 或 mid-stream error已接收的文本被丢弃`stream_llm` return None → `agentic.rs` emit AiError 并退出),用户看到错误气泡,之前生成的几百字全部丢失。
**根因**`stream_llm` 遇到错误时 `return None`,不保留已接收的部分文本。`agentic.rs` 收到 None 后直接结束。
**方案**
- `stream_llm` mid-stream error 时改为返回 `Some((partial_text, tool_calls, usage))` + 一个 `incomplete: bool` 标志
- `agentic.rs` 收到 incomplete=true 时:
- 已有文本正常入库(标注 `truncated`
- emit `AiCompleted`(而非 AiError让前端正常展示已生成内容
- 在消息末尾追加系统提示:"⚠ 响应因网络中断不完整"
- 前端 AI 消息气泡底部显示"继续生成"按钮(用最后一条 user 消息重新触发,后端识别不完整消息做续写或重跑)
---
## 五、技能与 Provider
### 5.1 Provider 切换零反馈
**现象**:点击 provider bar 循环切换 provider`cycleProvider`),切换后只在 bar 上显示名字变化,没有 toast 或动画反馈。
**根因**`cycleProvider` 直接调 `store.setProvider`,无 UI 反馈。
**方案**
- 切换后 toast 提示"已切换到 {providerName}"
- provider bar 切换时加 0.15s 淡入动画
- bar 上增加 provider 状态指示model 名称小字展示)
### 5.2 技能联想不展示参数用法
**现象**:技能联想浮层显示 name + description + source但不显示技能的参数格式或示例。选中后 placeholder 里的 `argument_hint` 太短。
**根因**:浮层设计只展示概要信息,`argument_hint` 仅在选中后作为 placeholder 显示。
**方案**
- 联想浮层每项增加一行参数提示(`argument_hint` 以等宽字体小字展示)
- 选中技能后输入框上方 chip 展示完整的参数格式说明(而非仅 name + description
- 或选中技能后自动在输入框填入模板骨架(如 `/commit `),光标定位到参数位置
---
## 六、窗口与布局
### 6.1 侧栏宽度不可调
**现象**:侧栏固定 160px不可拖拽调整宽度。长对话标题被截断。
**根因**`.ai-conv-sidebar { width: 160px; min-width: 160px; }` 硬编码。
**方案**
- 侧栏右边缘增加 2px 拖拽条(`cursor: col-resize`
- 拖拽时实时更新 width范围 120~280px
- 宽度持久化到 `df-ai-ui` 设置(与 sidebarOpen/maximized 同存)
### 6.2 分离窗口关闭后生成态不同步
**现象**:分离窗口关闭时,如果正在生成,主窗口虽然 `panelOpen=true` 恢复,但 `state.streaming` / `state.currentText` 可能不同步——如果生成中的对话不是主窗口当前活跃对话,主窗口看不到生成态。
**根因**:分离窗口关闭走 `closeDetachedWindow`,清了 localStorage 快照,但主窗口的状态依赖事件路由自然恢复。
**方案**
- 分离窗口关闭时,主窗口检测 `state.generatingConvId` 非空,自动切换到正在生成的对话
- 或在主窗口 header 显示"对话 X 正在生成中"提示条,点击切换过去
---
## 七、其他交互细节
### 7.1 无键盘快捷键
**现象**:除了 Enter 发送 / Shift+Enter 换行,没有其他快捷键。
**方案**
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+N` | 新建对话 |
| `Ctrl+K` | 搜索对话 |
| `Ctrl+L` | 清空当前对话 |
| `Ctrl+Shift+C` | 复制最后一条 AI 消息 |
| `Ctrl+R` | 重新生成最后一条 AI 回复 |
| `Esc` | 关闭面板(嵌入模式)/ 关闭窗口(分离模式) |
| `Ctrl+B` | 切换侧栏 |
### 7.2 消息列表无虚拟滚动
**现象**:消息列表直接 `v-for` 渲染所有消息,长对话几百条消息全量渲染 DOM滚动卡顿。
**根因**:没有使用虚拟滚动库。
**方案**
- 集成 `vue-virtual-scroller` 或自研 IntersectionObserver 懒渲染
- 仅渲染可视区域 ±缓冲区的消息节点
- 注意:流式渲染的最后一条消息需要始终保持挂载
### 7.3 空状态无引导
**现象**:首次打开 AI Chat有 provider 配置),空状态只显示标题+提示语,没有示例问题或快捷操作。
**方案**
- 空状态展示 3-4 个示例问题卡片(如"帮我创建一个新项目"、"查看当前任务列表"、"分析这段代码的问题"
- 点击卡片自动填入输入框并发送
- 无 provider 配置时展示"去配置 AI Provider"引导按钮
### 7.4 对话标题生成对用户不透明
**现象**:对话标题由后端 `ensure_conversation_title` 异步生成,侧栏对话从"新对话"突然变成某个标题,没有过渡。
**方案**
- 标题生成后加 0.3s 淡入动画
- 或在标题前加小图标标识"AI 自动生成"(可 hover 查看)
---
## 八、落地优先级
| 批次 | 痛点 | 理由 |
|------|------|------|
| **第一批** | 2.1 流式选中文字、1.2+1.3 消息操作栏(复制/重新生成、4.1 错误重试、4.2 断线保文 | 每次对话都会遇到,最高频痛点 |
| **第二批** | 2.2 代码高亮+复制、3.1 对话搜索、7.1 键盘快捷键、3.4 新建不中断生成 | 显著提升日常效率 |
| **第三批** | 1.1 编辑重发、1.4 `@` 引用、3.3 导出、7.2 虚拟滚动、6.1 侧栏可调 | 按需推进,锦上添花 |
| **第四批** | 2.3 分页加载、2.4 时间戳、3.2 置顶、5.1+5.2 技能/Provider 反馈、7.3+7.4 空状态/标题 | 打磨细节 |

View File

@@ -0,0 +1,282 @@
# AIChat 授权功能体验改进方案
> 创建: 2025-07-15 | 状态: 待讨论
## 一、当前授权机制概览
### 1.1 核心流程
```
用户发送消息
→ Agentic Loop (最多 10 轮)
→ LLM 流式响应
→ 解析 tool_calls
→ 按风险等级分流:
├─ Low Risk → 自动并行执行join_all
├─ Medium → 暂停循环,等待人工审批
└─ High → 暂停循环,等待人工审批
→ 审批通过 → 执行工具 → try_continue_agent_loop 恢复循环
→ 审批拒绝 → tool_result="用户拒绝了此操作" → 恢复循环让 LLM 自行决策
```
### 1.2 工具风险分级矩阵
| 风险等级 | 工具 | 执行方式 |
|---------|------|---------|
| **Low** | `list_projects`, `list_tasks`, `list_ideas`, `list_trash`, `read_file`, `list_directory` | 自动执行,并行 `join_all` |
| **Medium** | `create_project`, `create_task`, `create_idea`, `update_project`, `update_task`, `bind_directory`, `write_file` | 需人工审批 |
| **High** | `delete_project`, `restore_project`, `purge_project`, `delete_task`, `run_workflow`, `run_command` | 需人工审批 |
### 1.3 已实现的亮点
- **审计留痕**`ai_tool_executions` 表完整记录每次工具调用的状态、风险等级、决策者、请求时间和执行时间
- **断点恢复**:应用重启后通过 `restore_pending_approvals()` 从数据库重建内存中的待审批状态
- **审批卡片可读化**`build_approval_reason()` 解析工具参数,拼接人类可读的审批理由
- **generating 状态 RAII 守护**`GeneratingGuard` 确保无论正常退出还是 panic`generating` 标志都能复位
- **数据变更联动刷新**:工具执行成功后自动 emit `df-data-changed` 事件,前端自动刷新列表
- **安全防护**API Key 存 OS keyring、文件路径双层校验、write_file 覆盖前自动 .bak 备份 + 原子写
### 1.4 关键代码位置
| 模块 | 文件 | 职责 |
|------|------|------|
| 工具注册表 | `src-tauri/src/commands/ai/tool_registry.rs` | 工具定义 + 风险等级 + handler 同源 |
| 审批处理 | `src-tauri/src/commands/ai/audit.rs` | 工具调用审计 + pending 审批恢复 + 审批后状态回填 |
| Agentic 循环 | `src-tauri/src/commands/ai/agentic.rs` | 流式接收 → 工具执行 → 结果回传 LLM → 循环 |
| IPC 命令 | `src-tauri/src/commands/ai/commands.rs` | `ai_approve` / `ai_chat_send` / `ai_chat_stop` 等 |
| 审批卡片 UI | `src/components/ToolCard.vue` | 卡片渲染 + 参数展示 + 批准/拒绝按钮 |
| 卡片列表 | `src/components/ToolCardList.vue` | 多卡片折叠管理 |
| 聊天面板 | `src/components/AiChat.vue` | 消息列表 + 审批转发 + 输入区 |
| AI Store | `src/stores/ai.ts` | 模块级单例 state |
| 发送/审批 | `src/composables/ai/useAiSend.ts` | `approveToolCall` 乐观更新 + IPC 调用 |
---
## 二、用户体验痛点分析
### 痛点 1逐个审批无法批量操作
**现象**:当 AI 一次返回多个 Medium/High 工具调用时(比如同时创建 3 个任务 + 写 2 个文件),用户需要逐个点批准/拒绝。
**根因**`process_tool_calls`audit.rs把每个待审批工具都推入 `pending_approvals` HashMap前端为每个 pending 渲染独立的 ToolCard每个卡片只有自己的"批准/拒绝"按钮。`ToolCardList.vue` 没有批量操作入口。
**影响**:高频交互场景下审批变成体力活,用户体验疲劳。
### 痛点 2审批阻塞整个对话期间无法输入
**现象**:审批等待期间 `generating=true`,用户虽然能上滑看历史,但无法发送新消息(`sendMessage` 会入队),也无法预知还有多少审批在排队。
**根因**`ai_chat_send`commands.rs:46检查 `session.generating` 为 true 时拒绝新消息。审批等待期间 `generating` 保持 true`agentic.rs``guard.disarm()` 保持 true 以便 `try_continue` 续生成)。
**影响**:用户处于"被动等待"状态,无法并行做其他事。
### 痛点 3Medium 和 High 体验无差异
**现象**:两者都弹同样的审批卡片、同样的按钮,交互流程完全一致。
**根因**`process_tool_calls`audit.rs:103-120中 Medium 和 High 走同一个 `pending_approvals.insert` 分支,区别仅在 `build_approval_reason` 的文案后缀("请确认是否执行" vs "高风险,需人工批准")。
**影响**High 操作(如 `purge_project` 不可恢复)缺乏足够的警示力度,容易误操作。
### 痛点 4write_file 审批信息不够直观
**现象**`write_file` 是 Medium 风险,审批卡片只展示 path 和 content 参数。content 可能是几百行代码,在审批卡片里以 `formatArgValue` 截断到 300 字符展示。
**根因**`ToolCard.vue``toolArgsEntries``displayArgValue``formatArgValue` 对超长值统一截断到 300 字符。
**影响**:用户无法看清要写入的完整内容,只能盲目批准。尤其覆盖已有文件时,用户不知道会改什么。
### 痛点 5审批后无进度反馈
**现象**:点击"批准"后,卡片乐观置 `running`,但用户不知道:
- 工具正在执行还是已执行完等待 LLM 续生成
- 还有多少待审批在排队
- 整个 agentic 循环进行到第几轮
**根因**:前端没有全局的 agentic 循环进度指示器。`AiAgentRound` 事件虽然通知了轮次,但没有在 UI 上持久化展示。
**影响**:用户对系统状态缺乏掌控感,尤其在多轮工具调用时。
### 痛点 6审批卡片可能被滚出视口
**现象**:当消息很多时,审批卡片可能被新消息推到上方滚出视口。
**根因**`AiChat.vue``collapseInactive` 不会收起 `pending_approval` 卡片,但也不会自动滚动到 pending 卡片。没有全局徽标提醒。
**影响**:用户可能看不到待审批项,对话看起来"卡住了"但不知道在等什么。
---
## 三、改进方案
### P0 — 快速改善体感
#### 3.1 批量审批
**方案**:当同一轮有多个 pending 时,在 ToolCardList 顶部显示"全部批准(N) / 全部拒绝"按钮。
**改动范围**
- `ToolCardList.vue`:新增批量操作栏,监听 toolCalls 中 pending_approval 数量
- `useAiSend.ts`:新增 `approveAll(rejectAll)` 方法,循环调用 `ai_approve`
**交互**
```
┌─────────────────────────────────┐
│ ⏳ 3 项待审批 │
│ [✓ 全部批准] [✕ 全部拒绝] │
├─────────────────────────────────┤
│ [工具卡片 1 - pending] │
│ [工具卡片 2 - pending] │
│ [工具卡片 3 - pending] │
└─────────────────────────────────┘
```
#### 3.2 审批计数器 + 跳转
**方案**:在输入框上方或 header 显示 `⏳ 2 项待审批`,点击跳转到第一个 pending 卡片。
**改动范围**
- `AiChat.vue`header 区域增加审批徽标
- `ToolCardList.vue`:暴露 `scrollToFirstPending` 方法
**交互**
```
┌──────────────────────────────────┐
│ 🤖 助手 ⏳2 [+][×] │ ← 徽标在 header
├──────────────────────────────────┤
│ ...消息列表... │
│ ┌─ 工具卡片 (pending) ──┐ │ ← 点击徽标滚动到此
│ │ 创建任务XXX │ │
│ │ [批准] [拒绝] │ │
│ └────────────────────────┘ │
├──────────────────────────────────┤
│ [输入框] │
└──────────────────────────────────┘
```
#### 3.3 write_file diff 预览
**方案**write_file 审批时,如果文件已存在,展示前后对比 diff 而非裸 content。
**改动范围**
- 后端 `tool_registry.rs`write_file handler 在执行前读取旧文件内容,返回 diff 信息(或前端请求 diff
- `ToolCard.vue`pending_approval + name=write_file 时渲染 diff 视图
**交互**
```
┌─ 写入文件 src/main.rs (待审批) ──────┐
│ │
│ - fn main() { │ ← 红色:删除行
│ - println!("hello"); │
│ + fn main() { │ ← 绿色:新增行
│ + println!("hello, world"); │
│ + setup_logging(); │
│ │
│ ⚠ 覆盖已有文件 (23→45 行) │
│ [查看完整内容] [批准] [拒绝] │
└───────────────────────────────────────┘
```
---
### P1 — 增强控制力
#### 3.4 自动审批策略(信任模式)
**方案**Settings 中增加"自动审批"配置,用户可选择对特定风险等级或工具类型自动放行。
**配置项**
```
Settings → AI → 自动审批策略
○ 严格模式(默认):所有 Medium/High 需人工审批
○ 宽松模式Medium 自动放行High 需人工审批
○ 自定义:按工具类型选择
☑ write_fileworkspace 内自动放行)
☑ create_task / create_idea自动放行
☐ delete_*(始终需审批)
☐ run_command始终需审批
```
**改动范围**
- `df-storage``app_settings` 表存储配置KV 已有 V13 表)
- `audit.rs``process_tool_calls` 读取配置,决定 Medium 工具是否进 pending 或直接执行
- `Settings.vue`:新增配置面板
#### 3.5 High 二次确认
**方案**delete/purge/run_command 等高风险操作,批准后弹出二次确认。
**改动范围**
- `ToolCard.vue`High 风险 + approved=true 时,先弹 inline 确认("确定要永久删除?此操作不可恢复"
- 或用现有 `ConfirmDialog` 组件
**交互**
```
第一次点击"批准":
→ 卡片内弹出确认提示
→ "确定要永久删除项目「XXX」此操作不可恢复"
→ [确认删除] [取消]
第二次点击"确认删除":
→ 才真正执行 ai_approve
```
#### 3.6 审批超时
**方案**:可配置超时自动拒绝(默认 5 分钟),避免对话永久卡住。
**改动范围**
- 后端:`AiSession` 增加 pending 审批的 `created_at` 时间戳,定时检查超时
- 或前端:`useAiSend.ts` 在 pending 时启动定时器,超时自动调 `ai_approve(id, false)`
---
### P2 — 信息透明度
#### 3.7 Agentic 进度条
**方案**:在消息区域底部显示循环进度。
**改动范围**
- `AiChat.vue`:底部增加进度指示条
- `useAiEvents.ts`:处理 `AiAgentRound` 事件时更新进度
**交互**
```
┌──────────────────────────────────┐
│ ...消息列表... │
│ │
│ 🔄 循环 3/10 · ⏳2待审批 · ✅5完成 │ ← 底部进度条
├──────────────────────────────────┤
│ [输入框] │
└──────────────────────────────────┘
```
#### 3.8 审批历史面板
**方案**:独立页面展示 `ai_tool_executions` 表的审计记录。
**改动范围**
- 后端:新增 `list_tool_executions` IPC 命令(查询审计表)
- 前端:新增 `AuditLog.vue` 视图,表格展示历史记录
**展示字段**
| 时间 | 工具 | 风险 | 状态 | 决策者 | 参数摘要 | 结果摘要 |
|------|------|------|------|--------|---------|---------|
---
## 四、实施优先级建议
| 优先级 | 改进项 | 预估工作量 | 用户价值 |
|--------|--------|-----------|---------|
| **P0** | 3.1 批量审批 | 0.5 天 | 🔥🔥🔥 |
| **P0** | 3.2 审批计数器 + 跳转 | 0.5 天 | 🔥🔥🔥 |
| **P0** | 3.3 write_file diff 预览 | 1 天 | 🔥🔥 |
| **P1** | 3.4 自动审批策略 | 1.5 天 | 🔥🔥🔥 |
| **P1** | 3.5 High 二次确认 | 0.5 天 | 🔥 |
| **P1** | 3.6 审批超时 | 0.5 天 | 🔥 |
| **P2** | 3.7 Agentic 进度条 | 0.5 天 | 🔥🔥 |
| **P2** | 3.8 审批历史面板 | 1 天 | 🔥 |
**建议第一批落地**P0 三项(批量审批 + 计数器 + diff 预览),总计约 2 天工作量,覆盖最高频的体验痛点。

View File

@@ -0,0 +1,360 @@
# Patch File 工具设计
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
> 关联 todo: F-260615-06 [P0]
---
## 一、问题定义
### 1.1 现状缺口
DevFlow AI agent 有 `write_file`(全量覆盖写入)但**无局部编辑能力**。AI 需要修改文件中某几行时只能:
```
read_file → AI 在 context 中拼出完整新内容 → write_file 全量覆盖
```
**问题链**
1. **Token 浪费**:大文件(几百行)只改 3 行却要重发全部内容
2. **事故风险**write_file 全量覆盖已出事故PROGRESS.md 762 行 → 248 字节FR-S7 记录)
3. **无审计粒度**:无法知道「改了哪里」,只有「整个文件被替换了」
4. **并发不安全**join_all 并行场景下多工具操作同一文件无保护
### 1.2 目标
提供 `patch_file` 工具,让 AI 能做**精确的局部文本替换**,形成完整的文件操作闭环:
```
read_file(读) → search_in_file(定位) → patch_file(改) → run_command(验证)
```
---
## 二、API 设计
### 2.1 请求结构
```rust
/// 局部文件更新请求
struct PatchFileRequest {
/// 目标文件路径(必填,走 validate_path 校验 + 黑名单)
path: String,
/// 文件指纹(可选):用于检测外部修改
/// 格式: "{unix_timestamp}_{size}" 如 "1718400000_12345"
/// 由 read_file 返回的 file_hash 字段携带
expected_hash: Option<String>,
/// 有序补丁列表(从文件末尾往前执行,避免行号偏移)
patches: Vec<Patch>,
}
/// 单个补丁
struct Patch {
/// 必填:要替换的旧文本(精确匹配 = 乐观锁)
old_text: String,
/// 必填:替换后的新文本
new_text: String,
/// 可选:行号辅助定位(快速跳转 + 去歧增强)
/// 有值时优先跳到该行检查 old_text不匹配则降级全文扫描
line: Option<u32>,
/// 可选old_text 之前的上下文锚(去歧——多匹配时精确锁定)
before_text: Option<String>,
/// 可选old_text 之后的上下文锚(去歧)
after_text: Option<String>,
}
```
### 2.2 响应结构
```rust
struct PatchResult {
success: bool,
patches_applied: usize, // 成功替换的 patch 数
total_matches: usize, // 每个 patch 的总命中数(含未替换的)
lines_changed: i32, // 总行数变化(正=增加 负=减少)
warnings: Vec<String>, // ["匹配到 3 处,仅替换第 1 处"]
file_hash: String, // 操作后的新指纹(下次操作用)
}
```
### 2.3 read_file 扩展(返回指纹)
现有 read_file 返回值新增字段:
```rust
struct ReadFileResult {
path: String,
content: String,
size: u64,
lines: u32,
// 新增:
modified: String, // ISO8601 或 unix timestamp
file_hash: String, // mtime+size 指纹,如 "1718400000_12345"
}
```
### 2.4 LLM 描述tool_registry 注册用)
```
"局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。
每个补丁指定 old_text要替换的原文和 new_text新内容
可选 line 辅助定位、before/after_text 上下文锚定消除歧义。
属 Medium 风险操作(修改已有文件),需人工审批。
注意old_text 必须与文件内容完全匹配(含空格/缩进);若文件已被外部修改,
请先重新 read_file 获取最新内容和 file_hash。"
```
---
## 三、核心决策记录
### 决策 1定位方式 — old_text 精确匹配为主line 为辅
| 方案 | 示例 | 优点 | 缺点 |
|------|------|------|------|
| **A: old_text ✅ 选定** | 匹配 "fn main() {" 替换 | =隐式乐观锁AI零认知负担复制即用内容变了自动冲突报错 | 改动大时 old_string 长 |
| B: line 行号 | 替换第 42 行 | 短小精悍 | 文件改了行号偏移AI需先search定位多一轮IPC |
| C: 正则 regex | 匹配 `/return Err\(.*\)/` | 表达力强 | AI生成regex易出错转义复杂 |
**折中**old_text **必填**主定位line **可选**(辅助快速跳转+去歧before/after_text **可选**(多匹配去歧)。
**理由**
- AI 做 edit 时天然持有「要改哪段」上下文,复制即用(最小认知路径)
- old_text 天然防并发冲突CAS 语义Compare-And-Swap
- line 不单独使用(无内容校验=盲替换,并发不安全)
### 决策 2多匹配处理 — 替换第 1 处 + warning
```
文件中有 N(N>1) 处相同 old_text:
→ 仅替换第 1 处
→ 返回 warning: "⚠️ 匹配到 N 处,仅替换第 1 处"
→ AI 收到 warning 后可加 before/after_text 缩小范围重试
```
**不选**「全部替换」(太危险,可能批量错改)或「拒绝执行」(太严格,第 1 处往往就是目标)。
### 决策 3并发安全 — 文件级 Mutex不用队列
#### 为什么不用队列
| 维度 | 通用文件锁队列 | DevFlow 实际需要 |
|------|-------------|-----------------|
| 范围 | 全局、所有会话、所有文件 | 仅 join_all 并发窗口 + 远期多会话 |
| 粒度 | 每文件独立 FIFO 队列 | 文件级 Mutex 就够 |
| 复杂度 | 高(调度/超时/死锁检测) | **极低(~15行** |
| 场景 | 多用户 / 分布式 | 单进程单用户桌面应用 |
#### 当前真实并发源
```
唯一并行点: audit.rs:338 join_all — Low 风险工具并行执行
典型场景: AI 同时 list_projects + read_file + (未来) patch_file 同一文件
```
#### 实现
```rust
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// 全局文件锁表:每个路径一把互斥锁
static FILE_LOCKS: Lazy<Mutex<HashMap<PathBuf, ()>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
// handler 内使用:
let abs_path = validated_path.canonicalize()?;
let _guard = FILE_LOCKS
.lock()
.entry(abs_path)
.or_insert_with(|| ());
// guard drop 时自动释放
```
**效果**同一文件读写串行化不同文件仍并行。Mutex 释放后后续操作继续。
### 决策 4外部脏写防御 — expected_hash 指纹校验
#### 指纹选型
| 方案 | 精度 | 开销 | 适用 |
|------|------|------|------|
| **mtime + size ✅ 选定** | 秒级 | ~μs一次 metadata 调用) | 本地桌面应用 |
| blake3/sha256 | 内容级 | 大文件 ms 级 | 需要密码学强度时 |
| inode + mtime | Unix 语义 | ~μs | Windows inode 不同 |
**mtime + size**DevFlow 是本地桌面应用,「外部修改」= 用户切 VS Code 改了几行再回来,时间差 >1s。实现最简单。
#### 流程
```
read_file(path) → { content, file_hash: "1718400000_23456" }
AI 基于内容决策 ↓
patch_file({ path, expected_hash: "1718400000_23456", ... })
后端:
① current_meta = metadata(path)
② current_hash = format!("{}_{}", modified.timestamp(), size)
③ current_hash != expected_hash?
→ Err("⚠️ 文件已被外部修改(hash 不匹配),请重新读取")
含 current_hash 让前端可选自动重读
④ hash 通过 → 执行 patchL2 old_text 校验 + L3 .bak
```
### 决策 5截断策略 — 软删除标记(非真删)
关联 UX-2025-09 编辑消息功能。patch_file 本身不涉及消息截断,但设计原则一致:
```
messages 表: status 列
active — 正常显示
truncated — 被编辑截断(前端不展示,后端不进 context
保留历史可追溯,与 WF-A soft_delete 模式一致
```
---
## 四、三层防御架构
```
┌─────────────────────────────────────────────┐
│ L1: 文件级 Mutex │
│ 防时机冲突:同文件读写自动串行化 │
│ 实现: HashMap<PathBuf, Mutex<()>> (~15行) │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ L2: old_text 精确匹配 │ │
│ │ 防内容错配:= 乐观锁(CAS) │ │
│ │ 内容变了 → 匹配不上 → 报错 │ │
│ │ 实现: 内容扫描 (~20行) │ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ L3: expected_hash 指纹校验 │ │ │
│ │ │ 防版本漂移: 外部修改检测 │ │ │
│ │ │ 实现: metadata() (~10行) │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
│ │
│ 底层兜底: FR-S7 .bak 备份(误操作可恢复) │
└─────────────────────────────────────────────┘
```
各层职责独立、互补:
| 层 | 管 | 防什么 | 失效后果 |
|----|-----|--------|---------|
| L1 Mutex | 时机 | 两操作同时碰同一文件 | 数据丢失/半写 |
| L2 old_text | 内容 | 操作基于过时内容 | 错改别处 |
| L3 hash | 版本 | read→patch之间文件被外部改 | 基于错误版本操作 |
| .bak | 恢复 | 以上全失效时的最后防线 | 可回滚 |
---
## 五、查找策略line + old_text 组合)
```
有 line 参数?
├─ YES → 跳到该行,检查周围是否包含 old_text
│ ├─ 匹配 → 替换(快速路径 ✅)
│ └─ 不匹配(行已偏移) → 降级: 全文扫描 old_text
└─ NO → 全文扫描 old_text
全文扫描结果:
├─ 0 处匹配 → Err("未找到目标文本,文件可能已被修改")
├─ 1 处匹配 → 替换 ✅
└─ N 处匹配(N>1) → 替换第 1 处 + warning("匹配到 N 处...")
```
---
## 六、边界情况处理
| 边界情况 | 行为 | 理由 |
|---------|------|------|
| 文件不存在 | Err("文件不存在") | 安全第一 |
| 文件 >1MB | warn + 继续或拒绝(复用 FR-S2 上限) | 防性能问题 |
| 二进制文件(含 \0 | Err("不支持二进制文件") | 文本操作不适用 |
| old_text 为空串 | Err("old_text 不能为空") | 防全文件匹配 |
| new_text == old_text | success + warning("无实际更改") | 不浪费 I/O |
| patches 为空 | Err("patches 不能为空") | 无意义调用 |
| 单次 patch 文件膨胀 >900% | warn复用 FR-S7 逻辑) | 异常检测 |
| 目标路径是 .bak/.tmp | is_noise_file 过滤拒绝CR-03 | 不改临时文件 |
| 路径含 ".." | validate_path 黑名单拦截 | 路径遍历防护 |
| RiskLevel | **Medium**(修改已有文件) | 比 write_file 同级(都是改文件) |
---
## 七、性能分析
| 操作 | 开销 | 对比基准 |
|------|------|---------|
| Mutex 获取/释放 | ~100ns非竞争/ μs 级(等待) | 文件 I/O 是 ms 级,可忽略 |
| 全文扫描 old_text | O(n), n=行数(<1MB) | <1ms |
| hash 计算 | metadata() 一次系统调用 | ~μs 级 |
| .bak 备份 | 文件大小一次 copy | SSD ~100MB/s |
| **总开销** | | **<5ms<< LLM 秒级延迟)** |
---
## 八、与现有架构兼容性
| 维度 | 兼容性 |
|------|--------|
| tool_registry 注册 | ✅ 完全复用 write_file 模式 |
| RiskLevel 分流 | ✅ Medium → 审批(白名单收紧:纯读取外全审) |
| audit 审计日志 | ✅ process_tool_calls 自动入库 |
| validate_path | ✅ 复用黑名单 + canonicalize |
| .bak 备份 | ✅ 复用 FR-S7 已有逻辑 |
| ToolCard 前端渲染 | ✅ args 键值对自动适配 |
| 数据变更联动 AR-11 | ✅ emit df-data-changed 触发刷新 |
| 会话级授权 AE-04 | ✅ write_file 同类操作,可纳入 session_trust |
---
## 九、实施步骤
### 第一批(核心三件套,~50 行后端 + 前端零改动)
1. **`mod.rs``tool_registry.rs` 顶层**:加 `FILE_LOCKS` 静态 Mutex~10 行)
2. **`tool_registry.rs`**:注册 `patch_file` handler~40 行)
- 参数解析 + validate_path
- expected_hash 校验(可选,第一批可先加框架)
- 逐 patch 执行:扫描 old_text → 替换 → 记录结果
- .bak 备份(复用 write_file 逻辑)
- 截断输出stdout/stderr 各 10KB
3. **测试**AI 调用 `patch_file` 改一个已知文件,验证替换正确性
### 第二批(增强层,按需)
4. before/after_text 锁定逻辑(~15 行)
5. expected_hash 指纹校验完整接入(~10 行)
6. read_file 返回值扩展 file_hash 字段(~5 行)
### 第三批(远期)
7. replace_all 开关
8. regex 支持old_regex 字段)
9. undo stack会话内 patch 历史)
---
## 十、替代方案否决记录
| 方案 | 否决理由 |
|------|---------|
| sed/awk via run_command | B-37 stdout 空;修好后也是间接操作,无原子性/备份/审计 |
| write_file 全量覆盖 | 已出事故(762→248字节);大文件 token 浪费;无 diff |
| git apply (Git-based patch) | 强依赖 git 仓库;非 git 目录不可用 |
| JSON Patch (RFC 6902) | 面向 JSON/结构化数据;不适合自由格式文本 |
| AST-level (tree-sitter) | 过度工程;需每语言 parserAI 代码未必能 parse |
| 纯 line 号编辑 | 无内容校验=并发不安全;行号漂移易出错 |
| 全局文件队列 | 单用户桌面不需要Mutex 够用且简单 10x |

View File

@@ -322,6 +322,12 @@
- **治理方向**:① 后端支持阶段流转planning→in_progress…② 前端 map 对齐后端真实值active/deleted/archived③ 拆双字段status 生命周期 + stage 开发阶段)。
- **状态**:📐 待治理(治标已落地,根本病根未除)
### i18n import 统一 `@/i18n` 路径别名 [2026-06-15]
- **决策**vite.config.ts 加 `resolve.alias.{ '@': '/src' }` 路径别名;全项目 i18n import 统一为 `import i18n from '@/i18n'`,替代相对路径 `../i18n`/`../../i18n`
- **原因/取舍**CR-08 i18n 批量改造时workflow 代理将 stores/ 下层文件 i18n import 从相对路径(`../../i18n`)改为错误多层的 `../../../i18n` 或正确的 `@/i18n`(但项目无别名配置),导致 vite 构建失败(Could not resolve)。相对路径随文件深度变化易断stores/ 两层 vs composables/ai/ 三层 vs utils/ 一层);`@/i18n` 绝对路径不随文件位置变化零维护成本Vue/Vite 生态标准做法(多数 Vue 项目默认配 `@` → src别名仅影响构建时解析运行时无开销。
- **影响范围**9 个源文件(import 侧) + 1 个配置文件(vite.config.ts)。
- **状态**:✅ 已落地commit d6eb855 + 6254d06
## 状态持久化
### 窗口位置/大小:用 tauri-plugin-window-state纯 Rust 层)
@@ -481,6 +487,14 @@
- **「决策」术语边界**2026-06-12devflow 语境「决策/决策需求点」= 日常开发功能细节取舍(为什么这么定),**非** aichat 决策能力升级B 路线 coordinator/conditions。后者属架构层记 Phase2计划/模块文档,不混入本文档。
- **代码审查甄别原则**2026-06-13审查发现问题时按「运行时失败/数据损坏 → 简单清理 → 记录不动 → 不做」四档甄别。当前项目规模下list_all 无 LIMIT、ALLOWED_COLUMNS 不分表、bool→int 重复等属「记录不动」——个人工具表不超千行,加分页/拆白名单是过度设计,维护成本 >> 收益。原则:**真实 bug 修、简单清理做、规模不到位的优化先不动**,保持全局简洁和扩展容易。
## 文档维护
### 文档历史项保留原则:标状态不删行 [2026-06-15]
- **决策**所有文档ARCHITECTURE.md + 模块文档)中的历史设计项**一律保留原文不删除**,仅在行末或旁注标注实现状态:`✅ 已实现` / `❌ 未实现(设计预留)` / `⚠️ 骨架空壳(有文件但无实质逻辑)` / `~~已删~~`R-PD-X 等重构决策引用)。
- **原因/取舍**:全量核对报告(2026-06-15)发现 ARCHITECTURE.md 含多出虚构/过时项(ModelRouter 已删/Docker 等 5 节点未实现)。初版方案为「删虚构行+注」,用户两次明确否决删方案,要求保留全部历史项+标状态。理由:① 保留设计演进痕迹,接手方可理解"曾经考虑过什么、为什么没做";② 删除会导致核对报告等交叉引用断链;③ 标状态列比删行信息量更大。
- **影响范围**DOC-260615-01~14 全部文档修项均遵循此原则。已落地ARCHITECTURE.md §5.4 ModelRouter 标 `❌ ~~已删~~` + §5.5 8 节点标 `✅` / `❌`commit be38a44
- **状态**:✅ 2026-06-15 落地(用户两次否决删方案后定稿,首批标注已 commit
**相关文档**
- [Phase 1 架构决策](./Phase1架构决策-2026-06-12.md) — 架构级决策ADR
- [经验记录](./经验记录-2026-06-14.md) — 踩坑/约定/技巧/bug 排查教训