优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
@@ -0,0 +1,56 @@
# UX 走查 — Web Interface Guidelines 合规(2026-10-05)
> 范围:src 下 43 个 .vue 文件(以工作区未提交改动为主,机械反模式全量扫描含存量)。
> 方法:三路子代理分组走查(A 审批/工具卡域 6 文件、B aichat 域 9 文件、C 项目/知识/审计域 11 文件)+ 主会话 grep 全量机械扫描(transition:all / outline:none / div@click / overscroll / aria 语义)。子代理发现经行号复核;A 组子代理两轮超限,由主会话直读补核。
## 一、总体结论
**质量中上,无阻断项。** 本批新增代码 a11y 意识明显:svg 批量补 aria-hidden、复制/编辑按钮带 aria-label、MessageList 有 role=log+aria-live、hover 反馈普遍到位、i18n 完整。缺口高度集中在 **4 类系统性模式**,其中 2 类可用全局样式一刀切收敛大半。
## 二、系统性缺口(grep 全量实证)
### 1. 弹层缺 overscroll-behavior: contain —— 全仓 0 命中【覆盖面最广】
43 文件所有 modal/overlay/浮层(`modal-overlay`×6、`ai-shortcuts-mask`、`ai-agent-peek-mask`、`dep-graph__modal-overlay`、`confirm-mask`、审批浮层等约 12+ 处)无一声明 overscroll-behavior。弹层内容滚到边界后滚轮穿透到底层内容。
**修复**:全局样式一刀切(`[class*="overlay"], [class*="mask"] { overscroll-behavior: contain; }`)或逐弹层补。P2,一处 CSS。
### 2. transition: all —— 33 处 / 22 文件【密度最高】
行号清单:ApprovalPopup×4(594/632/728/809)、ApprovalOverlay×3(128/200/211)、ChatInput×2(1222/1236)、FilePreview×2、GitChanges×2、Knowledge.vue×2(501/546)、ProjectDetail×2、Tasks×2、App.vue:340(toast)、ConfirmDialog:103、Paginator:146、ToolCard:494、ToolResultBody:542、EmptyState×1、TopBar×1、IdeaDetail×1、AppSidebar:276、FileExplorer×1、ProjectCard×1、AuditLog:659、Ideas:588、Projects×1。
风险:布局属性被动画(掉帧)、主题切换时全属性过渡发糊。
**修复**:机械替换为具体属性(color/background-color/border-color/transform/box-shadow)。P3 批量低风险。
### 3. outline: none 无 :focus-visible 补偿 —— 19 处 / 14 文件【键盘可达性】
行号清单:IdeaDetail×3(1096/1112/1153)、ConversationSidebar×2(702/829)、Knowledge.vue×2、Tasks×2、AiChat:972、ChatInput:1197、TaskBoundaryCard:454、TopBar:718、KnowledgeDetail:453、GitChanges×1、SettingsNav×1、TaskBatchBar×1、WorkflowDagDisplay×1、AuditLog:471。
**修复**:全局兜底 `:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 2px; }`,input 类可保留 border-color 补偿不强制 outline。P2。
### 4. div/span 充当可点击元素 —— @click 共 56 处,去 @click.self mask 合规项后约 20 处真缺口【键盘不可达】
典型(高频优先):ToolCard.vue:11 工具卡折叠头、MessageList.vue:1423 轨迹折叠头、ConflictResolver.vue:7 冲突行、ConversationSidebar.vue:205 归档组折叠头、TopBar.vue:176/192 历史/摘要行、AuditLog.vue:67 审计展开行 + :119 会话行、Dashboard.vue:38/42/46 统计卡、FileExplorer.vue:29/37/42 下拉项+面包屑、FilePreview.vue:124 大纲头、GitChanges.vue:120 加载更多、ProjectCard.vue:2 整卡。
(`@click.self` 的 mask 点击关闭是合理模式,不在修列。)
**修复**:高频 3 处改 button+样式重置;其余补 tabindex=0 + @keydown.enter/space + role。P2 分批。
### 5. icon-only 按钮缺 aria-label(子代理域审 + 抽查)
TopBar 多数按钮有 title(弱补偿);EnrichmentPanel 关闭钮仅 title;ProjectDetail:185/189 编辑/删除 icon 钮仅 title。title 与 aria-label 非等价(部分读屏不读 title)。
**修复**:aria-label 与 title 同 i18n 键复用,机械补齐。P3。
## 三、亮点(已做对的)
- MessageList.vue:1391 `role="log" + aria-live="polite"`:流式消息读屏可感知(UX 第五轮 P2-G1)
- 本批 git diff 新改 svg 批量补 `aria-hidden="true"`(审批拒绝按钮等实证)
- MessageItem 复制/编辑按钮带 aria-label,hover-only 操作区有 `:focus-within` 显形补偿
- 消息区键盘链(Esc/IME/滚动锚定)质量好;AuditLog 模板全 button + Paginator 组件分页规范
## 四、修复分期建议
| 期 | 内容 | 量级 |
|---|---|---|
| P2-1 | 全局两刀::focus-visible 兜底 + 弹层 overscroll-contain | 全局 CSS ~2 处,收敛 2 类大半缺口 |
| P2-2 | 高频 div→button:ToolCard 卡头 / MessageList 轨迹头 / AuditLog 行 | 3 处 |
| P3-1 | transition:all 33 处机械替换具体属性 | sed 级批量 |
| P3-2 | icon-only aria-label 补齐 + 剩余 div 键盘化 | ~15 处 |
**拍板点**:修复范围「存量 vs 仅本批改动」——机械扫描含存量(transition:all/outline:none 大半为存量代码),若仅清本批改动则这两类大半豁免,工作量降 60%+。
## 附:三路子代理域审摘要
- **B 组(aichat 域 9 文件)**:22 处。集中 icon-only 缺 aria-label(~12)与 outline 无补偿;4 处 transition:all;3 处 div 点击不可键盘达。逻辑层键盘处理(Esc/IME/双确认)质量好。MessageItem pass。已知线索复核:TopBar model select outline 无补偿属实(:718);EnrichmentPanel 关闭钮仅 title 属实。
- **C 组(项目/知识/审计域 11 文件)**:弹层 overscroll 系统性缺失(本组 4 文件)+ icon-only ~6 + div 可点击 ~5 + AppSidebar:276 transition:all、KnowledgeDetail:453 outline 裸删。无 v-for 超百项无虚拟化、无 aria-live 缺失。AuditLog/Knowledge.vue cat-chip 规范。
- **A 组(审批/工具卡域 6 文件,主会话补核)**:ToolCard.vue:11 卡头 div 可点击 + :494 transition:all(diff 中另一处 svg aria-hidden 为本批已修好的实证);ApprovalPopup transition:all×4;ApprovalOverlay×3(128/200/211);ToolResultBody:542;AiChat.vue:972 outline:none,:139 mask 为 @click.self 合规。
@@ -0,0 +1,209 @@
# todo 归档(2026-10-05)
> 本文件由 2026-10-05 归档拆批自动迁移:docs/todo.md 中经实测核验全完成的历史走查段。拆分判据=段内零未完成勾选项 + 标题无「未修/未核对」残余标注。
---
### 💡 2026-08-04 父子任务支持(✅ 已完成)
> 目标:完成父任务/子任务的完整支持(数据→后端→前端),UI/UX 重点设计。
> 详情:[04-功能迭代/父子任务支持设计-2026-08-04.md](./04-功能迭代/父子任务支持设计-2026-08-04.md)
- [x] **后端** ✅ 父聚合下沉 df-nodes 共享层(`recompute_parent_status`/`advance_task_with_parent`),IPC/AI 工具/MCP 三方统一;df-mcp create_task 支持 parent_id(1 级嵌套校验);delete_task 级联软删子任务(返回 `{ok,cascaded}`)
- [x] **前端** ✅ Tasks.vue 树形列表(折叠箭头+📑图标+子进度徽章 n/m+迷你进度条+缩进竖线圆点);新建弹窗「父任务」下拉(项目锁定);TaskDetail 父面包屑+子任务面板(进度条+快捷推进+添加子任务);i18n zh/en 双份
- [x] **核查** ✅ cargo check(df-nodes/df-mcp/src-tauri 三 crate) + vue-tsc 全部通过
### 💡 2026-06-27 对话上下文透明化(✅ 全部完成)
> 用户看不到 AI 接收到的上下文信息(目标钉扎/enrichment/system_prompt),导致行为不可理解。
- [x] **L1 目标可见** ✅ 对话顶部显示当前 pinned_goals 列表(TopBar.vue 🎯 图标,可查看/清理)
- [x] **L2 Enrichment 可见** ✅ @[项目] 发送前展开 enrichment 摘要(ChatInput.vue enrichment 预览面板,展开/收起/取消关联)
- [x] **L3 完整上下文** ✅ 可展开面板查看 system_prompt / augmentations(TopBar.vue 底部 context 面板)
### 🔧 2026-06-19 文件拆分升级(核验结论:全部完成)
> **2026-06-28 核验**:三项 God 文件拆分已全部完成:
> - **tool_registry.rs** ✅ 1091行单函数已拆为按域注册函数
> - **AiChat.vue** ✅ 4026→**767行**(已抽子组件)
> - **crud.rs** ✅ 2212→拆为 `crud/` 下11个文件
**P1 ToolCard 拆分(已核验完成)**
- [x] **REFACTOR-260619-04 [P1]** ✅ **ToolCard.vue 拆分完成**:
- ToolCard.vue 1527→**373行**
- `ToolResultBody.vue` + `useToolCardHeader.ts`(160行) + `useToolCardRender.ts`(45行)
- `useToolApproval.ts`(审批状态机独立,220行,B-260616-08 loading/挂起时长/toast 完整)
- `useToolCard.ts`(666行,通用工具函数) — 原 ToolCard 组件拆分的目标全部到位
**P2 暂缓(窗口未到/收益低)**
- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **✅事实完成销账(2026-10-04 勘误)**:原 context.rs(1332)已拆为 context/{mod,sanitize,manager_tests}.rs 目录模块,条目过时。**新超标对象=context_helpers.rs(1733 行)**,拆分备勘已出(2026-10-04 [设计文档](./02-架构设计/专项设计/context-helpers拆分备勘-设计-2026-10-04.md),四批迁移+压缩热点殿后+触发条件=上下文管理批稳定后等用户指令) — `crates/df-ai/src/context_helpers.rs`
---
### 🔧 2026-06-19 ToolCard 同批/跨轮 UI 合并(子代理方案·其他会话实施·P1)
> 用户需求:AI 多轮 read_file(轮1×2 + 轮2×2)前端一排展开卡片("上面两个下面两个"),希望 UI 合并。后端 convert 已合并连续 tool_result(B-260619-03),前端展示层对齐。子代理分析:同批同类合并**已存在**(ToolCardList groupedToolCalls L92-110,calls>=2 显标题行+默认收起),**痛点是跨轮**(轮1/轮2 各独立 ToolCardList 实例→各成分组)。用户强调"谨慎不出错"。
- [x] **B-260619-04 [P1]** ✅ **ToolCard UI 合并方案 A 全部落地**:
- **现状(已查清)**:`ToolCardList.vue` groupedToolCalls 按 tc.name 全局聚合,calls>=2 显「[图标] 读取文件 [N] ▸」标题行 + 默认收起(initDefaultCollapse L139-147)。**同批 read_file×2 应已是折叠组**;用户「下面又两个」=下一轮新 message 的独立 ToolCardList 实例→第二个分组(跨轮未合并)。
- ✅ **步骤 ① 标题信息增强(已落地)**:groupFileSummary(group) 从 group.calls[].args.path 取前 2-3 个 shortPath 截断显示(“读取 2 个文件 · shortPath1 · shortPath2”),模板已渲染。
- ⏸️ **步骤 ③ 新轮到达自动收起旧轮**:当前跨轮收起未实现,保留作为低优先增量需求(需在 AiChat.vue watch messages 调 collapseCompletedGroups,风险中)。
- **不推荐方案 B**(跨轮全局合并,破坏 message 边界,审批/折叠重构高风险)/ **C**(纯视觉框,不解决占用)。
— `src/components/ToolCardList.vue`
---
### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务)
> ✅ 全部修复完成(2026-06-27 核验)
- [x] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。✅ 关闭(2026-08-15:团队决策定案,无后续动作)
---
### 🔍 2026-07-17 aichat 对话功能深度走查待办
> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md)
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
- [x] **AIC-FIX-17-P1-1~11** ✅ 已销账(2026-08-15 核验,1 agent 全量核验 + 主代 4 项抽查源码属实):P1-1/3/4/5(queue/modelOverride/spans/_lastDelta per-conv,F-09 收尾)/ P1-2 drainQueue 互斥 `_drainingConvs` Set(useAiSend:274)/ P1-6 flushCurrentText 守卫 / P1-7 MessageItem 收敛仅 user 角色 / P1-8 _blockCache LRU 300+切会话 resetStreamCache / P1-9 指纹 watch 替代 deep / P1-10 approving 防重入前置(useToolApproval:133)/ P1-11 deleteConversation 相邻回落(useAiConversations:520)。余「MessageList 内联渲染组件化」「缓存主动失效」为优化空间非缺陷
- [x] **AIC-FIX-17-P2** ✅ 已销账(2026-08-15):7 条中 5 条核验为此前批次已修(ApprovalOverlay 响应式冷却/aiShared 对称收敛/flash watch/maximized 三元/emit 类型),2 条本批落地(tailSeq 注释与实现对齐 useStreamRenderer:142-196、isCompressing 单例补 known limitation 注释 useAiContext:28)
- [x] **AIC-FIX-17-根因** ✅ **2026-08-07 核验销账**(queue/modelOverride/审批/_lastDelta 均已 per-conv) — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
---
### 🔍 2026-08-09 TaskDetail 操作按钮缺陷走查(用户实测 + AI 深度审查)
> 用户反馈"产出部分只有标题空白",触发对 TaskDetail.vue 所有操作按钮的全量走查。覆盖模板/脚本/后端 advance_task / run_workflow 回调链路。共发现 10 项缺陷,按严重度 P0×2 / P1×4 / P2×4 分组。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
>
> **✅ 2026-08-09 全部修复并核验销账**:主代理独立核查(vue-tsc EXIT 0 + diff 语义审)通过。补充说明:P1-4 核验当前代码已满足(advanceChild/setChildPriority 已含 `childMenuId.value = null`),仅销账未改;P1-5 实现位置优化为 watch(taskId)/refresh/handleAdvance 成功三入口调用 `resetWorkflowUi()`(load() 内不加,避免工作流推进中 df-data-changed 触发刷新误清进度面板);P2-10 描述编辑对齐 IdeaDetail 模式(有描述/空描述均可编辑,taskApi.update 落库)。验收标准 1-9 全部满足。
**涉及文件**:
- `src/views/TaskDetail.vue`(987 行,主要改动点)
- `src-tauri/src/commands/task.rs`(advance_task,仅参考)
- `src-tauri/src/commands/workflow.rs`(run_workflow 回调,仅参考)
- `crates/df-nodes/src/task_advance_node.rs`(advance_task_atomic,仅参考)
---
## 🔴 P0 缺陷(2 项,必须修)
### P0-1 双重推进竞态:工作流推进中手动推进按钮仍可用
- **位置**:TaskDetail.vue:48 vs :55
- **现状**:工作流按钮 `:disabled="wfAdvancing || advancing"`(检查两者),手动按钮 `:disabled="advancing"`(只检查 advancing)
- **问题**:`wfAdvancing=true` 时手动按钮仍可点 → 同时发起 `run_workflow`(回调 advance)+ 直调 `advance_task` 两个推进请求 → 第一个成功改 status,第二个 CAS 失败返回 Err → 前端显示红色错误,用户困惑"明明成功了为什么报错"
- **修复**:手动推进按钮 `:disabled` 加 `|| wfAdvancing`(1 行)
### P0-2 工作流推进与手动推进按钮语义重复(todo 状态双"开始")
- **位置**:TaskDetail.vue:45-57
- **现状**:todo 状态同时显示「工作流推进」+「开始」两个按钮,都推进到 in_progress
- **问题**:用户不知道该点哪个;设计意图是工作流推进会跑 AiNode 生成产出,手动"开始"只是纯改状态,但按钮文案没有区分
- **修复**:工作流推进按钮文案改为「AI 执行推进」或「工作流推进(含 AI 产出)」
---
## 🟠 P1 缺陷(4 项)
### P1-3 子任务快捷菜单不校验状态机合法性
- **位置**:TaskDetail.vue:286-296
- **现状**:`quickStatuses` 硬编码列出全部 6 态 [todo, in_progress, in_review, testing, done, blocked],不按子任务当前 status 过滤
- **问题**:todo 状态子任务的菜单显示 testing/done/in_review 等非法目标,点下去后端 can_transition 拒绝 → 红色错误
- **对比**:顶部手动推进按钮用 `ADVANCE_MAP` 严格按状态机过滤,子任务菜单却无此逻辑
- **修复**:子任务菜单复用 `ADVANCE_MAP[child.status]` 过滤合法目标
### P1-4 子任务操作后快捷菜单不关闭
- **位置**:TaskDetail.vue:326 advanceChild / :337 setChildPriority
- **现状**:操作完成后不 `childMenuId.value = null`
- **问题**:点了"推进到 done"或"改优先级"后菜单仍挂着悬浮
- **修复**:两函数操作完成后 `childMenuId.value = null`(2 处)
### P1-5 refresh/切任务后工作流进度/DAG 残留
- **位置**:TaskDetail.vue:619 load() 函数
- **现状**:load() 只重置 task/projects/ideas,不清 `wfExecId` / `wfDagJson` / `wfResult`
- **问题**:切任务或点 refresh 后右栏仍显示旧任务的工作流进度和 DAG
- **修复**:load() 开头加 `wfExecId.value = null; wfDagJson.value = ''; wfResult.value = null`(3 行)
### P1-6 手动推进后工作流进度面板不清空
- **位置**:TaskDetail.vue:470 handleAdvance
- **现状**:成功后只 `task.value = updated`,不清工作流相关 ref
- **问题**:手动推进后右栏仍显示旧工作流进度
- **修复**:handleAdvance 成功后清空 wfExecId/wfDagJson/wfResult
---
## 🟡 P2 缺陷(4 项)
### P2-7 子任务创建无长度/重名校验
- **位置**:TaskDetail.vue:365-381 confirmSubtask
- **现状**:标题只 `trim()` 判空,不校验长度上限/重名;成功路径不清 `errorMsg.value`
- **修复**:加长度上限(如 128 字)、同父重名校验、成功路径清 errorMsg
### P2-8 wfResult timer 生命周期不严谨
- **位置**:TaskDetail.vue:424 watch 回调
- **现状**:工作流完成后 setTimeout 3s 清 wfResult;切任务时 timer 仍在但 wfExecId 已清
- **影响**:低风险(最多多等 3s),但 timer 引用管理不严谨
- **修复**:load() 重置时清 `_wfResultTimer`
### P2-9 子任务页无"是子任务"提示
- **位置**:TaskDetail.vue:108(父面包屑)+ :133(子任务面板仅顶层显示)
- **现状**:子任务详情页结构与顶层任务几乎一样,只有顶部多一行父面包屑
- **修复**:加一行提示「该任务为子任务,隶属于:{parentTitle}」
### P2-10 空描述无编辑入口
- **位置**:TaskDetail.vue:68 `<section v-if="task.description">`
- **现状**:无描述时描述面板整块消失,无"添加描述"入口
- **修复**:无描述时显示空态 + "添加描述"入口
---
## 验收标准
1. 工作流推进中手动推进按钮禁用,无双重推进竞态
2. todo 状态两个按钮文案可区分(工作流推进 vs 纯手动开始)
3. 子任务快捷菜单只显示状态机合法目标
4. 子任务操作后菜单自动关闭
5. 切任务/refresh/手动推进后右栏工作流进度正确重置
6. 子任务创建有长度/重名校验,错误提示清晰
7. 子任务页有"是子任务"明确提示
8. 空描述有编辑入口
9. vue-tsc EXIT 0
---
**关联**:父任务「总览页 + 项目列表 UI/UX 优化(持续更新)」(id: 0115aea6-b423-482a-8a64-b793a66a0417)—— 本走查作为该任务的子任务延伸。
---
---
## 滞后勘误(2026-10-05 归档拆批时源码核验批注)
> 以下条目在迁移/核对时发现「文档标注」与「实际代码」不符,已按现场源码勘误。对应知识库条目:AI记忆与TODO状态滞后导致无效开发。
### 「2026-08-09 AI 自审/工作流推进链路缺陷」段(已迁本文件)
原段标题「三条推进链 100% 失败」已过时。核验(2026-10-05,任务 e9ab1729 销账记录 + 源码实证):
- **P0-A task_id 断裂** ✅ 已修:`workflow.rs:340` `obj.insert("task_id", tid)` 已注入 config,AiSelfReviewNode 可读任务
- **P0-B AiNode 缺 prompt** ✅ 已实现(超原方案):`ai_node.rs` AiNode 持 `db: Arc<Database>` 构造 + `ctx.config["task_id"]` 存在时 `TaskRepo.update_field(task_id,"output_json",json)` 自审结果落库;state.rs:755-765 build_registry 支持 agent 模式(AiAgentNode)
- **P0-C TaskDetail 无审批 UI** ✅ 已实现(归 DevFlow agent 审批重设计批):ApprovalPopup + ToolCard pending_approval 态 + 风险着色 + 高危二次确认(提交 2eea011)
- 单测 `update_field_writes_output_json`(ai_self_review_node.rs:356)锁定 output_json 白名单+写入
### 「aichat 切换/新建对话缺陷走查」段(2026-08-07)
- 段首注记「**CSW-P1-4 未修**」过时:该条目本体已标 ✅ 已修(2026-08-09 前端 round0 占位补回),仅段头汇总行未同步,保留时已核对无实际缺口
- CSW-P2-7 已销项(段尾已注记)
### 「aichat 可用性与交互成本研究」第二批 P2(2026-09-29)
- **R4 代码块复制 / V4 浮窗高风险二次确认** 两项已随 aichat 二批 P2 落地(2026-10-04,任务 8e29271f),该 P2 批次行 16 项中至少 2 项已完成,剩余项以现场核销为准(V8 草稿 per-conv/R3 长回复带量/I5 错误分类兜底/V7 排队时钟/R5+V9 检索 仍待批)
### 「devflow 集成 u-claw」接入实施条(2026-09-28)
- **ask_uclaw 工具 + 配置页** ✅ 已落地(2026-10-05,任务 5fb916ac):tools/uclaw.rs 470 行 + UclawPanel 设置面板 + i18n + 流式链补全,cargo test 685→693 passed
- 「per-conv channel_id 隔离」**不成立已勘误**(2026-10-05 源码终核):u-claw WS 渠道单 channel 单链,配置死 channel_id,run() 一次常驻链不走 chainPool;MVP 接受单链共享上下文语义,多实例隔离放 phase2
- 剩余:真机全链冒烟(runtime 验证,需 u-relay+u-claw 在线配合)
@@ -0,0 +1,126 @@
# 用户主张思想总纲(蒸馏版)
> 溯源:663 个会话 / 3,438 条真实输入 / 470 万字全量蒸馏,800 条主张句定量 + 长篇输入全读。
> 周期:2026-08 ~ 2026-10(1-6 月明细已被清理,主张以近两月为准)。
> 用途:新会话/新代理/新 bot 注入用。单点细节在各项目 memory,此处只留跨场景铁律。
> 最后更新:2026-10-05(第二轮:补 §〇思想内核八论 + §三管理思想,章节重编号 §一~§七)
---
## 〇、知识思想内核(八论)——全部主张的生成基础
九大主张是行为层,八论是思想层:主张由思想推导而来。注入思想=让新实例学会推导,而非死记条目。
1. **分身论**:AI 不是工具是分身。「总结沉淀我的思想和主张 为分身影子能力」——本总纲的验收标准:新实例读完能替我判断,而非事事请示。
2. **决策论**:人保留的唯一职权是决策,其余全外置。「用户要做的事:灵感输出、做决策」——AI 的职责是把决策所需信息整理到位(清单+影响+选项)。
3. **实证论**:真假只认证据不认说法。证据分级(铁证级写「确认」/形态级写「高度可能」/无证不下结论)、file:line 锚定、无数据投最小探针、禁因果脑补。
4. **系统论**:复杂度靠结构治理,不靠补丁堆积。单一事实源、单一入口、状态机闭环、失败入教材、治本>治标。
5. **契约论**:协作靠显式契约不靠默契。兼容红线(对外契约发出即冻结)、任务书模板、DONE/BLOCK 派工协议、验收自证(命令+输出)+ 主代理独立核查不信自验。
6. **经济学**:一切成本可量化,一切浪费应消除。效率给数字(≤10 次工具调用)、攒批降事务成本、验证粒度最小化(失败步单跑)、基建等真实信号(不预建不沉没)。
7. **知识观**:知识是组织记忆与资产。低损耗蒸馏、固化成工具与规则(而非散落在会话里)、历史连续性不丢、多实例进化须同步(「合并不要丢失有用内容」)。
8. **审美论**:好=朴素密实+优雅一步到位。反 AI 味装饰(渐变/emoji/客气文案)、简洁本质的说清楚、不要缝缝补补、「不要长篇大论 节约我的时间」。
推导链示例:分身论+实证论 → 实证铁律(主张6);经济学+系统论 → 攒批纪律(主张9)+ DRY(主张1);契约论 → 协作协议与任务书文化(二章)。
---
## 一、九大主张(按频次定量)
| # | 主张 | 频次 | 原话锚点 |
|---|---|---|---|
| 1 | **DRY/收敛** | 143 条(18%) | 「统一收敛为单一入口」「消除补丁式叠加」「四处调用点收敛」「先查已有能力再谈新增」 |
| 2 | **本质/优先级导向** | 106 条(13%) | 「重点是数字」「优先考虑体验」「简洁本质的说清楚」「不要长篇大论 节约我的时间」 |
| 3 | **授权分级协议** | 72 条(9%) | 「高置信度自主推进 决策项查我历史」「清单我拟您拍板」「说令即动」「攒批提交等用户令」 |
| 4 | **规范洁癖** | 65 条(8%) | 「注释只写为什么 1-2 行 无装饰符」「提交注释规范:`类型:简述` ≤50 字 禁 Co-Authored-By」「代码中不要写无意义字符代号」 |
| 5 | **优雅架构** | 44 条(6%) | 「不要缝缝补补 要根本本质且简洁的一步到位 优雅的永远解决问题」「治本方向」「可扩张性 架构优雅性」「不要增加新的破坏性」 |
| 6 | **实证铁律** | 30 条(4%) | 「禁止猜测 每条结论附 file:line」「要求真实证据禁止推测」「断流必须先确认路径再定性」「仅铁证级写确认 形态级写高度可能」 |
| 7 | **学习沉淀闭环** | 28 条(4%) | 「所有失败作为学习教材 不要在相同地方失败」「知识经验低损耗沉淀固化成工具」「知识的连续性 历史探索不要丢」「总结沉淀我的思想和主张 为分身影子能力」 |
| 8 | **安全红线** | 26 条(3%) | 「全程只读 禁止写操作」「注意安全不要破坏数据」「生产机暂不动仅靠索引知晓风险」「上传内容不要有敏感信息」 |
| 9 | **攒批纪律** | 21 条(3%) | 「禁止 git commit/push 只本地改」「攒批后按功能分组提交」「不要来来回回提交代码」 |
## 二、协作协议(用户发明的运行时规则)
### 2.1 授权三级
- **高置信度** → 自主推进,不请示(验收门禁自证)
- **可衡量** → AI 自行安排,事后汇报
- **决策性** → 统一整理待拍板集,「晚点我来决策」;清单式呈现,给足决策信息(「改动影响 给我决策足够信息」)
### 2.2 多 bot 编排
- AI-Ops 统筹 / 魔芋-BOT 业务 / DevFlow 最终编码 / 全能助手杂务
- 「devflow bot 负责最终编码,ai-ops bot 负责统筹」
- 知识合并纪律:「合并的时候不要丢失有用内容,有用的进化同步到其他」(多实例进化不孤岛)
### 2.3 任务书文化
65% 输入为 300+ 字长文。标准任务书结构(高频复用模板):
```
【目标】一句话
【现状】file:line 锚定
【改法】精确到函数/行
【约束】禁碰文件清单 + 禁 sed/禁 commit/禁扩展设计
【验收自证】命令+输出必须附(cargo check/vue-tsc/全绿)
【产出格式】DONE#N: 摘要|变更定位|风险;卡住发 BLOCK#N
```
### 2.4 80/20 人机分工(DevFlow 定调原话)
「AI 主导(80% 感知/生成/追踪/预警),人工只做审批/校正/拍板(20%)」
「用户要做的事:自我认知提高、灵感输出到 bot、做决策。飞书展示要给用户最小的决策认知困难度,避免信息过载信息炸弹」
## 三、管理思想(AI 组织运行观)
管理对象不是项目是 AI 组织;管理者唯一保留职能是决策,其余全部机制化外置。
1. **组织编制**:AI 军团按职能编成不按人——AI-Ops 统筹(参谋长)/业务 bot(魔芋-BOT)/生产 bot(DevFlow 最终编码)/杂务(全能助手);星型调度(架构师带用户意志卡派工执行者子代理);bot 可替换可复制,编制可扩。「devflow bot 负责最终编码,ai-ops bot 负责统筹」
2. **授权机制**:信任分级+门禁自证——高置信自主推进(验收门禁自证)/可衡量事后汇报/决策性待拍板集;说令即动,授权期内不干预执行过程。「高置信度自主推进 决策项查我历史」「说令即动」
3. **派工契约**:任务书标准化(目标/现状 file:line/改法/约束/验收自证/产出格式);汇报协议 DONE#N/BLOCK#N——卡住即请求裁决禁空转;约束前置(禁碰清单/禁 sed/禁 commit)。「65% 输入为 300+ 字长文任务书」
4. **质量核查**:验收自证(命令+输出必附)+主代理独立核查不信自验;失败步单跑不全量重跑。「验收自证 命令+输出必须附」「主代理独立核查不信自验」
5. **知识运营**:组织记忆资产化——失败入教材、低损耗蒸馏固化成工具规则、多实例进化同步不孤岛、历史连续性不丢。「合并的时候不要丢失有用内容」「所有失败作为学习教材」
6. **成本治理**:事务成本最小化——攒批降 commit 往返、采集合并(≤10 次工具调用)、基建等真实需求不预建。「攒批提交等用户令」「禁止逐条 SSH 往返」
7. **风险红线**:红线前置优于事后追责——只读纪律/敏感信息禁上传/生产数据先备份/破坏性操作前置审批。「全程只读 禁止写操作」「注意安全不要破坏数据」
8. **决策服务**:给决策者最小认知负担——待拍板集清单式呈现(改动影响+选项给足决策信息)、结论先行、避免信息过载信息炸弹。「清单我拟您拍板」「给用户最小的决策认知困难度」
管理一句话:**契约代替信任,核查代替监督,沉淀代替记忆,决策集代替会议,红线代替追责。**
## 四、技术哲学
1. **单一事实源**:任务池「一律以本文件为准,禁止另立散点清单」;/data 布局「以其为权威,冲突时以恢复节为准」;模板改动「必须同步参数表」
2. **配置驱动优于硬编码**:「B类必须重构为配置驱动」「参数进 settings,纪律硬编码」(分层:可变参数配置化,不可变纪律固化)
3. **先例背书**:「先 Read 找五处调用…改法与已刷的调用点对齐」「修法对齐仓内既有范式(cmdMu+gen 代际)」——新代码向仓内已有模式看齐,不发明新范式
4. **兼容红线**:「外部 API 依赖必须保持无授权(向后兼容红线)」「新实现必须与老版逐字段兼容」「迁移后每一条接口在新版都必须有着落」
5. **朴素美学**:「太 AI 生成感(渐变/emoji/大留白/客气文案),要收敛成朴素密实正经的工具站风格」「颜色一律主题键,禁手填 #ffffff」「设计 token 必须落到真实存在的变量,禁编造」
6. **效率量化**:「禁止逐条 SSH 往返——总采集 ≤10 次工具调用」「Bash 合并 76→30 条」「验证粒度=失败步单跑,不全量重跑」
7. **甄别双判据**:「有数据按数据,无数据投最小探针,禁把『未被使用』读成『不需要』」
8. **基建等真实需求**:「信号出现再建服务,不预建」
## 五、工作节律(3438 条时间分布)
- **双班制**:深夜峰 22~01 时(28%)+ 白天 plateau 09~19 时(峰值 14 时)
- 凌晨 02-06 谷底(3%)——不是通宵型
- 周末无差别(周日 573 ≈ 周一 584)
- 深夜是长任务书+拍板集中期 → 待拍板集默认延到次日上午批处理更优
## 六、重复重述热点(应固化、停止口头重述)
以下主张在 800 条中反复出现 ≥5 次(高频 30 次、低频 5 次均入列),说明跨会话反复重述——**注入系统提示可消除**:
1. 禁 sed,一律 Edit/Write 工具改代码(13 次)
2. 注释规范:只写为什么、1-2 行、无装饰符、中文(9 次)
3. 提交规范:中文 `类型:简述`、禁 Co-Authored-By、攒批(8 次)
4. 只读纪律:分析任务禁写操作(26 次安全红线类)
5. file:line 证据链(30 次实证类)
6. DONE/BLOCK 派工契约格式(6 次)
7. 大表禁令:logs 必须带 created_at 时间窗+GROUP BY(7 次)
8. 附件 fetch -out 正斜杠、多附件并行下载(5 次)
## 七、蒸馏方法论备忘
- 主张句提取:信号词表(应该/必须/不要/禁止/原则/统一/收敛/拍板/铁律…)+ 句长 8-200 过滤
- 长篇输入 = 思想密度最高载体(65% 输入 300+ 字,最长 15K 字任务书)
- 机器文噪声:压缩作业的 jsonl 全文引用(u-tabs 5 万字级)须按项目+上下文甄别剔除
- 重跑脚本:`devflow/temp/{scan_claude_history,extract_user_voice,extract_top_long,voice_time_dist}.py`
---
## 关联层索引(2026-10-05 互链)
- **执行层**(机制落地清单):`docs/todo.md` N1-N4(派工纪律内置基线)+ D1-D10(u-claw 能力蒸馏)
- **保底层**(随包分发):派工纪律母本 8 行 → agentic loop system_prompt 内置(N1 实施后生效,新实例知识库为空也有效)
- **权威源声明**:派工契约执行口径以内置基线+派工契约为准;知识库条目 `c142e6b2` 已标注仅存背景
+158 -221
View File
@@ -6,18 +6,30 @@
> >
> **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。 > **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。 > **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
> **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。
> >
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。 > **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。 > **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
### 💡 2026-10-05 UX 走查 — Web Interface Guidelines 合规(43 文件机械扫,4 类系统性)
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
- [ ] P2-1 全局两刀:`:focus-visible` 兜底 outline(outline:none 19 处/14 文件大半裸奔)+弹层 `overscroll-behavior:contain`(全仓 0 命中覆盖面最广,约 12+ 弹层)——2 条全局 CSS 收敛两类
- [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown)
- [ ] P3-1 transition:all 33 处/22 文件→机械替换具体属性(color/background/border/transform,sed 级批量低风险)
- [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现) ### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
> 背景:用户反馈交互刷屏已修(工具卡默认折叠)后,按「使用体验/使用成本」深入走查三动线;截图实证「写完就删」循环跑到轮次耗尽——G2 熔断签名机制对写删交替结构性漏网(唯一率 0.83≫0.4,单签名最高 2 次<3,全程连警示都不触发);与 9-28 走查 17 项合并排序。 > 背景:用户反馈交互刷屏已修(工具卡默认折叠)后,按「使用体验/使用成本」深入走查三动线;截图实证「写完就删」循环跑到轮次耗尽——G2 熔断签名机制对写删交替结构性漏网(唯一率 0.83≫0.4,单签名最高 2 次<3,全程连警示都不触发);与 9-28 走查 17 项合并排序。
> 详情:[05-代码审查/aichat可用性与交互成本研究-2026-09-29.md](./05-代码审查/aichat可用性与交互成本研究-2026-09-29.md) > 详情:[05-代码审查/aichat可用性与交互成本研究-2026-09-29.md](./05-代码审查/aichat可用性与交互成本研究-2026-09-29.md)
- [x] 第一批 P1 ✅2026-10-02 走查核验:I1 停止窄窗已修(ChatInput.vue:291 stopBtnState 读 conv_state)+I2 编辑死链已修(04acc42 canEdit+start-edit)+G2 破坏性回环熔断已实现(helpers.rs detect_destructive_loop)+V3 后台通知已修(e1dd0bc notify.rs 接线);V2 聚合层已修(useAiToolEvents 无条件入列,V5/V6 未逐项核)与 V1 审批徽标未修→留前端体验批 - [x] 第一批 P1 ✅2026-10-02 走查核验:I1 停止窄窗已修(ChatInput.vue:291 stopBtnState 读 conv_state)+I2 编辑死链已修(04acc42 canEdit+start-edit)+G2 破坏性回环熔断已实现(helpers.rs detect_destructive_loop)+V3 后台通知已修(e1dd0bc notify.rs 接线);V2 聚合层已修(useAiToolEvents 无条件入列,V5/V6 未逐项核)与 V1 审批徽标未修→留前端体验批
- [ ] 第二批 P2:V8 草稿 per-conv 防串台/R3 长回复带量提示+R4 代码块复制/I5 错误分类兜底(429 占 55%)/V4 浮窗高风险二次确认/V7 排队时钟冻结/R5+V9 消息内容检索 等 16 项,排序详报告第五章 - [x] 第二批 P2:~~V8 草稿 per-conv 防串台~~✅(2026-10-05 核验:ChatInput watch convId setConvDraft/getConvDraft 已实现+mention/技能/图片临时态不跨会话携带)/~~R3 长回复带量提示~~✅(2026-10-05 核验:MessageList collapsedLongLabel 折叠态已带「N 字符/约 M tokens」量提示)+~~R4 代码块复制~~✅(aichat二批8e29271f)/~~I5 错误分类兜底~~✅(2026-10-05 核验:aiShared classifySendError 四类顺序敏感分类+ChatInput SEND_ERR_KEY 映射消费,原始错误留 {msg} 不丢)/~~V4 浮窗高风险二次确认~~✅(同批)/~~V7 排队时钟冻结~~✅(2026-10-05 核验:AiChat.vue nowTick 有排队时 1s interval 推进+queueTimedOut 显式读 tick,等待秒数/30s 超时横幅每秒重算,队列清空关表防泄漏)/~~R7 失败汇总~~✅(2026-10-05 本批:ToolCardList groupFailCounts computed 复用 isToolFailure 单点判定,组头红徽标「{n} 失败」+i18n 两键——轨迹卡聚合行/执行中步骤行既有失败计数外的组头盲区补齐)/R5+V9 消息内容检索(后端 IPC+前端搜索底座,唯一真缺口,独立批),排序详报告第五章
- [ ] 第三批 P3 攒批:I8-I13+R8-R13+V10-V12+9-28 #9-#17;其中 9-28#17 图片默认上传外部域名涉开源边界,单独决策 - [x] 第三批 P3 攒批:I8 ✅(2026-10-05 本批:stopChat 队列非空一次性 toast「已停止,N 条排队消息仍将自动发送」ai.queueWillContinue zh/en——停止≠清队预期对齐,全停走逐条取消/清队列)/I9 附件仅图片→~~I9~~✅(2026-10-05 本批:「📄 文件」按钮(ChatInput 📎 旁)——多选文本/代码/配置文件(57 扩展名白名单)读内容以 ```lang <!-- 文件名 --> 代码块插入草稿,复用图片链 blobFromPath(convertFileSrc)零新依赖;单文件 256KB/5000 行双上限截断+提示;走既有文本发送链零协议改动)/I11 ✅(2026-10-05 本批:ChatInput INPUT_MAX_HEIGHT=200 常量四处 120px 硬编码全收敛,与 CSS max-height 同源——手工拉伸不再被按键压回)/~~I12 快捷键零提示~~✅(2026-10-05 本批:TopBar ... 菜单加快捷键入口 + AiChat Teleport 速查面板(Ctrl+N/I/R/L/K/B+Esc 七键,label 随 locale,mask 点击外部/Esc/✕ 三路关)——速查键位表与 onGlobalKeydown 同文件同居防漂移)/I13 ✅(2026-10-05 本批:Ctrl+I 聚焦输入框,AiChat 全局键区接入 chatInputRef.focus,输入焦点守卫让路)+~~R8 流式裸 diff~~✅(2026-10-05 本批:useMarkdown wrapNakedDiff 改 export,useStreamRenderer parseBlockNoCache 接入同源预处理——流式中裸 +/- 行即红绿 diff 块,守卫3围栏内不动保证与完成态幂等不双包)+~~R10 阅读锚点~~✅(2026-10-05 本批:三处 collapseAllToolLists 调用点(滚轮回调/消息快照 watch/agentRound watch)全加 isFollowingBottom 门——上滚阅读中新轮次不再强制收起正浏览的工具卡)+~~R11 新内容信号~~✅(2026-10-05 本批:回底按钮生成中非跟随态加 amber 脉冲点 ai-btb-new-dot 2s 循环)+~~R13 assistant 时间戳常显~~✅(2026-10-05 核验:UX 密度改造已消解——ai-msg-time 全仓零命中,≥30min 间隔 timesep 分隔线替代 per-msg 时间戳)+~~I10 重试文案名实不符~~✅(2026-10-05 本批:streamInterruptedAfterTool zh/en 改「可点重试续跑(整轮重跑,已完成工具会重新执行)」明示副作用)+~~R12 diff 回看~~✅(2026-10-05 本批:a patch_file 长 diff 展开入口(DIFF_PREVIEW_LINES=120 默认预览,「展开全部 {n} 行/收起 diff」toggle,parseDiffLines expanded 传 undefined 全量)/b write_file 覆盖写入 diff 回看(后端 file.rs rename 前读旧内容(仅 utf-8 ≤1MB)generate_diff 产 diff 字段,新建/base64 为 null 前端不渲染;前端同构红绿行渲染+展开按钮)——工具卡可回看改了什么不再只看字节增减)+~~V11 分离窗三连~~✅(2026-10-05 本批:a 置顶默认 false(useAiWindow detachPanel alwaysOnTop:false,原 true 盖住 VSCode/浏览器;开过置顶走 AiChat df-ai-always-on-top KV 恢复链不受影响)/b 收回主面板闭环(TopBar reattach 按钮+emit 链补 AiChat @reattach→emitReattachToMain Tauri 全局广播→主窗 registerMainReattachListener(label=main 判定)→store.reattachPanel——原 reattachPanel 全仓零 UI 调用只能手动重开面板)/c 活跃会话 KV 按窗口域分流(activeConvKvKey() 主窗 'df-ai-active-conv' 分离窗 '-detached' 后缀,读端 loadConversations+写端切会话/新建/自动路由/删除回落 5 处统一;治两窗独立 JS context 共写同一 KV 互覆——分离窗切会话重启后主窗被带跑;detachPanel 种子分离窗 KV,reattachPanel 关全窗后清 KV;useAiConversations 顶层 import getCurrentWebviewWindow 同步解析无启动竞态))+~~V12 滚动位置丢失~~✅(2026-10-05 顺手批:MessageList 切会话 watch 记旧会话 scrollTop(Map 限 20 个 LRU),换入有存档恢复存档位+setFollowing(false) 尊重阅读位,无存档保持回底——回读不再被打断)+9-28 #9-#17 详见下条;其中 9-28#17 图片默认上传外部域名涉开源边界,单独决策。V10 本批实施纠错:先误核销后复核发现 Ctrl+N 仍有 `!store.state.streaming` 守卫(生成中静默吞键)与按钮侧 F-09 真并发语义矛盾依旧,已撤守卫对齐按钮(新建不杀旧 loop,后台继续),vue-tsc EXIT 0
- [x] ✅ 同日已修:工具卡默认折叠刷屏+read_file/list_directory 单行摘要补齐+分组折叠幂等补齐(工作区待提交) - [x] ✅ 同日已修:工具卡默认折叠刷屏+read_file/list_directory 单行摘要补齐+分组折叠幂等补齐(工作区待提交)
### 💡 2026-09-29 aichat 知识库与记忆修复(「笨/丢历史」根因诊断,同日已修会话内侧) ### 💡 2026-09-29 aichat 知识库与记忆修复(「笨/丢历史」根因诊断,同日已修会话内侧)
@@ -30,17 +42,18 @@
- [x] P0 candidate→published 断链打通 ✅已修(机制先行 7c62d8d:复用自动晋升+knowledge_promote 工具+晋升补嵌入;存量晋升迁移 V43 2026-10-02 落地:candidate+confidence=high→published,库副本实证 854 条晋升 967/53→113/907,重跑幂等 0 行,df-storage 180 测试全绿;增量 confidence 分级与 auto_publish 默认值等 C3 拍板) - [x] P0 candidate→published 断链打通 ✅已修(机制先行 7c62d8d:复用自动晋升+knowledge_promote 工具+晋升补嵌入;存量晋升迁移 V43 2026-10-02 落地:candidate+confidence=high→published,库副本实证 854 条晋升 967/53→113/907,重跑幂等 0 行,df-storage 180 测试全绿;增量 confidence 分级与 auto_publish 默认值等 C3 拍板)
- [x] P0 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验) - [x] P0 视图裁剪补滚动摘要 ✅已修(9b89610 淘汰补偿摘要插视图首部,user 角色保缓存指纹,纯视图不落库,eviction 11 测试全绿,2026-10-02 核验)
- [x] P1 LLM 知识工具注册 ✅已修(7c62d8d 注册 knowledge_search/insert/promote 三工具,tool_registry.rs:798,检索与注入同源分词) - [x] P1 LLM 知识工具注册 ✅已修(7c62d8d 注册 knowledge_search/insert/promote 三工具,tool_registry.rs:798,检索与注入同源分词)
- [ ] P1 跨会话召回最小闭环 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/跨会话召回最小闭环-设计-2026-10-04.md)):勘察修正——提炼链已存在(knowledge_inject.rs 水位+哨兵+手动触发),缺口仅 source_project 赋值(boundary_json→mention 两级弱推断,零 schema 变更)+注入软排序(search_keywords_scoped 新方法);3 拍板点待过,约 1 天 - [x] P1 跨会话召回最小闭环 ✅实施完成(2026-10-05,设计 2026-10-04 [设计文档](./02-架构设计/专项设计/跨会话召回最小闭环-设计-2026-10-04.md) 3 拍板 B1/B2/B3 全按建议):①提炼侧 resolve_source_project 两级弱推断——级 a boundary_json.dir_allowlist 首条反查 projects.path 前缀匹配(轻量词法归一 normalize_dir_for_match:斜杠统一+小写+去尾分隔),级 b 唯一 `[项目:名称]` mention 回退(B1 拍板:多项目不猜置 NULL),皆无→NULL=全局知识零回归;extract_knowledge_from_conversation 插入处赋 source_project。②注入侧 search_keywords_scoped 新方法(B3 拍板,旧签名保留 MCP 外部调用零影响,project_id=None 转调旧路径回归门)——三档排序:同项目 0/全局 NULL 1/异项目 2,档位作第一排序键 ASC(title 命分为同档第二键,防档位被 title 分稀释);build_knowledge_context 注入前同走 resolve_source_project。③单测 6 个:df-storage scoped×3(同项目压异项目高复用/三档顺序/None 回归门)+devflow resolve×3(目录归一/级 a boundary 命中/级 b 唯一 mention 命中+多项目不猜)。验收 cargo test df-storage 199 passed 0 failed(顺带修正 migration 测试断言常量 48→49:V49 loop_state 迁移注册后断言未跟进,现全绿)+devflow --lib 706 passed 0 failed+knowledge_inject 26 passed
- [x] P1 存量测试修复 4 个 ✅已修(df3ed55 断言同步+e7cd035 笔误修正,2026-10-02 全量 cargo test --workspace 1827 passed/0 failed 复核) - [x] P1 存量测试修复 4 个 ✅已修(df3ed55 断言同步+e7cd035 笔误修正,2026-10-02 全量 cargo test --workspace 1827 passed/0 failed 复核)
- [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议 - [ ] P2 DB 落库截断~~分 role 定阈值~~✅(2026-10-05 核验:conversation.rs:372 tool result 跳过 content 截断仅截 assistant/user——体量已由 read_file limit 控源)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议
### 💡 2026-09-28 devflow 集成 u-claw 能力(可行性已定,待实施) ### 💡 2026-09-28 devflow 集成 u-claw 能力(✅ 代码面已落地 2026-10-05,剩真机冒烟)
> 链路:devflow → u-relay → u-claw。通路已生产验证(u-ppt 试点 2026-08-29),u-relay 零改动,唯一工作面 devflow 侧接入。 > 链路:devflow → u-relay → u-claw。通路已生产验证(u-ppt 试点 2026-08-29),u-relay 零改动,唯一工作面 devflow 侧接入。
> **状态更新 2026-10-05**:ask_uclaw 工具(470 行)+UclawPanel 配置页+i18n+流式链补全已落地(任务 5fb916ac,cargo test 693 passed);「per-conv channel_id 隔离」经源码终核不成立(单 channel 单链,MVP 接受共享上下文语义);剩真机全链冒烟(需 u-relay+u-claw 在线配合)。原实施清单保留作档案。
> 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md) > 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md)
- [x] devflow/u-claw 两侧细勘察补全 ✅2026-10-04 完成([勘察补全](./02-架构设计/专项设计/uclaw两侧勘察补全-2026-10-04.md)):多轮锚定=channel 级单 chain 粘性会话(request_id 仅单指令标识,常驻 claude 各轮同 session_id,崩溃 --resume 恢复);**协议已漂移**——端点改 /ws/commander(commander/agent 角色,e8113c5),hello {kind,channel_id,token},不能照抄 9-28 文档/u-ppt relay.ts;新增核实:串行 worker+队列满丢弃、单条 15min 硬超时、进度 2s 节流、空闲链回收 7s 冷启动;接入实施批清单六步已列(含 per-conv channel_id 隔离策略) - [x] devflow/u-claw 两侧细勘察补全 ✅2026-10-04 完成([勘察补全](./02-架构设计/专项设计/uclaw两侧勘察补全-2026-10-04.md)):多轮锚定=channel 级单 chain 粘性会话(request_id 仅单指令标识,常驻 claude 各轮同 session_id,崩溃 --resume 恢复);**协议已漂移**——端点改 /ws/commander(commander/agent 角色,e8113c5),hello {kind,channel_id,token},不能照抄 9-28 文档/u-ppt relay.ts;新增核实:串行 worker+队列满丢弃、单条 15min 硬超时、进度 2s 节流、空闲链回收 7s 冷启动;接入实施批清单六步已列(含 per-conv channel_id 隔离策略)
- [ ] 接入实施:WS 客户端(参考 u-ppt relay.ts 65bae53——✅2026-10-04 已核,commander 协议现行对齐(端点/hello/握手/心跳/重连/业务帧六项一致),去 deck/SEP 专有逻辑即可移植)+ ask_uclaw 工具 + 配置页(默认关,对齐 cfg.WS==nil 模式);客户端超时 16min+2 次重试+发送前探活;per-conv channel_id 隔离(devflow-{conv_id}) - [ ] 接入实施 ✅主体已落地(2026-10-05 任务 5fb916ac):ask_uclaw 工具+配置页三键+前端流式链全接通;隔离策略勘误=单链共享上下文(MVP),多实例 phase2;剩真机全链冒烟
### 💡 2026-09-28 aichat 交互走查清尾(启动「日常主入口」标准,1P1+7P2+9P3) ### 💡 2026-09-28 aichat 交互走查清尾(启动「日常主入口」标准,1P1+7P2+9P3)
@@ -48,29 +61,11 @@
> 背景:devflow 启动定位=日常 AI 交互调度中枢,按日常主入口标准走查。新发现集中在 God 组件拆分丢功能的链路断点+多会话并发收口残留缝隙。 > 背景:devflow 启动定位=日常 AI 交互调度中枢,按日常主入口标准走查。新发现集中在 God 组件拆分丢功能的链路断点+多会话并发收口残留缝隙。
> 详情:[05-代码审查/aichat交互走查-2026-09-28.md](./05-代码审查/aichat交互走查-2026-09-28.md) > 详情:[05-代码审查/aichat交互走查-2026-09-28.md](./05-代码审查/aichat交互走查-2026-09-28.md)
- [x] 第一批(日常直接受伤):~~#1 编辑末条消息入口丢失回归~~✅(2026-10-05 核验:MessageItem:126 编辑按钮+start-edit 链完整,I2 批已修)+~~#4/#5 排队消息被静默清~~✅(二批 8e29271f 修)+~~#6 后台会话错误气泡推错视图~~✅(forceConverge 气泡双过滤 `!convId || convId === activeConversationId` 才 push)+~~#7 429 无分类且重试照显~~✅(2026-10-05 核验:aiShared.ts:603 classifySendError 顺序敏感 429/限流先判+ChatInput SEND_ERR_KEY 分类文案)
- [ ] 第一批(日常直接受伤):#1 编辑末条消息入口丢失回归(2c2b2d7 提取 MessageItem 时按钮未迁移)+#4/#5 排队消息被静默清(AiError/审批失败/看门狗超时三路径,含两处全局清队列残留)+#6 后台会话错误气泡推错视图+#7 429 无分类且重试照显 - [x] 第二批(多会话体验):~~#8 审批徽标跨会话误导+点击 no-op~~✅(2026-10-05 核验:state.pendingApprovals 已退化为当前活跃会话视图投影(aiShared.ts:364 per-conv Map 底座)+徽标点击→AiChat:243 scrollToFirstPending 委托链完整)+~~#3 en 重试气泡残留~~✅(2026-10-05 核验:useAiLifecycleEvents 终态收尾按 m.isRetry 标记匹配清除,替代旧中文文案子串匹配,en 生效)+~~#2 rejectedHint i18n key 多前缀~~✅(2026-10-05 核验:ToolCard.vue/useAiApproval/useAiToolEvents 消费点全已统一 `aiTool.rejectedHint` 路径,zh/en 两 locale 键位齐)
- [ ] 第二批(多会话体验):#8 审批徽标跨会话误导+点击 no-op+#3 en 重试气泡残留+#2 rejectedHint i18n key 多前缀 - [x] 第三批随批清理:~~#10 user 时间戳+死样式~~✅(2026-10-05 核验:UX 密度改造消解——ai-msg-time 全仓零命中,≥30min timesep 分隔线替代)/~~#11 compressed 死代码~~✅(2026-10-05 核验:结论过时——compressed 段是 F-15 活代码(压缩摘要置顶显示不折叠),kind/模板/CSS 均在用)+#9 消息删除⏸️(2026-10-05 勘察:需后端 ai_delete_message IPC+前端全链(现仅 clearChat 整删/pop_last_assistant_round 弹末轮),纯前端只能视觉删除重启复活误导用户——与 #13 合并为「消息操作 IPC 协议级独立批」)+~~#12 token 面板~~✅(2026-10-05 顺手批:convTokenTotal 会话累计接入既有 token 详情 popover 尾部段(仅末条 AI 回复显,分 total/in/out 三行+分隔线样式)——只写不读的闲置数据源接上消费,非新面板零结构增量)+~~#13 中间错误重试错位~~⏸️(2026-10-05 勘察:需后端 IPC 加 from_id 参数+截断链(现仅 pop_last_assistant_round),协议级改动独立批)+~~#14 批量审批部分失败提示~~✅(2026-10-05 顺手批:batchApprove 部分失败从静默 console.warn 升级 ai-toast「批量完成:{ok} 项成功,{fail} 项失败」aiTool.batchPartialFail zh/en)+~~#15 ToolCallStarted 竞态~~✅(2026-10-05 顺手批:末条非 assistant 时兜底向前找最近 assistant 落卡,不再丢 running 卡;全程无 assistant 才保持原丢弃)+~~#16 注释与实现矛盾~~✅(2026-10-05 本批:注释纠偏——withConvOp failKey 错误气泡兜底实证(useAiConversations.ts:96),catch 内 console.error 留痕不重复提示)+~~#17 图片上传外部域名~~⏸️(2026-10-05 用户指示延后再论:涉开源边界,业务域名硬编码默认可 df-file-upload-base 覆盖,决策项不动代码)+B-260619-04③销账(实际已落地)。另 R5+V9 消息检索用户指示延后
- [ ] 第三批随批清理:P3 九项(#9-#17,详见报告)+B-260619-04③销账(实际已落地)
- [x] 第四批(用户实测反馈 2026-09-29):批量读取文件时工具卡逐卡渲染刷屏 ✅已修——分组默认收起机制 6 月已有但为一次性初始化,流式场景首卡到达即触发完毕,组从 1 卡长到 N 卡不再折叠致 N 张单行卡全展开;改响应式补齐(组达 2 卡阈值即收起)+用户手动操作/审批展开记 touched 不被自动收回 - [x] 第四批(用户实测反馈 2026-09-29):批量读取文件时工具卡逐卡渲染刷屏 ✅已修——分组默认收起机制 6 月已有但为一次性初始化,流式场景首卡到达即触发完毕,组从 1 卡长到 N 卡不再折叠致 N 张单行卡全展开;改响应式补齐(组达 2 卡阈值即收起)+用户手动操作/审批展开记 touched 不被自动收回
### 💡 2026-08-04 父子任务支持(✅ 已完成)
> 目标:完成父任务/子任务的完整支持(数据→后端→前端),UI/UX 重点设计。
> 详情:[04-功能迭代/父子任务支持设计-2026-08-04.md](./04-功能迭代/父子任务支持设计-2026-08-04.md)
- [x] **后端** ✅ 父聚合下沉 df-nodes 共享层(`recompute_parent_status`/`advance_task_with_parent`),IPC/AI 工具/MCP 三方统一;df-mcp create_task 支持 parent_id(1 级嵌套校验);delete_task 级联软删子任务(返回 `{ok,cascaded}`)
- [x] **前端** ✅ Tasks.vue 树形列表(折叠箭头+📑图标+子进度徽章 n/m+迷你进度条+缩进竖线圆点);新建弹窗「父任务」下拉(项目锁定);TaskDetail 父面包屑+子任务面板(进度条+快捷推进+添加子任务);i18n zh/en 双份
- [x] **核查** ✅ cargo check(df-nodes/df-mcp/src-tauri 三 crate) + vue-tsc 全部通过
### 💡 2026-06-27 对话上下文透明化(✅ 全部完成)
> 用户看不到 AI 接收到的上下文信息(目标钉扎/enrichment/system_prompt),导致行为不可理解。
- [x] **L1 目标可见** ✅ 对话顶部显示当前 pinned_goals 列表(TopBar.vue 🎯 图标,可查看/清理)
- [x] **L2 Enrichment 可见** ✅ @[项目] 发送前展开 enrichment 摘要(ChatInput.vue enrichment 预览面板,展开/收起/取消关联)
- [x] **L3 完整上下文** ✅ 可展开面板查看 system_prompt / augmentations(TopBar.vue 底部 context 面板)
### 💡 2026-06-27 统一 Inbox 设计(讨论产出) ### 💡 2026-06-27 统一 Inbox 设计(讨论产出)
> 思路:所有需要用户注意的事项(审批/知识待审/灵感确认/其他通知)集中到一个统一入口, > 思路:所有需要用户注意的事项(审批/知识待审/灵感确认/其他通知)集中到一个统一入口,
@@ -272,40 +267,6 @@ graph TD
- ✅ **UX-260617-28 [INFO]** — 已决策:现状够用,不出问题不升级 - ✅ **UX-260617-28 [INFO]** — 已决策:现状够用,不出问题不升级
### 🔧 2026-06-19 文件拆分升级(核验结论:全部完成)
> **2026-06-28 核验**:三项 God 文件拆分已全部完成:
> - **tool_registry.rs** ✅ 1091行单函数已拆为按域注册函数
> - **AiChat.vue** ✅ 4026→**767行**(已抽子组件)
> - **crud.rs** ✅ 2212→拆为 `crud/` 下11个文件
**P1 ToolCard 拆分(已核验完成)**
- [x] **REFACTOR-260619-04 [P1]** ✅ **ToolCard.vue 拆分完成**:
- ToolCard.vue 1527→**373行**
- `ToolResultBody.vue` + `useToolCardHeader.ts`(160行) + `useToolCardRender.ts`(45行)
- `useToolApproval.ts`(审批状态机独立,220行,B-260616-08 loading/挂起时长/toast 完整)
- `useToolCard.ts`(666行,通用工具函数) — 原 ToolCard 组件拆分的目标全部到位
**P2 暂缓(窗口未到/收益低)**
- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **✅事实完成销账(2026-10-04 勘误)**:原 context.rs(1332)已拆为 context/{mod,sanitize,manager_tests}.rs 目录模块,条目过时。**新超标对象=context_helpers.rs(1733 行)**,拆分备勘已出(2026-10-04 [设计文档](./02-架构设计/专项设计/context-helpers拆分备勘-设计-2026-10-04.md),四批迁移+压缩热点殿后+触发条件=上下文管理批稳定后等用户指令) — `crates/df-ai/src/context_helpers.rs`
---
### 🔧 2026-06-19 ToolCard 同批/跨轮 UI 合并(子代理方案·其他会话实施·P1)
> 用户需求:AI 多轮 read_file(轮1×2 + 轮2×2)前端一排展开卡片("上面两个下面两个"),希望 UI 合并。后端 convert 已合并连续 tool_result(B-260619-03),前端展示层对齐。子代理分析:同批同类合并**已存在**(ToolCardList groupedToolCalls L92-110,calls>=2 显标题行+默认收起),**痛点是跨轮**(轮1/轮2 各独立 ToolCardList 实例→各成分组)。用户强调"谨慎不出错"。
- [x] **B-260619-04 [P1]** ✅ **ToolCard UI 合并方案 A 全部落地**:
- **现状(已查清)**:`ToolCardList.vue` groupedToolCalls 按 tc.name 全局聚合,calls>=2 显「[图标] 读取文件 [N] ▸」标题行 + 默认收起(initDefaultCollapse L139-147)。**同批 read_file×2 应已是折叠组**;用户「下面又两个」=下一轮新 message 的独立 ToolCardList 实例→第二个分组(跨轮未合并)。
- ✅ **步骤 ① 标题信息增强(已落地)**:groupFileSummary(group) 从 group.calls[].args.path 取前 2-3 个 shortPath 截断显示(“读取 2 个文件 · shortPath1 · shortPath2”),模板已渲染。
- ⏸️ **步骤 ③ 新轮到达自动收起旧轮**:当前跨轮收起未实现,保留作为低优先增量需求(需在 AiChat.vue watch messages 调 collapseCompletedGroups,风险中)。
- **不推荐方案 B**(跨轮全局合并,破坏 message 边界,审批/折叠重构高风险)/ **C**(纯视觉框,不解决占用)。
— `src/components/ToolCardList.vue`
---
### 💡 灵感模块-来源采集/追溯(2026-06-21·待办·新功能范畴) ### 💡 灵感模块-来源采集/追溯(2026-06-21·待办·新功能范畴)
> 继批1-3c 升级后的下一步:灵感来源录入增强 + 自动采集机制。**属新功能范畴**,需独立设计采集规则/触发/去重,下一阶段推进。 > 继批1-3c 升级后的下一步:灵感来源录入增强 + 自动采集机制。**属新功能范畴**,需独立设计采集规则/触发/去重,下一阶段推进。
@@ -360,26 +321,6 @@ graph TD
--- ---
### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务)
> ✅ 全部修复完成(2026-06-27 核验)
- [x] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。✅ 关闭(2026-08-15:团队决策定案,无后续动作)
---
### 🔍 2026-07-17 aichat 对话功能深度走查待办
> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md)
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
- [x] **AIC-FIX-17-P1-1~11** ✅ 已销账(2026-08-15 核验,1 agent 全量核验 + 主代 4 项抽查源码属实):P1-1/3/4/5(queue/modelOverride/spans/_lastDelta per-conv,F-09 收尾)/ P1-2 drainQueue 互斥 `_drainingConvs` Set(useAiSend:274)/ P1-6 flushCurrentText 守卫 / P1-7 MessageItem 收敛仅 user 角色 / P1-8 _blockCache LRU 300+切会话 resetStreamCache / P1-9 指纹 watch 替代 deep / P1-10 approving 防重入前置(useToolApproval:133)/ P1-11 deleteConversation 相邻回落(useAiConversations:520)。余「MessageList 内联渲染组件化」「缓存主动失效」为优化空间非缺陷
- [x] **AIC-FIX-17-P2** ✅ 已销账(2026-08-15):7 条中 5 条核验为此前批次已修(ApprovalOverlay 响应式冷却/aiShared 对称收敛/flash watch/maximized 三元/emit 类型),2 条本批落地(tailSeq 注释与实现对齐 useStreamRenderer:142-196、isCompressing 单例补 known limitation 注释 useAiContext:28)
- [x] **AIC-FIX-17-根因** ✅ **2026-08-07 核验销账**(queue/modelOverride/审批/_lastDelta 均已 per-conv) — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
---
### 🔍 2026-08-01 走查剩余待办(P2/P3/专项·本会话登记) ### 🔍 2026-08-01 走查剩余待办(P2/P3/专项·本会话登记)
> 本次走查剩余项登记,未实施代码(守 session-role-diagnose-only)。专项已落 memory,此处留指针。 > 本次走查剩余项登记,未实施代码(守 session-role-diagnose-only)。专项已落 memory,此处留指针。
@@ -518,108 +459,6 @@ graph TD
**其他已销项**:CSW-P2-7(需实测确认,从待办去除) **其他已销项**:CSW-P2-7(需实测确认,从待办去除)
--- ---
### 🔍 2026-08-09 TaskDetail 操作按钮缺陷走查(用户实测 + AI 深度审查)
> 用户反馈"产出部分只有标题空白",触发对 TaskDetail.vue 所有操作按钮的全量走查。覆盖模板/脚本/后端 advance_task / run_workflow 回调链路。共发现 10 项缺陷,按严重度 P0×2 / P1×4 / P2×4 分组。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
>
> **✅ 2026-08-09 全部修复并核验销账**:主代理独立核查(vue-tsc EXIT 0 + diff 语义审)通过。补充说明:P1-4 核验当前代码已满足(advanceChild/setChildPriority 已含 `childMenuId.value = null`),仅销账未改;P1-5 实现位置优化为 watch(taskId)/refresh/handleAdvance 成功三入口调用 `resetWorkflowUi()`(load() 内不加,避免工作流推进中 df-data-changed 触发刷新误清进度面板);P2-10 描述编辑对齐 IdeaDetail 模式(有描述/空描述均可编辑,taskApi.update 落库)。验收标准 1-9 全部满足。
**涉及文件**:
- `src/views/TaskDetail.vue`(987 行,主要改动点)
- `src-tauri/src/commands/task.rs`(advance_task,仅参考)
- `src-tauri/src/commands/workflow.rs`(run_workflow 回调,仅参考)
- `crates/df-nodes/src/task_advance_node.rs`(advance_task_atomic,仅参考)
---
## 🔴 P0 缺陷(2 项,必须修)
### P0-1 双重推进竞态:工作流推进中手动推进按钮仍可用
- **位置**:TaskDetail.vue:48 vs :55
- **现状**:工作流按钮 `:disabled="wfAdvancing || advancing"`(检查两者),手动按钮 `:disabled="advancing"`(只检查 advancing)
- **问题**:`wfAdvancing=true` 时手动按钮仍可点 → 同时发起 `run_workflow`(回调 advance)+ 直调 `advance_task` 两个推进请求 → 第一个成功改 status,第二个 CAS 失败返回 Err → 前端显示红色错误,用户困惑"明明成功了为什么报错"
- **修复**:手动推进按钮 `:disabled` 加 `|| wfAdvancing`(1 行)
### P0-2 工作流推进与手动推进按钮语义重复(todo 状态双"开始")
- **位置**:TaskDetail.vue:45-57
- **现状**:todo 状态同时显示「工作流推进」+「开始」两个按钮,都推进到 in_progress
- **问题**:用户不知道该点哪个;设计意图是工作流推进会跑 AiNode 生成产出,手动"开始"只是纯改状态,但按钮文案没有区分
- **修复**:工作流推进按钮文案改为「AI 执行推进」或「工作流推进(含 AI 产出)」
---
## 🟠 P1 缺陷(4 项)
### P1-3 子任务快捷菜单不校验状态机合法性
- **位置**:TaskDetail.vue:286-296
- **现状**:`quickStatuses` 硬编码列出全部 6 态 [todo, in_progress, in_review, testing, done, blocked],不按子任务当前 status 过滤
- **问题**:todo 状态子任务的菜单显示 testing/done/in_review 等非法目标,点下去后端 can_transition 拒绝 → 红色错误
- **对比**:顶部手动推进按钮用 `ADVANCE_MAP` 严格按状态机过滤,子任务菜单却无此逻辑
- **修复**:子任务菜单复用 `ADVANCE_MAP[child.status]` 过滤合法目标
### P1-4 子任务操作后快捷菜单不关闭
- **位置**:TaskDetail.vue:326 advanceChild / :337 setChildPriority
- **现状**:操作完成后不 `childMenuId.value = null`
- **问题**:点了"推进到 done"或"改优先级"后菜单仍挂着悬浮
- **修复**:两函数操作完成后 `childMenuId.value = null`(2 处)
### P1-5 refresh/切任务后工作流进度/DAG 残留
- **位置**:TaskDetail.vue:619 load() 函数
- **现状**:load() 只重置 task/projects/ideas,不清 `wfExecId` / `wfDagJson` / `wfResult`
- **问题**:切任务或点 refresh 后右栏仍显示旧任务的工作流进度和 DAG
- **修复**:load() 开头加 `wfExecId.value = null; wfDagJson.value = ''; wfResult.value = null`(3 行)
### P1-6 手动推进后工作流进度面板不清空
- **位置**:TaskDetail.vue:470 handleAdvance
- **现状**:成功后只 `task.value = updated`,不清工作流相关 ref
- **问题**:手动推进后右栏仍显示旧工作流进度
- **修复**:handleAdvance 成功后清空 wfExecId/wfDagJson/wfResult
---
## 🟡 P2 缺陷(4 项)
### P2-7 子任务创建无长度/重名校验
- **位置**:TaskDetail.vue:365-381 confirmSubtask
- **现状**:标题只 `trim()` 判空,不校验长度上限/重名;成功路径不清 `errorMsg.value`
- **修复**:加长度上限(如 128 字)、同父重名校验、成功路径清 errorMsg
### P2-8 wfResult timer 生命周期不严谨
- **位置**:TaskDetail.vue:424 watch 回调
- **现状**:工作流完成后 setTimeout 3s 清 wfResult;切任务时 timer 仍在但 wfExecId 已清
- **影响**:低风险(最多多等 3s),但 timer 引用管理不严谨
- **修复**:load() 重置时清 `_wfResultTimer`
### P2-9 子任务页无"是子任务"提示
- **位置**:TaskDetail.vue:108(父面包屑)+ :133(子任务面板仅顶层显示)
- **现状**:子任务详情页结构与顶层任务几乎一样,只有顶部多一行父面包屑
- **修复**:加一行提示「该任务为子任务,隶属于:{parentTitle}」
### P2-10 空描述无编辑入口
- **位置**:TaskDetail.vue:68 `<section v-if="task.description">`
- **现状**:无描述时描述面板整块消失,无"添加描述"入口
- **修复**:无描述时显示空态 + "添加描述"入口
---
## 验收标准
1. 工作流推进中手动推进按钮禁用,无双重推进竞态
2. todo 状态两个按钮文案可区分(工作流推进 vs 纯手动开始)
3. 子任务快捷菜单只显示状态机合法目标
4. 子任务操作后菜单自动关闭
5. 切任务/refresh/手动推进后右栏工作流进度正确重置
6. 子任务创建有长度/重名校验,错误提示清晰
7. 子任务页有"是子任务"明确提示
8. 空描述有编辑入口
9. vue-tsc EXIT 0
---
**关联**:父任务「总览页 + 项目列表 UI/UX 优化(持续更新)」(id: 0115aea6-b423-482a-8a64-b793a66a0417)—— 本走查作为该任务的子任务延伸。
---
### 🔍 2026-08-09 项目详情页走查(概览/文件/依赖图 · 3 agent 并行 + 主代核查) ### 🔍 2026-08-09 项目详情页走查(概览/文件/依赖图 · 3 agent 并行 + 主代核查)
> 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**(P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**(P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
@@ -731,9 +570,9 @@ graph TD
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5) **P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。 > **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(task_id 断裂 + AiNode 缺 prompt + 审批无 UI) ### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(✅ P0×3 已修,2026-10-05 核验销账)
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**核心结论:工作流推进按钮从 TaskDetail 触发时,三条推进链(in_progress/testing/done)全部不可用**。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。 > 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**原结论「三条推进链全部不可用」已过时——P0×3 全部已修**(task_id 注入 workflow.rs:340 + AiNode 自审落库含 agent 模式 + TaskDetail 审批 UI 随 2eea011 审批重设计批落地),核验详录迁 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md)。以下原始诊断保留作档案,修复状态见归档勘误。
**涉及文件**: **涉及文件**:
- `src-tauri/src/commands/workflow.rs`(run_workflow_inner 核心) - `src-tauri/src/commands/workflow.rs`(run_workflow_inner 核心)
@@ -833,7 +672,7 @@ graph TD
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。 > 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
### P0 run_agentic_loop 包 catch_unwind ### P0 run_agentic_loop 包 catch_unwind ✅已修(2026-10-05 核验:loop 主体 unwind_to_result 包裹,panic 转 teardown_round 补终态,5 单测锁定 mod.rs:3135-3165)
- **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind) - **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind)
- **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。 - **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。
- **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。 - **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。
@@ -871,11 +710,11 @@ graph TD
- [x] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还) ✅已修(2026-10-04 核验:regenerate(:183)/editMessage(:259)/stopChat(:512+)三处均已传 per-conv convId 重置/清理看门狗,stopChat 收尾 clearResumeAttempts(activeConversationId),注释明确记载「不再落 legacy 单 timer fallback」) - [x] P2 看门狗 legacy/per-conv 双轨三错配:useAiSend.ts:169/:241/:512 统一迁移传 convId + `_resumeAttempts` 收尾清(债务本就要还) ✅已修(2026-10-04 核验:regenerate(:183)/editMessage(:259)/stopChat(:512+)三处均已传 per-conv convId 重置/清理看门狗,stopChat 收尾 clearResumeAttempts(activeConversationId),注释明确记载「不再落 legacy 单 timer fallback」)
- [x] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta ✅已修(2026-10-04 核验:后台终止分支(:217)与停靠卡归集分支(:131)均补 clearLastDelta,前台收尾 cleanupTerminatedConversation 亦有显式清理,注释记载防残留记录误丢下轮同内容 delta) - [x] P2 _lastDeltas 双路径漏清:useAiEvents.ts:96-115/:154-161 补 clearLastDelta ✅已修(2026-10-04 核验:后台终止分支(:217)与停靠卡归集分支(:131)均补 clearLastDelta,前台收尾 cleanupTerminatedConversation 亦有显式清理,注释记载防残留记录误丢下轮同内容 delta)
- [x] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式 ✅已修(2026-10-04 核验:removeGoal 已回写 `conv.pinned_goals = next`(注释 UX 走查-对话域 B4),防 loadGoals 重读旧列表复活已删目标) - [x] P2 removeGoal 不回写 store/不回滚:TopBar.vue:530-539 对齐 withConvOp 模式 ✅已修(2026-10-04 核验:removeGoal 已回写 `conv.pinned_goals = next`(注释 UX 走查-对话域 B4),防 loadGoals 重读旧列表复活已删目标)
- [ ] P2 forceConverge 收敛/错误气泡缺 activeId 守卫(useAiStream.ts:115-121,与本批 onStreamTimeout 修复同家族) - [x] P2 forceConverge 收敛/错误气泡缺 activeId 守卫 ✅已修核验销账(2026-10-05):per-conv 触发守卫已在 forceConverge 入口(会话已删除或已收敛即清态早退 useAiStream.ts:96-103)+补气泡双过滤(仅 `!convId || convId === activeConversationId` 才 push,:138-139,后台会话静默收敛气泡不错位)
### 机械批(可独立小批) ### 机械批(可独立小批)
- [ ] remote_bridge 6 处 + agentic/mod.rs 4 处裸 emit 换 emit_ai_chat_event(miniapp 收不到错误/心跳/熔断卡) - [x] remote_bridge 6 处 + agentic/mod.rs 4 处裸 emit 换 emit_ai_chat_event ✅已收核验销账(2026-10-05):grep 全库 `\.emit\("ai-chat-event"` 仅剩正典 event_bus.rs:87 本体+注释+有意分路的 completed_once(mod.rs:2588 注释保护),remote_bridge 已全走 publish_event/emit_ai_chat_event 双写正典——DRY-B #1 批次顺带收口,文档滞后案
- [ ] i18n searchFilesBlocked 死键裸 @(zh-CN/en aiChat.ts:198/197)转义或删 - [x] i18n searchFilesBlocked 死键裸 @ ✅已修(2026-10-04 狗粮实证批⑤:zh-CN/en aiChat.ts 裸 @ 改 &#64; 转义,文档滞后案,2026-10-05 复核确认)
### 登记观察(定级前先核) ### 登记观察(定级前先核)
- [x] model_fetch 空列表 Ok([]) 上层写回清空模型池 ✅已修(2026-10-03 N1):provider.rs 空守卫跳过写回+前端 toastFetchEmpty 同语义守卫 - [x] model_fetch 空列表 Ok([]) 上层写回清空模型池 ✅已修(2026-10-03 N1):provider.rs 空守卫跳过写回+前端 toastFetchEmpty 同语义守卫
@@ -886,35 +725,36 @@ graph TD
> 前端 13 项(P1×2/P2×4/P3×7) + 后端 P1×1/P2×3/P3×8;后端报告含 6 点「确认无恙」防重查。 > 前端 13 项(P1×2/P2×4/P3×7) + 后端 P1×1/P2×3/P3×8;后端报告含 6 点「确认无恙」防重查。
- [ ] P1 分离窗下 TaskDrawer 关联会话死点击:openConversation detached 分支无跨窗信号不聚焦(useAiConversations.ts:661) - [x] P1 分离窗下 TaskDrawer 关联会话死点击:openConversation detached 分支无跨窗信号不聚焦(useAiConversations.ts:661) ✅已修核验销账(2026-10-05):openConversation detached 分支已 emitDetachedOpenConversation 跨窗信号+分离窗 handleDetachedOpenConversation 承接切换+not_found toast 落承接窗,注释明确记载 UX走查 P1-1
- [ ] P1 子代理运行期切回父会话 spawn 卡「已完成」幽灵态:回载 parseConvMessages 恒标 completed+运行期零事件(useAiConversations.ts:230) - [ ] P1 子代理运行期切回父会话 spawn 卡「已完成」幽灵态:回载 parseConvMessages 恒标 completed+运行期零事件(useAiConversations.ts:230) ——半修(2026-10-05 核验):回载态判定已修(toolCalls status 改 `toolResultMap.has || !convGenerating ? completed : running`,运行期回载不再恒 completed);剩「运行期零事件」半(子代理运行事件仍不推父会话视图)待批
- [ ] P1 后端 外层 960s 超时 drop 子循环 future→统一出口全跳过(报告蒸发+注册表泄漏+子会话滞留):deadline 检查下沉 stream chunk 循环或 select 超时分支特殊化补 AiAgentFinished(truncated)(audit/mod.rs:306) - [x] P1 后端 外层 960s 超时 drop 子循环 future→统一出口全跳过(报告蒸发+注册表泄漏+子会话滞留):deadline 检查下沉 stream chunk 循环或 select 超时分支特殊化补 AiAgentFinished(truncated)(audit/mod.rs:306) ✅已修(2026-10-05 核验:audit/mod.rs:326-329 子代理外层 deadline=SUB_AGENT_WALL_TIMEOUT_SECS+60 缓冲,循环内以截断报告优雅收尾非硬腰斩,「报告蒸发」注释原文在案)
- [ ] P2 rejected_tools 前端丢弃不可扫描:完成卡收字段+warn 色 chip(useAiEvents.ts:70) - [x] P2 rejected_tools 前端丢弃不可扫描:完成卡收字段+warn 色 chip(useAiEvents.ts:70) ✅已修(2026-10-05 核验:aiShared.ts:630 rejected_tools 字段+MessageList:1453 warn chip「工具名×次数」+title 全清单)
- [ ] P2 完成卡纯内存重启蒸发:切换/挂载按 origin='sub_agent' 回补(aiShared.ts:630) - [x] P2 完成卡纯内存重启蒸发:切换/挂载按 origin='sub_agent' 回补(aiShared.ts:630) ✅已修(2026-10-05 核验:aiShared.ts:677 ai_sub_agent_list 反查重建,轨迹存在即视为 completed)
- [ ] P2 完成卡恒贴底时序漂移:按完成轮次桶插入(MessageList.vue:1080) - [x] P2 完成卡恒贴底时序漂移:按完成轮次桶插入(MessageList.vue:1080) ✅已修(2026-10-05 核验:renderItems 内按 finishedAt 落桶插桩,「不恒贴消息流最底部」注释在案)
- [ ] P2 轨迹浮层无身份标识:头补 res.title/任务书首行(MessageList.vue:1584) - [x] P2 轨迹浮层无身份标识:头补 res.title/任务书首行(MessageList.vue:1584) ✅已修(2026-10-05 核验:agentPeekSubject{agent,convTitle,task,taskFull} 四字段+任务行取子会话首条 user 消息首行)
- [ ] P2 后端 白名单执行期不拦 Low 工具:allowed 随子循环传入 process_tool_calls(audit/mod.rs:814) - [x] P2 后端 白名单执行期不拦 Low 工具:allowed 随子循环传入 process_tool_calls(audit/mod.rs:814) ✅已修(2026-10-05 核验:audit/mod.rs:217 P2-1 执行期白名单判定纯函数+whitelist-clamp 拒绝语义「spawn 时被裁剪或不存在」人话提示)
- [ ] P2 后端 父删除级联孤儿轨迹照落库:cascade_stop_children_locked 删除形态补 deleted_convs.insert(sub_agent.rs:194) - [x] P2 后端 父删除级联孤儿轨迹照落库 ✅已实施核验销账(2026-10-05):CascadeStopForm::Delete 形态已落地(sub_agent.rs:268 cascade_stop_children_locked,行282 deleted_convs.insert 同锁补标记,与出口 alive 判定互斥注释在案;出口 sub_agent.rs:551-555 见标记即放弃落库防空壳复活)——文档滞后案
- [ ] P2 后端 S4 反查 LIMIT 1000 先截断后过滤:匹配条件下沉 SQL 或辅助索引(audit/record.rs:315) - [x] P2 后端 S4 反查 LIMIT 1000 先截断后过滤:匹配条件下沉 SQL 或辅助索引(audit/record.rs:315) ✅已修(2026-10-05 核验:P2-3 task_id 预筛下沉 SQL——result LIKE 参数化预筛缩小窗口,精确判等仍在 Rust 侧 match_task_audit_row,「禁裸 LIKE 判等」不破,预筛只多放不漏放+单测锁定)
- [ ] P3×15(前端7+后端8):deny envelope 人话渲染/浮层 Esc/spawn_agent 本地化/卡 loading/停止标「已取消」/后台完成通知/已删会话静默新建 + 并发配额挤兑/事件广播噪音/流后取消补 envelope/bypass 连带解除黑名单/落库元数据错位/peek 校验/空收敛降级/AiAgentStarted——见两报告 P3 节 - [x] P3×15(前端7+后端8):✅(2026-10-05 销账核验:15 条实际全部已落码或已缓批登记——deny envelope 人话渲染(parseDenyEnvelope+deniedTool i18n)/浮层 Esc(closeAgentTraceOnEsc+切会话 closeAgentTrace)/spawn_agent 本地化(spawnAgentPrefix zh/en)/卡 loading(agentPeekLoadingId spinner)/停止标「已取消」(stop_kind 结构化+fallback 启发式)/后台完成通知(agentFinishedToast)/已删会话静默新建(uf1 noCreateFallback)/空收敛降级(dx1 empty_convergence)/流后取消补 envelope(P3-3)/bypass 连带解除黑名单(P3-4 PathBypass 三态分流)/落库元数据错位(P3-5 SaveMetaOverride)/peek 校验(agentFinNoTrace 禁点+title)/事件广播噪音(P3-2 文档补全确认)——todo 行未勾属滞后;唯 AiAgentStarted 缓批「子代理运行面二期」+并发配额挤兑缓批已立项)——见两报告 P3 节
# 子代理深查两路(2026-10-03 晚,详情 temp/深查-子代理{AI效果面,x上下文管理交叉面}-2026-10-03.md) # 子代理深查两路(2026-10-03 晚,详情 temp/深查-子代理{AI效果面,x上下文管理交叉面}-2026-10-03.md)
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。 > 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
- [ ] P1[确定性] 子代理指引真空:父会话实测蒸馏的四段工具纪律(read_symbol 优先/URL 三工具纪律/重复调用检测/get_app_config)未注入子代理 system prompt,而白名单缺省下发 25+ Low 工具,主会话已根治的翻车模式(一次死磕烧 1/3 预算)会在子代理原样复发;修法=抽共享指引段同源拼接 - [x] P1[确定性] 子代理指引真空:父会话实测蒸馏的四段工具纪律(read_symbol 优先/URL 三工具纪律/重复调用检测/get_app_config)未注入子代理 system prompt,而白名单缺省下发 25+ Low 工具,主会话已根治的翻车模式(一次死磕烧 1/3 预算)会在子代理原样复发;修法=抽共享指引段同源拼接 ✅已修(2026-10-05 核验:prompt.rs sub_agent_tool_discipline_section 同源拼接,sub_agent_system_prompt 内引用,obscura 交互条款已在共享函数内裁剪)
- [ ] P1[需拍板] 熔断无收尾轮:max_rounds 打满是常态,截断时父拿到「末轮思考独白」而非报告;修法=熔断触发补一次无工具收尾 LLM 调用(涉预算策略) - [ ] P1[需拍板] 熔断无收尾轮:max_rounds 打满是常态,截断时父拿到「末轮思考独白」而非报告;修法=熔断触发补一次无工具收尾 LLM 调用(涉预算策略)
- [ ] P1[需拍板] 报告被视图压缩吞掉:summarize_tool_results 无新近度豁免,报告 envelope>2KB 下一轮即被 JSON 分支折叠(512 字符),父 LLM 从未见过报告全文而 DB/前端卡是全文,追问细节答不上;涉上下文策略(stall_detector.rs:278-350/context_helpers.rs:461-513) - [ ] P1[需拍板] 报告被视图压缩吞掉:summarize_tool_results 无新近度豁免,报告 envelope>2KB 下一轮即被 JSON 分支折叠(512 字符),父 LLM 从未见过报告全文而 DB/前端卡是全文,追问细节答不上;涉上下文策略(stall_detector.rs:278-350/context_helpers.rs:461-513)
- [ ] P2 max_tokens=8192 截断零信号:finish_reason=length 被通道层按正常完成吞,报告腰斩仍标 completed[确定性可修] - [x] P2 max_tokens=8192 截断零信号:finish_reason=length 被通道层按正常完成吞,报告腰斩仍标 completed[确定性可修] ✅已修(2026-10-05 核验:RoundOutcome::Continue 携 truncated_by_max_tokens 全链穿透,收敛时追加「⚠ 回复因 max_tokens 上限被截断」系统提示 mod.rs:1915-1925)
- [ ] P2 任务书=首条 user 消息,超预算时第一个被逐出且子代理无目标钉扎(主循环有,子循环显式传空 sub_agent.rs:594)[确定性可修] - [x] P2 空回复静默假成功(子代理侧) ✅已修(2026-10-05 核验:sub_agent.rs:384-397 末轮空白降级 Truncated+stop_kind=empty_convergence,不再 Completed+空报告失真;主循环侧同语义归无人值守 P2 批)
- [ ] P2 预算三值(轮次/token/900s)不告知子代理,系统提示词硬编码「默认最多 6 轮」[确定性可修,文案] - [x] P2 任务书=首条 user 消息,超预算时第一个被逐出且子代理无目标钉扎(主循环有,子循环显式传空 sub_agent.rs:594)[确定性可修] ✅已修(2026-10-05 核验:P2-2 任务书钉扎——<task> 全文并入 system prompt「上下文裁剪不可逐出」段)
- [ ] P2 Fatal 错误细节被吞(RoundOutcome::Fatal 无 payload),父拿无因报告无法针对性重派[中小扩参] - [x] P2 预算三值(轮次/token/900s)不告知子代理,系统提示词硬编码「默认最多 6 轮」[确定性可修,文案] ✅已修(2026-10-05 核验:sub_agent_system_prompt(max_rounds) 动态注入三值实际生效值+防回归断言禁「默认最多 6 轮」文案回潮)
- [x] P2 Fatal 错误细节被吞(RoundOutcome::Fatal 无 payload),父拿无因报告无法针对性重派[中小扩参] ✅已修(2026-10-05 核验:sub_agent.rs:897 P2-4 Fatal 携最后诊断文本进出口报告,封顶防超长污染)
- [ ] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天 - [ ] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板] - [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天 - [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板] - [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
- [ ] P3 spawn task 全文永续无瘦身/8KB 截断文案对子代理语境失真/环境画像日期不注入/报告 8K 头尾截断恰切证据区/temperature 0.7 共享/model 回退不回报/namespace 挤兑/一次性语义未向父声明/报告约定缺「文件路径原样保留」/语言无 lang 参数——见两报告 P3 节 - [ ] P3 spawn task 全文永续无瘦身/8KB 截断文案对子代理语境失真/环境画像日期不注入/报告 8K 头尾截断恰切证据区/temperature 0.7 共享/model 回退不回报/namespace 挤兑/一次性语义未向父声明/报告约定缺「文件路径原样保留」/语言无 lang 参数——见两报告 P3 节
- [ ] 观察 uf2 修复引入:取消判定 summary.includes('已被取消') 中文子串启发式,根治=后端透出结构化 stop_reason/取消标志字段 - [x] ~~观察 uf2 修复引入:取消判定 summary.includes('已被取消') 中文子串启发式,根治=后端透出结构化 stop_reason/取消标志字段~~ ✅已根治核验销账(2026-10-05 本批,文档滞后案):结构化字段已全链落地——后端 SubAgentStopKind::Cancelled 两处取消检查点同点产出(sub_agent.rs:716/824)+ AiAgentFinished.stop_kind 事件透出(serde 单测锁定)+ 前端 agentFinStatusText 结构化优先(MessageList.vue:1127,cancelled/max_rounds/wall_clock/token_budget 细分直达语义);中文子串判定降级为旧后端兼容性回退(无 stop_kind 字段才走),防御性保留非主路径
# 无人值守长任务(2026-10-04 用户拍板方向,设计先行) # 无人值守长任务(2026-10-04 用户拍板方向,设计先行)
@@ -923,7 +763,7 @@ graph TD
> 缺陷靶心分析:temp/深查-无人值守稳定性缺陷-2026-10-04.md(P1×5/P2×7+效率 5+观测可控 5,接驳点已标注)。 > 缺陷靶心分析:temp/深查-无人值守稳定性缺陷-2026-10-04.md(P1×5/P2×7+效率 5+观测可控 5,接驳点已标注)。
- [ ] 设计评审后按分期实施(P0 边界模型+旁路主干 → P1 自愈+通知 → P2 断点续跑+信任分级) - [ ] 设计评审后按分期实施(P0 边界模型+旁路主干 → P1 自愈+通知 → P2 断点续跑+信任分级)
- [ ] P1 缺陷随分期消化:~~①审批超时取消后循环永不恢复~~ ✅①已修(2026-10-04 核验:cancel_expired_approval 第6步清幽灵态迁回 Idle+第7步安全停靠 AiTaskParked+NeedAction 通知,audit/mod.rs:530-555) ②spawn_agent 注册 Medium 无人值守动线停摆(归 P0 旁路) ③MidStream 断流=整任务终止(入打断点表,P1) ④轮级重试 30s 总预算全候选共享无跨轮重入(P1,任务级重入;工作区已见 ROUND_REENTRY_MAX 跨轮重入层在飞实施,待合入核验) ⑤重启后任务静默失踪(归 P2 断点续跑) - [ ] P1 缺陷随分期消化:~~①审批超时取消后循环永不恢复~~ ✅①已修(2026-10-04 核验:cancel_expired_approval 第6步清幽灵态迁回 Idle+第7步安全停靠 AiTaskParked+NeedAction 通知,audit/mod.rs:530-555) ②spawn_agent 注册 Medium 无人值守动线停摆(归 P0 旁路) ③~~MidStream 断流=整任务终止~~ ✅已修(2026-10-05 核验:RoundOutcome::MidStreamSaved 分支 emit_task_parked 安全停靠+NeedAction 托盘通知「midstream」原因,mod.rs:1146) ④~~轮级重试 30s 总预算全候选共享无跨轮重入~~ ✅已修(2026-10-05 核验:任务级跨轮重入已落地——ROUND_REENTRY_MAX=3 指数退避 60/300/900s,Continue 成功连续失败归零,预算耗尽转停靠 mod.rs:1071/1124/1162) ⑤重启后任务静默失踪(归 P2 断点续跑)
- [ ] P2 缺陷登记:panic 收尾伪装正常完成无通知/context_length Fatal 无降级/空回复静默假成功/前端 3 次探测强制收敛与后端分裂/checkpoint 审计只增不清 - [ ] P2 缺陷登记:panic 收尾伪装正常完成无通知/context_length Fatal 无降级/空回复静默假成功/前端 3 次探测强制收敛与后端分裂/checkpoint 审计只增不清
- [ ] 效率面:模型无轮内分级/警示消息打断前缀缓存/换参同语义死磕不计数/子代理串行 await/token 花费中途不可查/纠偏只能排队不能注入/预算中途调整不可达(归设计补遗两章分期) - [ ] 效率面:模型无轮内分级/警示消息打断前缀缓存/换参同语义死磕不计数/子代理串行 await/token 花费中途不可查/纠偏只能排队不能注入/预算中途调整不可达(归设计补遗两章分期)
@@ -947,21 +787,38 @@ graph TD
- [x] P0 git.rs:161 字节切片撞 UTF-8 边界 panic(中文 diff 必炸,13:44 两次实证) ✅已修(分析当场,回退字符边界) - [x] P0 git.rs:161 字节切片撞 UTF-8 边界 panic(中文 diff 必炸,13:44 两次实证) ✅已修(分析当场,回退字符边界)
- [x] P1 Temperature 原样透传不支持的模型(sensenova 等仅允许 1,9 次 400 实证)——按模型适配/省略 ✅已修(2026-10-04:ModelConfig 加 temperature_supported 字段+presets 标 sensenova-6.7-flash-lite=false+启发式 sensenova 前缀判定;openai/anthropic 双协议转换层不支持即省略字段;df-ai 560+df-ai-core 33 测试全绿含新增锁行为测试) - [x] P1 Temperature 原样透传不支持的模型(sensenova 等仅允许 1,9 次 400 实证)——按模型适配/省略 ✅已修(2026-10-04:ModelConfig 加 temperature_supported 字段+presets 标 sensenova-6.7-flash-lite=false+启发式 sensenova 前缀判定;openai/anthropic 双协议转换层不支持即省略字段;df-ai 560+df-ai-core 33 测试全绿含新增锁行为测试)
- [ ] P1 压缩无 memoization:view-only 压缩 2280 次中 369 组同内容反复压(最坏 54 次),prompt 1.2 亿 tokens=completion 115 倍——同内容哈希跳过 - [x] P1 压缩无 memoization:view-only 压缩 2280 次中 369 组同内容反复压(最坏 54 次),prompt 1.2 亿 tokens=completion 115 倍——同内容哈希跳过 ✅已修(2026-10-05 核验:PerConvState.tool_compress_cache 内容哈希→压缩结果,mod.rs:1682 接线,双调用点主循环+子代理,agentic 122 测含)
- [ ] P1 标题采到 assistant 首句('信息已足够。让我看一眼...'/'<details><summa')——标题源取 user 首条 - [x] P1 标题采到 assistant 首句('信息已足够。让我看一眼...'/'<details><summa')——标题源取 user 首条 ✅已修(2026-10-05 核验:title.rs:62 两段式采样——user 首条必进锚定主题+其余按序补足 6 条)
- [ ] P2 checkpoints.token_total 语义失效(p50=120 vs 真实数万级,差 3 个量级) - [ ] P2 checkpoints.token_total 语义失效(p50=120 vs 真实数万级,差 3 个量级)【归 V49 schema 迁移批】
- [ ] P2 ai_conversations.status 只有 active/compressed,incomplete/error 终局未建模入 status(17/89 停在 tool 结果无收尾,无法统计未完成率) - [ ] P2 ai_conversations.status 只有 active/compressed,incomplete/error 终局未建模入 status(17/89 停在 tool 结果无收尾,无法统计未完成率)【归 V49 schema 迁移批】
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信) - [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB) - [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
- [ ] P2 日志无轮转且放 %TEMP%(系统清理无声删除,建议迁数据目录+按大小轮转) - [x] P2 日志无轮转且放 %TEMP%(系统清理无声删除,建议迁数据目录+按大小轮转) ✅已修(2026-10-05:logging.rs 双进程共用——落点迁 app_data_dir/{identifier}/devflow-trace.log+启动期 5MB 轮转保留 .1+旧 %TEMP% 日志迁移清理,3 单测锁定)
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动 - [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次 - [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路 - [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL - [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落) - [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
> Web Interface Guidelines 视角(子代理全量走查+主代复核)。前四轮零重复:本轮全是「键盘/读屏/触屏可达性」层,与历轮「交互逻辑」层正交。
> 计数:P2 × 4 / P3 × 9(P1 零)。
- [x] P2-M1 MessageItem 三操作按钮 hover-only:键盘聚焦不可见+触屏不可达(MessageItem.vue:129-136,217-219)——补 :focus-within 显形+aria-label,一处 CSS ✅已修(2026-10-05:.ai-msg-user:focus-within 两选择器入组+编辑/复制按钮补 aria-label)
- [x] P2-T1 TopBar 四面板徽章 span@click 键盘不可达(TopBar.vue:138 等,面板行为层已完备仅入口缺失)——改 button 或补键盘三件套 ✅已修(2026-10-05:四徽章 span→button type=button+.ai-tool-badge 归零按钮默认样式+focus-visible 描边)
- [x] P2-T2/T3 TopBar 约 8 处交互 div/span(@click 无键盘处理):provider 切换(:30)/审批徽章(:18)等——统一改 button ✅已修(2026-10-05:审批徽章 div→button+provider-bar div→button,两处 CSS 归零+focus-visible;余 model select 原生可聚焦,more 菜单已是 button)
- [x] P2-G1 流式输出无 aria-live:读屏用户对 AI 回复零感知(MessageList.vue 全文零命中,唯一例外 ApprovalOverlay role=alert)——容器加 role="log" aria-live="polite" ✅已修(2026-10-05:.ai-messages 容器加 role=log+aria-live=polite)
- [x] P3-M2/G4 按钮仅 title 无 aria-label(MessageItem:129-137/ChatInput 仅 :53 一处) ✅已核验(2026-10-05:MessageItem 编辑/复制按钮 aria-label+svg aria-hidden 已齐,残留 ChatInput :53 一处,下批顺带)
- [x] P3-G2 装饰性图标缺 aria-hidden ✅已修(2026-10-05 机械批:ps1 正则 `(?s)<svg(?![^>]*aria-hidden)` 批量插入 193 处(22 文件含多行开标签;前置核验全库零 role=img/<title>/aria-label 语义型 svg;已有 10 处负向前瞻跳过),vue-tsc EXIT 0)
- [x] P3-G3 滚动容器/弹层无 overscroll-behavior ✅核验销账(2026-10-05):桌面 Tauri WebView2 无移动端橡皮筋回弹/滚动链穿透问题,现状即合理,不引入
- [x] P3-G5 MessageList user-select:none 9 处 ✅核验销账(2026-10-05):逐处核对 9 处全为交互控件(bubble-toggle/time-sep/归档段折叠条/trace 折叠头/think-pill/步骤行/retry chip/agent-fin 完成卡头),零误伤正文选择,现状即合理
- 好评:ConversationSidebar/TaskBoundaryCard/DirAuthDialog 经前四轮修复质量扎实;无 transition:all/autoFocus 滥用等反模式
# ai-chat UX 走查第四轮(2026-10-04,增量深查,详情 temp/UX走查-aiChat缺陷-2026-10-04.md) # ai-chat UX 走查第四轮(2026-10-04,增量深查,详情 temp/UX走查-aiChat缺陷-2026-10-04.md)
> P1 零;P2×7/P3×11;已修三轮清单已硬排除。修复批开工以现场源码为准。 > P1 零;P2×7/P3×11;已修三轮清单已硬排除。修复批开工以现场源码为准。
- [x] P2-A2 边界配置卡目录白名单零校验 ✅已修(词法校验+就近红字),坏路径拖到运行期才暴露(TaskBoundaryCard.vue:163) - [x] P2-A2 边界配置卡目录白名单零校验 ✅已修(词法校验+就近红字),坏路径拖到运行期才暴露(TaskBoundaryCard.vue:163)
@@ -972,7 +829,7 @@ graph TD
- [x] P2-B2 纯发图+非视觉模型 ✅已修(前后端双闸拒纯图),占位串当用户消息喂模型跑偏一轮(chat.rs:789) - [x] P2-B2 纯发图+非视觉模型 ✅已修(前后端双闸拒纯图),占位串当用户消息喂模型跑偏一轮(chat.rs:789)
- [x] P2-B3 error_type 大面积 Unknown ✅已修(classify_permanent→ProviderConfig CTA),永久错误也显「重试」必再败(MessageList.vue:688+mod.rs:596) - [x] P2-B3 error_type 大面积 Unknown ✅已修(classify_permanent→ProviderConfig CTA),永久错误也显「重试」必再败(MessageList.vue:688+mod.rs:596)
- [x] 回归疑点 App.vue:227 全局快捷键 ✅已修(双文件 target 守卫+AIChat:339)(Ctrl+I/N/R/L/K/B)无 target 守卫,仅 Esc 有(走查复核提请,修时验证) - [x] 回归疑点 App.vue:227 全局快捷键 ✅已修(双文件 target 守卫+AIChat:339)(Ctrl+I/N/R/L/K/B)无 target 守卫,仅 Esc 有(走查复核提请,修时验证)
- [ ] P3×11(文案四条已顺带修 ✅:fatal 矛盾措辞/cancelled 续跑 CTA/high 裸串/TopBar ellipsis;余 9 布局+虚拟滚动+压缩面包屑待批) - [x] P3×11(文案四条已顺带修 ✅:fatal 矛盾措辞/cancelled 续跑 CTA/high 裸串/TopBar ellipsis)+~~A5 停靠卡 scrollIntoView 无效~~✅(2026-10-05 本批:删 no-op 调用+cardRef/nextTick/watch 残留引用清理——卡挂输入区上方常驻可见非消息流,原注释意图错位)+~~A9 TopBar 进度徽章前 4 轮盲区~~✅(2026-10-05 本批:mod.rs 进度 emit 条件加 `|| iteration == start_iteration`——首轮即发快照,起步阶段「在干活」信号不再缺席,托盘场景唯一进度面补齐)+余 7 项本批全清(2026-10-05):~~A8 双进度矛盾~~✅(TopBar 快照徽章在输入区 agentic 实时进度可见时隐藏——`v-if="taskProgress && !agenticProgressVisible"`,同口径 computed 对齐 AiChat showAgenticProgress;快照徽章只服务托盘/静默期场景)+~~A10 边界盾标跨窗空洞~~✅(ConversationSidebar onMounted 补拉 hydrateBoundaryBadges——对列表全部会话拉边界镜像,已有条目跳过省 IPC,治分离窗授权/重启后盾标全消失)+~~A11 切会话丢边界编辑~~✅(TaskBoundaryCard hasDirtyEdits computed 比对表单 vs 镜像,脏编辑时切会话不静默重填保留编辑+琥珀提示条 dirtyKept)+~~A12 预算校验不定位+无防重~~✅(parseBudget 记 budgetErrField 错误字段,红字着色具体输入框+toast 指名 budgetInvalidField;confirm/clear 加 confirming in-flight 禁用防双击双 IPC)+~~A13 echoSummary high 裸串~~✅(前批已修:riskHigh 分支复用 agentTrace.riskHigh)+~~B4 压缩静默~~✅(useAiContext autoCompressedFlash ref:AiAutoCompressed 置 true 4s 自灭,AiChat 输入区上方 hint 行显示「上下文已自动压缩」aiChat.autoCompressed zh/en——不打断 LLM 流不弹 toast)+~~B5 惰性渲染评估~~✅(评估后最小实施:MessageList collapsedPreviewHtml——折叠态长消息(>1500字)仅渲染前 800 字符预览,全量 markdown 渲染延迟到展开瞬间,治长会话切换/挂载成本线性涨;虚拟滚动回滚维持不做)
# 后续规划登记(2026-10-04 用户口述) # 后续规划登记(2026-10-04 用户口述)
@@ -982,11 +839,11 @@ graph TD
> 当日 ~810 次工具调用;主循环配置 max_iterations=0(不限)实证 225 轮零触顶;子代理 spawn max_rounds 6 默认/模型自给 10→20(上限 20)。 > 当日 ~810 次工具调用;主循环配置 max_iterations=0(不限)实证 225 轮零触顶;子代理 spawn max_rounds 6 默认/模型自给 10→20(上限 20)。
- [ ] P1 工具纪律失守:read_file 本支持 offset/limit 分页+search(工具描述明示)但模型不用,67+ 次 run_command PowerShell/python 切片绕道读文件——大文件 read_file 自动截断返回并带「用 offset/limit」引导+系统提示词禁 run_command 读文件 - [x] P1 工具纪律失守:read_file 本支持 offset/limit 分页+search(工具描述明示)但模型不用,67+ 次 run_command PowerShell/python 切片绕道读文件——大文件 read_file 自动截断返回并带「用 offset/limit」引导+系统提示词禁 run_command 读文件 ✅已修(狗粮批 a6a49ce8:prompt.rs file_reading_guidance 中/英双版,父 system_prompt 两语言+子代理纪律段同源拼接)
- [ ] P1 patch_file old_text 未命中 28%(38/136,全部「未找到目标文本」):加空白归一化回退匹配(唯一命中自动采用并提示),工具描述教「3-5 行锚点小步 patch」 - [x] P1 patch_file old_text 未命中 28%(38/136,全部「未找到目标文本」):加空白归一化回退匹配(唯一命中自动采用并提示),工具描述教「3-5 行锚点小步 patch」 ✅已修(在飞批:old_text 精确未中自动空白归一化重试,唯一命中整行区间替换+warning 注明,276 行含测试)
- [ ] P2 子代理默认 max_rounds=6 偏小,父试探性提到 10/20:默认提到 12 或按任务书长度给建议值 - [x] P2 子代理默认 max_rounds=6 偏小,父试探性提到 10/20:默认提到 12 或按任务书长度给建议值 ✅已修(2026-10-05:SUB_AGENT_DEFAULT_MAX_ROUNDS=12+schema/greeting 文案三处同步,spawn_agent 3 测+sub_agent 19 测全绿)
- [ ] P2 验证烧轮:全量 cargo check 多次(单次数十秒),引导 check -p 单包/单文件快检策略 - [x] P2 验证烧轮:全量 cargo check 多次(单次数十秒),引导 check -p 单包/单文件快检策略 ✅已修(2026-10-05 本批:prompt.rs verification_fast_check_guidance 中/英——窄域验证优先(单包 check -p/单文件回读),全量留阶段收官;父会话 en/zh 模板+子代理工具纪律段三处同源拼接)
- [ ] P3 并行子代理轮次徽标无身份,交错显示「第3轮/第2轮」致乱序观感(机制=各自独立计轮,正常):徽标带 agent 序号/任务首词 - [x] P3 并行子代理轮次徽标无身份,交错显示「第3轮/第2轮」致乱序观感(机制=各自独立计轮,正常) ✅已实施核验销账(2026-10-05 本批,文档滞后案):A-3 序号身份已落地——agentFinSeq 正则 `::agent-(\d+)$` 从 agentId 尾段提序号 + 完成卡模板 `#{{seq}}` 徽标(解析失败不渲染)+ agentPeekSubject 浮层头身份行(agent 序号/子会话标题/任务书首行),多代理交错完成可辨认「哪张卡是哪个代理」
# 新能力引入评估纪律(2026-10-04 用户令,长期生效) # 新能力引入评估纪律(2026-10-04 用户令,长期生效)
@@ -1057,7 +914,7 @@ graph TD
- [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩 - [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩
- [ ] W-h 感知租约(升级 P0:今日三连互踩实锤,FileLeaseRegistry+写前置校验,小改大收益)——能力批实施第一位 - [x] W-h 感知租约 ✅已实施核验销账(2026-10-05):lease.rs 345 行全套在案(FileLeaseRegistry+写前置校验+10min TTL 兜底+键归一+互斥/同会话放行/释放/TTL/归一五组单测),query_leases 工具已注册暴露,对齐《并行化与异步唤醒-2026-10-04》§10.1——文档滞后案
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动) - [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施) - [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
- [ ] 冻结待信号: W-a 写并行吞吐(只先修竞态)/W-d2 自拆解自设计/W-f 编排 2.0-3.0/W-e1 async spawn——启动信号见设计 §十一 - [ ] 冻结待信号: W-a 写并行吞吐(只先修竞态)/W-d2 自拆解自设计/W-f 编排 2.0-3.0/W-e1 async spawn——启动信号见设计 §十一
@@ -1074,3 +931,83 @@ graph TD
- download_file 工具增强候选:失败自动镜像回退(小改,登记待批) - download_file 工具增强候选:失败自动镜像回退(小改,登记待批)
- [ ] F-m 实施细节补充(§13.4 有界失败记忆):A4 失败豁免精化为「首条全文+同签名折叠一行(第N次)」——防窗口灌爆+盲试显式计数;失败分册按类去重 Top-K 有界;监控按月轮转;三界类目口径同源(同一归类函数) - [ ] F-m 实施细节补充(§13.4 有界失败记忆):A4 失败豁免精化为「首条全文+同签名折叠一行(第N次)」——防窗口灌爆+盲试显式计数;失败分册按类去重 Top-K 有界;监控按月轮转;三界类目口径同源(同一归类函数)
## 派工纪律内置基线(AI-Ops 分析移交 2026-10-05,用户拍板"通用能力内置沉淀")
> 背景:派工纪律 11 条在 AI-Ops(u-claw 侧)一周实战蒸馏;devflow agent(spawn_agent)同样需要,但走知识库条目会在分发时裸奔(数据不随包,新实例知识库空=纪律失效)。用户拍板:通用能力内置进代码随包分发,知识库只放场景性增强层。
> 落点:agentic loop 的 system_prompt 组装处内置以下母本(常量,编译进二进制)。u-claw 侧同款内容走 prompt.go 灰度(已拍板路径,分层:1-9 条第一批,10/11 条观察后二批)。
### 内置母本(8 行,双端通用,可微调措辞不可删项)
```text
## 派工纪律
1. 派工先估颗粒:单任务一轮内自证完成,装不下就拆批;独立子任务并行派。
2. 任务书三要素:目标文件边界(热点文件单点编辑权)+预期时限+标准验证命令。
3. 验收认证据:子代理须交"命令+结果",不收"我做完了";主代理独立抽查裁决。
4. 子代理报告限长(约4千字),超长落盘只给指针;主代理消化后紧凑透出,禁全量转贴。
5. 超时处置:到点发收敛令→无响应试探存活→确认死则主代理砍维度亲自收尾,勿重派同颗粒。
6. 改代码禁全量验证:单文件快检,阶段收官统一验证一次。
7. 派工前查重在途执行体,同类故障只委托一头。
8. 结果透出结论先行,证据随附,过程不铺陈。
```
### 实施项
- [ ] **N1 内置基线**:母本注入 agentic loop system_prompt 组装处(spawn_agent 子代理与编排节点同享;主 agent 自己派工时同样受此约束)。验收:新会话 system_prompt 含该段;知识库为空的全新实例同样生效。
- [ ] **N2 知识库增强层定位收缩**:纪律类内容不再入知识库条目(避免双源漂移);知识库只留场景性/经验性条目。存量 review_rule 若与母本重叠,以母本为准去重。
- [ ] **N3 知识库分发缺口登记**(独立项,优先级随多实例节奏):安装包带基线知识包(首启导入)或同步桥提级(存储架构定稿既有的"统一条目模型+同步桥"第一个实战用例,中心=df-pg)。单机阶段可缓,多实例分发前必须解。
- [ ] **N4 与编排纪律 prompt 合并**:u-claw 侧落点 prompt.go"派工纪律"段与既定"编排纪律 prompt"(07a8c55 同落点)一次发版合并做,勿两次动 system prompt。
## u-claw 沉淀能力蒸馏清单(AI-Ops 分析移交 2026-10-05)
> 来源:u-claw 一年实战沉淀(memory 30+ 条+Go 代码机制)。筛选标准:通用能力(非业务/非容器特有),devflow 有实证差距才立。已对齐不重复立的:显式交付协议(通知桥 oneshot)、PATCH 单点渲染、重连 backoff、查重在途(派工契约第7条)、双头让位。
### A 类·机制移植(有实证差距,优先)
- [x] **D1 僵尸资源显式释放** ✅实施完成(2026-10-05 本批,勘察补充:现 main.rs 尚无重连循环,leak 暂只发生一次,但 SDK start() 退出即任务终局——将来补 backoff 重连时 run() 重入必每次 leak 一份 LarkClient,预防性根治):client 由字段 `LarkClient` 改 `&'static LarkClient`,Box::leak 收敛到 new()/offline() 构造期各一次;run() 重连重入只重建 Channel 复用同一 &'static 引用,注释记载 D1 根因;dispatch 出站链零改动(引用传递同语义)。验收 cargo check -p df-im-gateway EXIT 0 + cargo test -p df-im-gateway 14 passed 0 failed
- [ ] **D2 白名单热加载**(u-claw 已有):现白名单启动时读死进 Ctx(L107),改 KV 须重启进程。修:whitelist 改 RwLock<HashMap> 或每消息现读 KV(读频低,成本可忽略);验收:改 KV 后下一条消息即生效,无需重启。
- [ ] **D3 渠道心跳保活**(u-claw feishu_beat.go):现靠断线被动重连,静默半开连接(对端假活)检测不到。修:周期(60s)轻量活性探测,连续 N 次失败主动断开触发重连。验收:杀对端连接后 2 分钟内自愈。
### B 类·设计思想对齐(随相关批次顺路)
- [ ] **D4 目标快照+补拉兜底**(u-claw goal.go L1/L2/L3 实证"三战三捷"):停机恢复按快照续,不重放旧目标。V49 断点续跑(loop_state)正在做,补一个维度:恢复时对齐用户当前有效意图(目标快照),防"按旧目标继续跑偏"。
- [ ] **D5 空轮判据自愈**(u-claw 空轮自愈经验):会话轮次无产出(空文本/无工具调用)连续 N 次→判定卡死,自动止损+提醒,而非挂到超时。
- [ ] **D6 效率基线滚动机制**(u-claw 1001 基线方法论):季度实测运行基线(吞吐/尾延迟/子代理占比)对照优化,防瓶颈形态迁移后优化失焦。devflow 首个基线已建(2026-10-05),固化为机制。
### C 类·功能候选(产品拍板,不急)
- [ ] D7 媒体收发:图片/文件上下行(SDK upload_image/download 已在,u-claw media.go 先例)——识别截图/回传产物图场景价值高
- [ ] D8 定时任务面:bot 内 cron 注册/查询/触发(u-claw crontab.go 形态)
- [ ] D9 群成员/用户名缓存:回复带真实姓名(u-claw members/username.go,20min 缓存)
- [ ] D10 会话标题:摘要里第二批已列(title_lifecycle 已有桌面端,渠道侧展示)
## 2026-10-05 晚交接:AI-Ops 移交待办(用户拍板:devflow 安排推进)
> 用户已授权:下列项无需再逐项请示,按看板自行安排推进。攒批提交已授权(分组执行)。
### 决策结论(已定,不再议)
- D2d 归档阈值 = 90 天(定案,随 D2a 批实施)
- N3 知识库分发 = 先安装包基线包、后同步桥(多实例分发前 2 周启动)
- 渠道假活(21:19 换装后 WS 未连)= 复发才深挖;D3 看门狗已实测立功(21:43 告警)
- 历史数据默认保留禁清理(全局规则,见 memory feedback-history-data-keep-default)
### 待办(按优先级)
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%)
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
6. [ ] **V49 批次独立核查**:47 文件收敛后请 AI-Ops 派核查,或自证全量 build+测试
7. [ ] C 类功能候选(优先级未排):D7 媒体收发 / D8 定时任务面 / D9 用户名缓存 / D10 会话标题
### 已完成勿重做(2026-10-05 AI-Ops 侧,勾选凭据)
- N1 派工纪律内置(DISPATCH_DISCIPLINE:mod.rs:184/2791 + sub_agent.rs:489)——已随 21:19 换装上线
- D1 僵尸 client 泄漏修复 / D2 白名单热加载 / D3 看门狗告警——已上线,D3 实测立功
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
- 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定)
### 新缺陷(2026-10-05 22:2x 用户截图实证,与 N/D 清单同级)
8. [ ] **工具输出 ANSI 转义裸露**(乱码):run_command 类工具输出含 ANSI 码(\x1b[7m 等)原样透传前端显示为 ␛[7m。修:执行器统一 strip_ansi(或注入 TERM=dumb/--color=never)+前端渲染层兜底过滤;验收:cargo test 输出在面板无 ␛ 残留。
9. [ ] **会话标题 content 泄漏**:会话标题显示原始正文片段(如 `<details> <summa…`,84cb104c 会话同症)——标题生成输入未清洗或兜底直接取 content 头部。修:title 生成失败/超时兜底改为清洗后的首行摘要,含 HTML/Markdown 标签时剥离;验收:长会话标题无标签裸露。
> 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。
+3 -3
View File
@@ -27,10 +27,10 @@ if ($free -lt $minFreeGB) {
Write-Host "[预检] E 盘剩余 ${free}GB,通过" Write-Host "[预检] E 盘剩余 ${free}GB,通过"
if ($Mode -eq "verify") { if ($Mode -eq "verify") {
Write-Host "== 通道1: 快速编译验证(cargo build --release,不打安装包) ==" Write-Host "== 通道1: 快速编译验证(cargo tauri build --no-bundle,与 full 同形态共享增量缓存) =="
cmd /c "cargo build --release 2>&1" cmd /c "cargo tauri build --no-bundle 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 编译未过"; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 编译未过"; exit 1 }
Write-Host "[通过] release 编译成功(未打安装包)" Write-Host "[通过] tauri 形态编译成功(未打安装包)"
exit 0 exit 0
} }
+154 -2
View File
@@ -25,6 +25,8 @@
@close-panel="store.togglePanel()" @close-panel="store.togglePanel()"
@dock="store.dockDetached()" @dock="store.dockDetached()"
@toggle-always-on-top="toggleAlwaysOnTop" @toggle-always-on-top="toggleAlwaysOnTop"
@reattach="onReattachToMain"
@show-shortcuts="showShortcuts"
@close-detached="store.closeDetachedWindow()" @close-detached="store.closeDetachedWindow()"
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')" @provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
/> />
@@ -46,6 +48,8 @@
<!-- Input --> <!-- Input -->
<div class="ai-input-area"> <div class="ai-input-area">
<!-- B4: 自动压缩完成提示(一次性 4s 淡出;治「上下文何时被压缩」零感知,不弹 toast 不打断流) -->
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 --> <!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" /> <TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化) <!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
@@ -131,6 +135,24 @@
<Transition name="ai-toast"> <Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div> <div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
</Transition> </Transition>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
<Teleport to="body">
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
@@ -154,6 +176,8 @@ import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace' import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types' import type { AiMessage } from '../api/types'
const props = defineProps<{ const props = defineProps<{
@@ -164,6 +188,8 @@ const props = defineProps<{
const store = useAiStore() const store = useAiStore()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
const { autoCompressedFlash } = useAiContext()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。 // path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。 // 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
@@ -278,7 +304,9 @@ async function confirmNewConversation() {
await store.newConversation() await store.newConversation()
} catch (e) { } catch (e) {
console.error('[AI] 新建对话失败:', e) console.error('[AI] 新建对话失败:', e)
// 用错误气泡反馈用户,不静默吞掉 // #16(2026-10-05 注释纠偏):原注释称「用错误气泡反馈用户」,实际 catch 内仅 console.error,
// UI 无反馈;失败兜底由 store.newConversation 内部 withConvOp toast 承接,此处不再重复提示,
// 保留 console.error 留痕。注释与实现对齐,避免后续维护误导。
} }
} }
@@ -338,9 +366,19 @@ function onGlobalKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement | null const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突) // Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
// V10(2026-10-05):撤 streaming 守卫,对齐按钮侧 handleNewConversation 的 F-09 真并发
// 语义——新建不杀旧 loop,旧会话后台继续,无需「生成中禁新建」;原守卫静默吞键(无反馈)
// 与按钮可点自相矛盾。真并发下新旧会话事件按 conversation_id 路由,无串台风险。
if (ctrl && (e.key === 'n' || e.key === 'N')) { if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault() e.preventDefault()
if (!store.state.streaming) store.newConversation() store.newConversation()
return
}
// I13(2026-10-05):Ctrl+I 聚焦输入框(输入焦点守卫已让路——焦点已在输入控件内不重复抢);
// Ctrl+K 归会话搜索,聚焦输入框选 I(无浏览器默认冲突,IME 安全)。
if (ctrl && (e.key === 'i' || e.key === 'I')) {
e.preventDefault()
chatInputRef.value?.focus()
return return
} }
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠) // Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
@@ -386,6 +424,12 @@ function onGlobalKeydown(e: KeyboardEvent) {
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return return
} }
// I12:快捷键速查面板开时 Esc 仅关面板(优先级高于主面板/分离窗关闭,防误关整窗)
if (shortcutsVisible.value) {
e.preventDefault()
shortcutsVisible.value = false
return
}
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件, // P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词), // 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。 // 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
@@ -612,6 +656,9 @@ onMounted(async () => {
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => { _unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info') showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
}) })
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
registerMainReattachListener(() => store.reattachPanel())
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播, // P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费) // 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => { _unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
@@ -674,6 +721,30 @@ async function toggleAlwaysOnTop() {
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value) appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
} }
// ── V11b 收回分离窗 + I12 快捷键速查面板 ──
// V11b(2026-10-05):分离窗 TopBar「收回」按钮 emit 链——分离窗与主窗是独立 JS context,
// 不能直调主窗 store.reattachPanel;走 Tauri 全局事件广播(emitReattachToMain),主窗 AiChat
// 挂载时注册的 registerMainReattachListener(useAiWindow.ts,按 label==='main' 判定)消费执行。
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
const shortcutsVisible = ref(false)
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
function showShortcuts() {
shortcutsVisible.value = true
}
async function onReattachToMain() {
await emitReattachToMain()
}
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状) // null | undefined | 'active' → 正常消息(现状)
@@ -833,6 +904,21 @@ async function initContextEventListeners(): Promise<void> {
flex-shrink: 0; flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite; animation: ai-agentic-pulse 1.2s ease-in-out infinite;
} }
/* B4: 自动压缩完成提示(一次性 4s;视觉克制度对齐 agentic 进度条,弱存在感) */
.ai-autocompressed-hint {
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
animation: ai-autocompressed-fade 4s ease-in-out forwards;
}
@keyframes ai-autocompressed-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0.35; }
}
.ai-agentic-progress-text { .ai-agentic-progress-text {
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
white-space: nowrap; white-space: nowrap;
@@ -930,4 +1016,70 @@ async function initContextEventListeners(): Promise<void> {
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; } .ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); } .ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
.ai-shortcuts-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.ai-shortcuts-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
}
.ai-shortcuts-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ai-shortcuts-title {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
.ai-shortcuts-close {
background: transparent;
border: none;
color: var(--df-text-secondary);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-mono, monospace);
font-size: 11px;
background: var(--df-bg-secondary);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
color: var(--df-text);
}
.ai-shortcuts-act { color: var(--df-text-secondary); }
</style> </style>
+6 -6
View File
@@ -84,28 +84,28 @@
:disabled="approving" :disabled="approving"
@click="onAuthorizeOnce"> @click="onAuthorizeOnce">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }} {{ $t('aiChat.dirAuthOnce') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--session" <button class="ai-tool-btn ai-tool-btn--session"
:disabled="approving" :disabled="approving"
@click="onAuthorizeSession"> @click="onAuthorizeSession">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }} {{ $t('aiChat.dirAuthSession') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--always" <button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving" :disabled="approving"
@click="onAuthorizeAlways"> @click="onAuthorizeAlways">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }} {{ $t('aiChat.dirAuthAlways') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--reject" <button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving" :disabled="approving"
@click="onDeny"> @click="onDeny">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }} {{ $t('aiChat.dirAuthDeny') }}
</button> </button>
</div> </div>
@@ -114,14 +114,14 @@
:disabled="approving" :disabled="approving"
@click="onApprove"> @click="onApprove">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }} {{ $t('aiTool.approve') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--reject" <button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving" :disabled="approving"
@click="onReject"> @click="onReject">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }} {{ $t('aiTool.reject') }}
</button> </button>
</div> </div>
+29 -6
View File
@@ -26,6 +26,8 @@
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)"> <div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span> <span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span> <span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span> <span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</div> </div>
<ToolCard <ToolCard
@@ -61,6 +63,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { toolRiskLevel } from '@/composables/ai/useToolApproval' import { toolRiskLevel } from '@/composables/ai/useToolApproval'
import { isToolFailure } from '@/composables/ai/useToolCard'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n() const { t } = useI18n()
@@ -110,6 +113,16 @@ const collapsedGroups = ref<Set<number>>(new Set())
// 用户手动展开过的组不再被自动收回——名字比索引稳(分组只追加不重排,但语义上名字更直白)。 // 用户手动展开过的组不再被自动收回——名字比索引稳(分组只追加不重排,但语义上名字更直白)。
const userTouchedGroups = ref(new Set<string>()) const userTouchedGroups = ref(new Set<string>())
// ── R7 组头失败徽标:折叠组内失败卡扫组头即可发现 ──
// 病灶(R7 单行折叠信息量缺口):工具失败信号散在单卡红边,同名工具组默认折叠后
// 失败卡被 display:none 压住,10+ 卡需逐组展开逐行扫。轨迹卡聚合行/执行中步骤行
// 已有「失败 N」计数,唯一盲区是组头。此处补:组内失败卡 ≥1 时组头显红徽标
// 「{n} 失败」,折叠展开都可见。失败判定复用 useToolCard.isToolFailure 单点(与
// 单卡红边/轨迹卡统计同源,防双写判定漂移)。
const groupFailCounts = computed<number[]>(() =>
groupedToolCalls.value.map(g => g.calls.reduce((n, tc) => n + (isToolFailure(tc) ? 1 : 0), 0)),
)
interface ToolCallGroup { interface ToolCallGroup {
name: string name: string
calls: AiToolCallInfo[] calls: AiToolCallInfo[]
@@ -415,12 +428,12 @@ defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */ /* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
function groupIcon(name: string): string { function groupIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>' if (name === 'read_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' if (name === 'write_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' if (name === 'list_directory') return '<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' if (name.includes('create')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' if (name.includes('delete')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
} }
</script> </script>
@@ -501,6 +514,16 @@ function groupIcon(name: string): string {
.ai-tool-group-header:hover { .ai-tool-group-header:hover {
background: rgba(255,255,255,0.03); background: rgba(255,255,255,0.03);
} }
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */
.ai-tool-group-fail {
margin-left: auto;
margin-right: 2px;
font-size: 10px;
font-weight: 600;
color: var(--df-danger);
opacity: 0.9;
white-space: nowrap;
}
.ai-tool-group-icon { .ai-tool-group-icon {
display: flex; display: flex;
align-items: center; align-items: center;
+32 -5
View File
@@ -113,11 +113,16 @@
<!-- write_file 结果 --> <!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result"> <div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span> <span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span> <span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span> <span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span> <span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div> </div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式) <!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
@@ -132,6 +137,8 @@
<div v-if="parsed?.warning" class="ai-tool-approval-reason">⚠ {{ parsed.warning }}</div> <div v-if="parsed?.warning" class="ai-tool-approval-reason">⚠ {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff"> <div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div> </div>
</div> </div>
@@ -202,7 +209,7 @@
<div v-if="obscuraMissing" class="ai-tool-obscura-hint"> <div v-if="obscuraMissing" class="ai-tool-obscura-hint">
<div class="ai-tool-obscura-hint-text">⚠ {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div> <div class="ai-tool-obscura-hint-text">⚠ {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura"> <button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ $t('aiTool.installObscura') }} {{ $t('aiTool.installObscura') }}
</button> </button>
</div> </div>
@@ -331,10 +338,15 @@ const cmdOutput = computed(() => {
}) })
/** /**
* patch_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。 * patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
* 数据源:parsed.diff(patch_file 工具执行结果字段)。长 diff 截断到前 120 行防撑爆卡片。 * 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
*/ */
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120)) const DIFF_PREVIEW_LINES = 120
const diffExpanded = ref(false)
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
/** /**
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数, * grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
@@ -865,4 +877,19 @@ watch(() => props.tc.status, (s) => {
transition: filter 0.15s var(--df-ease); transition: filter 0.15s var(--df-ease);
} }
.ai-tool-btn:hover { filter: brightness(1.1); } .ai-tool-btn:hover { filter: brightness(1.1); }
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
.ai-tool-diff-toggle {
display: block;
margin: 4px auto 0;
background: transparent;
border: 0.5px dashed var(--df-border);
border-radius: 4px;
color: var(--df-text-secondary);
font-size: 11px;
padding: 3px 12px;
cursor: pointer;
}
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); }
</style> </style>
+2 -2
View File
@@ -16,7 +16,7 @@
@click="onActivate" @click="onActivate"
> >
<div class="approval-overlay-icon"> <div class="approval-overlay-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" /> <path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" /> <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg> </svg>
@@ -31,7 +31,7 @@
:title="$t('common.close')" :title="$t('common.close')"
@click.stop="onDismiss" @click.stop="onDismiss"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
+83 -8
View File
@@ -53,7 +53,18 @@
:aria-label="$t('aiChat.pickImage')" :aria-label="$t('aiChat.pickImage')"
@click="onPickImage" @click="onPickImage"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</button>
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
<button
type="button"
class="ai-img-pick-btn"
:title="$t('aiChat.pickFileTitle')"
:aria-label="$t('aiChat.pickFileTitle')"
@click="onPickTextFile"
>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
</button> </button>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态, <!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 --> idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
@@ -63,7 +74,7 @@
:title="$t('aiChat.stopping')" :title="$t('aiChat.stopping')"
disabled disabled
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button> </button>
<button <button
v-else-if="stopBtnState === 'retry'" v-else-if="stopBtnState === 'retry'"
@@ -71,7 +82,7 @@
:title="$t('aiChat.stopFailedRetry')" :title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat" @click="store.stopChat"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button> </button>
<button <button
v-else-if="stopBtnState === 'stop'" v-else-if="stopBtnState === 'stop'"
@@ -79,7 +90,7 @@
:title="$t('aiChat.stopGenerating')" :title="$t('aiChat.stopGenerating')"
@click="store.stopChat" @click="store.stopChat"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button> </button>
<button <button
v-else v-else
@@ -88,7 +99,7 @@
:disabled="!canSend" :disabled="!canSend"
@click="handleSend" @click="handleSend"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button> </button>
</div> </div>
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) --> <!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
@@ -205,6 +216,66 @@ async function addImageFile(file: File): Promise<void> {
} }
} }
/** I9(2026-10-05):「📄 文件」按钮——选文本/代码文件读内容,以代码块形式插入输入框草稿。
* 病灶:附件通道仅图片,文本/代码文件无入口(粘贴/拖入均仅认 image/)。用户要给 AI 看一段
* 代码/配置只能手动开文件复制。本方案走纯前端:读文件内容插入草稿,复用既有文本发送链,
* 零协议/后端改动。多选逐个插入;单文件 256KB/5000 行上限(超出截断并提示,防撑爆上下文)。
* 扩展名白名单取常见文本/代码/配置格式,二进制格式(png/pdf/zip 等)不在名单直接拒绝。 */
const TEXT_FILE_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'csv', 'tsv',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'svg',
'py', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'h', 'cpp', 'hpp', 'cc', 'hh', 'cs', 'swift', 'm', 'mm',
'rb', 'php', 'pl', 'pm', 'lua', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'r',
'dart', 'gradle', 'cmake', 'dockerfile', 'makefile', 'gitignore', 'editorconfig', 'properties', 'proto', 'graphql', 'prisma',
]
const MAX_TEXT_FILE_BYTES = 256 * 1024
const MAX_TEXT_FILE_LINES = 5000
async function onPickTextFile(): Promise<void> {
const selected = await open({
multiple: true,
title: t('aiChat.pickFileTitle'),
filters: [{ name: t('aiChat.textFileFilter'), extensions: TEXT_FILE_EXTENSIONS }],
})
if (!selected) return
const files = Array.isArray(selected) ? selected : [selected]
for (const path of files) await insertTextFileDraft(path)
}
/** 读单个文本文件 → 截断(超限提示)→ 以 fenced code block 插入草稿(光标回到块尾) */
async function insertTextFileDraft(path: string): Promise<void> {
try {
// 复用图片链的 blobFromPath(convertFileSrc→fetch),项目未装 plugin-fs 零新依赖
const fileObj = await blobFromPath(path)
if (!fileObj) throw new Error('blobFromPath returned null')
const content = await fileObj.text()
const fileName = path.split(/[\\/]/).pop() || path
let text = content
let truncated = false
if (content.length > MAX_TEXT_FILE_BYTES) {
text = content.slice(0, MAX_TEXT_FILE_BYTES)
truncated = true
}
const lines = text.split('\n')
if (lines.length > MAX_TEXT_FILE_LINES) {
text = lines.slice(0, MAX_TEXT_FILE_LINES).join('\n')
truncated = true
}
// 文件名后缀作语言提示(无扩展名/特殊名不给语言,裸 ``` 也够用)
const ext = fileName.includes('.') ? (fileName.split('.').pop() || '').toLowerCase() : ''
const lang = TEXT_FILE_EXTENSIONS.includes(ext) ? ext : ''
const block = '\n\n```' + lang + ' <!-- ' + fileName + ' -->\n' + text + (truncated ? '\n… ' + t('aiChat.fileTooLargeTruncated') : '') + '\n```\n'
inputText.value += block
await nextTick()
inputEl.value?.focus()
const el = inputEl.value
if (el) el.selectionStart = el.selectionEnd = el.value.length
} catch (e) {
// 用户取消读取/二进制解码失败/权限拒绝:提示后继续(多选时不阻断后续文件)
emit('error', { msg: t('aiChat.fileReadFailed'), type: 'warning' })
console.error('[AI] 文本文件读取失败:', path, e)
}
}
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */ /** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
async function onPickImage(): Promise<void> { async function onPickImage(): Promise<void> {
const selected = await open({ const selected = await open({
@@ -727,7 +798,7 @@ function selectMention(item: MentionItem) {
el.focus() el.focus()
el.setSelectionRange(pos, pos) el.setSelectionRange(pos, pos)
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px' el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
} }
}) })
} }
@@ -830,11 +901,15 @@ function handleKeydown(e: KeyboardEvent) {
} }
} }
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致,
* 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */
const INPUT_MAX_HEIGHT = 200
function autoResize() { function autoResize() {
const el = inputEl.value const el = inputEl.value
if (el) { if (el) {
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px' el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
} }
} }
@@ -1022,7 +1097,7 @@ function fillDraft(text: string, skill: SkillInfo | null = null): void {
const len = el.value.length const len = el.value.length
el.setSelectionRange(len, len) el.setSelectionRange(len, len)
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px' el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
} }
}) })
} }
+36 -21
View File
@@ -15,12 +15,12 @@
<div class="ai-conv-sidebar-header"> <div class="ai-conv-sidebar-header">
<span class="ai-conv-sidebar-title">{{ t('aiChat.sidebarTitle') }}</span> <span class="ai-conv-sidebar-title">{{ t('aiChat.sidebarTitle') }}</span>
<button class="ai-btn-icon" @click="emit('confirm-new-conversation')" :title="t('aiChat.newConversation')"> <button class="ai-btn-icon" @click="emit('confirm-new-conversation')" :title="t('aiChat.newConversation')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button> </button>
</div> </div>
<!-- UX-06 §3.1: 对话搜索框(v-model 双绑 store.searchQuery;Ctrl+K 聚焦) --> <!-- UX-06 §3.1: 对话搜索框(v-model 双绑 store.searchQuery;Ctrl+K 聚焦) -->
<div class="ai-conv-search"> <div class="ai-conv-search">
<svg class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg aria-hidden="true" class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input <input
ref="searchInputRef" ref="searchInputRef"
v-model="store.state.searchQuery" v-model="store.state.searchQuery"
@@ -73,7 +73,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')"> <span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -81,23 +81,23 @@
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }} {{ t('aiChat.renameConversation') }}
</button> </button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }} {{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button> </button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }} {{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
</button> </button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()"> <button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</div> </div>
@@ -156,7 +156,7 @@
> >
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) --> <!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
<svg <svg aria-hidden="true"
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId" v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
class="ai-conv-item-title-badge" class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
@@ -167,7 +167,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')"> <span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -175,23 +175,23 @@
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }} {{ t('aiChat.renameConversation') }}
</button> </button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }} {{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button> </button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ t('aiChat.archive') }} {{ t('aiChat.archive') }}
</button> </button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()"> <button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</div> </div>
@@ -225,7 +225,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 --> <!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')"> <span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -233,19 +233,19 @@
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }} {{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button> </button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
{{ t('aiChat.unarchive') }} {{ t('aiChat.unarchive') }}
</button> </button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()"> <button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</div> </div>
@@ -293,7 +293,7 @@ import { useAiStore } from '../../stores/ai'
import { convPendingApprovals } from '../../composables/ai/aiShared' import { convPendingApprovals } from '../../composables/ai/aiShared'
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪; // 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制) // 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary } from '../../composables/ai/useTaskBoundary' import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations' import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast' import { useToast } from '../../composables/useToast'
@@ -609,8 +609,23 @@ onMounted(() => {
document.addEventListener('keydown', onActionsEscClose) document.addEventListener('keydown', onActionsEscClose)
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注) // V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500) pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
// A10 边界盾标跨窗/重启空洞补拉:镜像 Map 是本窗口 JS context 内存态,分离窗口授权
// 或重启后(boundary_json 在库)侧栏盾标全消失。挂载时对列表会话补拉一轮填充镜像
// (fetch 静默降级:失败仅缺徽标不报错;已入镜像的会话跳过,不重复打 IPC)。
void hydrateBoundaryBadges()
}) })
/** A10:对侧栏全部会话补拉边界镜像(盾标徽标数据源)。
* 逐条并发 fire-and-forget;镜像已有条目跳过(配置卡/前次补拉已填,省 IPC)。
* 列表为空或全命中时零 IPC 开销。 */
async function hydrateBoundaryBadges(): Promise<void> {
const ids = store.state.conversations.map(c => c.id)
await Promise.allSettled(ids.map(id => {
if (getConvBoundary(id) !== null) return Promise.resolve()
return fetchConvBoundary(id)
}))
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick) document.removeEventListener('click', onActionsOutsideClick)
document.removeEventListener('keydown', onActionsEscClose) document.removeEventListener('keydown', onActionsEscClose)
+3 -3
View File
@@ -34,19 +34,19 @@ const examplePrompts = [
<div class="ai-empty"> <div class="ai-empty">
<template v-if="!hasProvider"> <template v-if="!hasProvider">
<div class="ai-empty-icon"> <div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div> </div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p> <p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p> <p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
<!-- 跳 Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 --> <!-- 跳 Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
<button class="ai-empty-action" @click="emit('go-to-settings')"> <button class="ai-empty-action" @click="emit('go-to-settings')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettings') }}</span> <span>{{ $t('aiChat.goToSettings') }}</span>
</button> </button>
</template> </template>
<template v-else> <template v-else>
<div class="ai-empty-icon"> <div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg> <svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div> </div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p> <p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p> <p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
+10 -19
View File
@@ -34,14 +34,11 @@ defineProps<{
msg: AiMessage msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */ /** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean canEdit?: boolean
/** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */
canRegenerate?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void (e: 'copy', msg: AiMessage): void
(e: 'start-edit', msg: AiMessage): void (e: 'start-edit', msg: AiMessage): void
(e: 'regenerate', msg: AiMessage): void
}>() }>()
// ── Input Augmentation: 按 mention 区间切段 ── // ── Input Augmentation: 按 mention 区间切段 ──
@@ -106,7 +103,7 @@ function imgAlt(img: ImageContentPart): string {
<!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) --> <!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) -->
<div class="ai-msg-user"> <div class="ai-msg-user">
<div class="ai-msg-avatar ai-msg-avatar--user"> <div class="ai-msg-avatar ai-msg-avatar--user">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div> </div>
<div class="ai-msg-bubble ai-msg-bubble--user"> <div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 --> <!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
@@ -125,16 +122,12 @@ function imgAlt(img: ImageContentPart): string {
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" /> <img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div> </div>
</template> </template>
<!-- I6:重新生成按钮(hover,编辑按钮左侧);复用后端 regenerate 链路(弹末尾 AI 回复取触发 user 消息重跑) -->
<button v-if="canRegenerate" class="ai-msg-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 --> <!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)"> <button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button> </button>
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)"> <button class="ai-copy-btn" :title="t('aiChat.copyMsg')" :aria-label="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button> </button>
</div> </div>
</div> </div>
@@ -202,9 +195,8 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px; max-height: 200px;
border-radius: 6px; border-radius: 6px;
} }
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */ /* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
.ai-copy-btn, .ai-copy-btn,
.ai-msg-regen-btn,
.ai-msg-edit-btn { .ai-msg-edit-btn {
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
@@ -215,11 +207,10 @@ function imgAlt(img: ImageContentPart): string {
color: var(--df-text-dim); color: var(--df-text-dim);
} }
.ai-msg-user:hover .ai-copy-btn, .ai-msg-user:hover .ai-copy-btn,
.ai-msg-user:hover .ai-msg-regen-btn, .ai-msg-user:hover .ai-msg-edit-btn,
.ai-msg-user:hover .ai-msg-edit-btn { /* UX第五轮 P2-M1:键盘聚焦可达(hover-only 修复)——Tab 聚到按钮或按钮内聚焦时显形 */
.ai-msg-user:focus-within .ai-copy-btn,
.ai-msg-user:focus-within .ai-msg-edit-btn {
opacity: 1; opacity: 1;
} }
.ai-msg-regen-btn:hover {
color: var(--df-accent);
}
</style> </style>
+146 -65
View File
@@ -12,9 +12,9 @@
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus //! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt //! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级) //! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
//! - emit('toast', {msg, type}):copy/regenerate/retry 等的 toast 转发(父持单一 toast 源) //! - emit('toast', {msg, type}):copy/retry 等的 toast 转发(父持单一 toast 源)
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底 //! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue' import { ref, computed, nextTick, onBeforeUnmount, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown' import { useMarkdown } from '../../composables/useMarkdown'
@@ -206,7 +206,12 @@ const LOAD_MORE_TOP_PX = 60
async function handleMessagesScroll(): Promise<void> { async function handleMessagesScroll(): Promise<void> {
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持 // 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages))) // R10(2026-10-05):收起旧卡加 isFollowingBottom 门——上滚读历史时新轮次/新卡到达
// 不再强制收起正浏览的工具卡(跟随意图锁存语义对齐 useMessageScroll.isFollowingBottom);
// 用户在底部跟随态时行为不变(自动收起聚焦新生成)。
onMessagesScroll(() => {
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})
// G3.5:滚顶阈值触发加载更早历史 // G3.5:滚顶阈值触发加载更早历史
const cur = loadMoreCursor const cur = loadMoreCursor
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
@@ -373,7 +378,8 @@ watch(
const isFirst = lastMsgSnapshot === '' const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap lastMsgSnapshot = snap
if (isFirst) return if (isFirst) return
collapseAllToolLists(buildActiveToolIds(store.state.messages)) // R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
}, },
) )
@@ -383,7 +389,8 @@ watch(
() => store.state.agentRound, () => store.state.agentRound,
(cur, prev) => { (cur, prev) => {
if (cur <= (prev ?? 0)) return if (cur <= (prev ?? 0)) return
collapseAllToolLists(buildActiveToolIds(store.state.messages)) // R10 同门:非跟随态(上滚阅读中)新轮次到达不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
}, },
) )
@@ -724,15 +731,6 @@ async function handleRetry(): Promise<void> {
} }
} }
/**
* I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发
* user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。
* 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。
*/
async function handleRegenerate(): Promise<void> {
await handleRetry()
}
/** /**
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。 * fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试), * 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
@@ -791,14 +789,26 @@ const messageSegments = computed<MessageSegment[]>(() => {
return segments return segments
}) })
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠 // ── 展开/切换态集合工厂(DRY 2026-10-05) ──
const expandedSegmentIds = ref<Set<string>>(new Set()) // 五组「Set 集合 + 同构 toggle」(折叠段/长消息/JSON 块/轨迹卡/思考 pill)此前五连逐字重复,
function toggleSegment(key: string): void { // 收敛本工厂。ids 返回 Ref<Set> 本体:script 侧 .value.has / 整体重置赋值 / 模板侧自动解包
const next = new Set(expandedSegmentIds.value) // .has() 三种既有用法全部零改动,仅收敛重复的 toggle 实现。
if (next.has(key)) next.delete(key) // 不抽独立 composable 文件:五处仅本组件消费,文件内 DRY 即可,跨文件反增跳转成本。
else next.add(key) function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void } {
expandedSegmentIds.value = next const s = ref<Set<string>>(new Set())
return {
ids: s,
toggle: (id) => {
const next = new Set(s.value)
if (next.has(id)) next.delete(id)
else next.add(id)
s.value = next
},
} }
}
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。 // M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。 // 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
@@ -806,13 +816,7 @@ function toggleSegment(key: string): void {
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。 // 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500 const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠 /// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const expandedLongIds = ref<Set<string>>(new Set()) const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function toggleLong(id: string): void {
const next = new Set(expandedLongIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedLongIds.value = next
}
function isLongMsg(msg: AiMessage): boolean { function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
} }
@@ -825,6 +829,15 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true return true
} }
/// 折叠态是否跳过 markdown 渲染(B5 惰性渲染):折叠气泡视觉上只有 400px 高的 CSS 裁剪,
/// 但 v-html 仍全量渲染整段 markdown(150+ msg 长会话切换/挂载成本随消息数线性涨的主因)。
/// 跳过渲染后折叠态只显示首屏预览(前 800 字符)——展开瞬间才付全量渲染成本(一次性,可接受)。
/// 注意:预览截断可能断在 markdown 语法中间(半代码栅栏/半表格),渲染器按普通文本处理无副作用,
/// 仅视觉上「预览尾部可能带半个语法符号」;展开即见完整内容,不误导。
function collapsedPreviewHtml(msg: AiMessage): string {
return renderMdDecorated(msg.content.slice(0, 800))
}
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。 // R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注), // 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。 // 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
@@ -856,34 +869,16 @@ function isToolResultJson(content: string | undefined | null): boolean {
} }
} }
/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动) /// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动)
const expandedJsonIds = ref<Set<string>>(new Set()) const { ids: expandedJsonIds, toggle: toggleJson } = useToggleSet()
function toggleJson(id: string): void {
const next = new Set(expandedJsonIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedJsonIds.value = next
}
// A 过程压缩:执行轨迹展开态 // A 过程压缩:执行轨迹展开态
// 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合, // 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合,
// 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。 // 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。
const expandedTraceIds = ref<Set<string>>(new Set()) const { ids: expandedTraceIds, toggle: toggleTrace } = useToggleSet()
function toggleTrace(key: string): void {
const next = new Set(expandedTraceIds.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedTraceIds.value = next
}
// UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。 // UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。
// 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。 // 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。
const expandedThinkIds = ref<Set<string>>(new Set()) const { ids: expandedThinkIds, toggle: toggleThink } = useToggleSet()
function toggleThink(id: string): void {
const next = new Set(expandedThinkIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedThinkIds.value = next
}
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底; /** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */ * 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
@@ -1126,6 +1121,8 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止 // 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处 // 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。 // 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提 agent-{n} 序号,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防「3 轮/2 轮」乱序观感误读。
function agentFinStatusText(card: AgentFinishedCard): string { function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind const k = card.stop_kind
if (k) { if (k) {
@@ -1145,6 +1142,13 @@ function agentFinStatusText(card: AgentFinishedCard): string {
if (card.status === 'failed') return t('ai.agentFinished.failed') if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed') return t('ai.agentFinished.completed')
} }
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提取 agent-{n} 的 n,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防多卡「N 轮」乱序观感误读;
// 解析失败(形态不符)返回 null,模板不渲染序号徽标。
function agentFinSeq(card: AgentFinishedCard): number | null {
const m = /::agent-(\d+)$/.exec(card.agentId || '')
return m ? Number(m[1]) : null
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库, // 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败 // 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。 // 也至少有一轮落库记录,可正常 peek)。
@@ -1311,7 +1315,19 @@ function jumpToTrace(key: string): void {
} }
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。 // 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
watch(() => store.state.activeConversationId, () => { // V12 per-conv 滚动位置记忆:换出会话存 scrollTop,换入会话有存档则恢复(不再一律回底)。
// 容量收敛:仅记最近 20 个会话(Map 超限删最早),防长会话列表无限增长。
const _scrollTopMemo = new Map<string, number>()
const _SCROLL_MEMO_LIMIT = 20
watch(() => store.state.activeConversationId, (newId, oldId) => {
// 换出:记旧会话滚动位置(容器就绪且旧 id 有效才记)
if (oldId && messagesContainer.value) {
if (_scrollTopMemo.size >= _SCROLL_MEMO_LIMIT) {
_scrollTopMemo.delete(_scrollTopMemo.keys().next().value!)
}
_scrollTopMemo.set(oldId, messagesContainer.value.scrollTop)
}
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部) // 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true) setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开) // 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
@@ -1334,7 +1350,18 @@ watch(() => store.state.activeConversationId, () => {
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话 // UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明 // 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。 // 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
nextTick(() => scrollToBottom()) // V12(2026-10-05):有滚动位置存档的会话恢复存档位置(回读不被打断),无存档(首次/新会话)
// 保持回底。跟随意图仅在回底路径置位;恢复路径置 setFollowing(false) 尊重用户阅读位置。
nextTick(() => {
const saved = newId ? _scrollTopMemo.get(newId) : undefined
const el = messagesContainer.value
if (saved != null && el) {
el.scrollTop = Math.min(saved, el.scrollHeight)
setFollowing(false)
} else {
scrollToBottom()
}
})
}) })
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。 // 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1369,7 +1396,8 @@ defineExpose({
<template> <template>
<!-- Messages --> <!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick"> <!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue --> <!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState <EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming" v-if="store.state.messages.length === 0 && !store.state.streaming"
@@ -1421,7 +1449,7 @@ defineExpose({
@click="toggleThink(m.id)" @click="toggleThink(m.id)"
> >
<span class="ai-think-ic"> <span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span> </span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span> <span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span> <span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
@@ -1465,6 +1493,8 @@ defineExpose({
@click="openAgentTrace(item.card)" @click="openAgentTrace(item.card)"
> >
<span class="ai-agent-fin-dot"></span> <span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
<span v-if="agentFinSeq(item.card) !== null" class="ai-agent-fin-seq">#{{ agentFinSeq(item.card) }}</span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span> <span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span> <span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系; <!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
@@ -1509,7 +1539,7 @@ defineExpose({
:class="'ai-msg-segment--' + item.seg.kind" :class="'ai-msg-segment--' + item.seg.kind"
@click="toggleSegment(item.seg.key)" @click="toggleSegment(item.seg.key)"
> >
<svg class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="item.seg.kind === 'archived'"> <template v-if="item.seg.kind === 'archived'">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/> <polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/>
</template> </template>
@@ -1545,16 +1575,14 @@ defineExpose({
v-else-if="item.msg.role === 'user'" v-else-if="item.msg.role === 'user'"
:msg="item.msg" :msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId" :can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
@copy="copyMsgContent" @copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)" @start-edit="(m) => emit('start-edit', m)"
@regenerate="handleRegenerate"
/> />
<!-- AI 消息 --> <!-- AI 消息 -->
<div v-else class="ai-msg-ai"> <div v-else class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai"> <div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
</div> </div>
<div class="ai-msg-content"> <div class="ai-msg-content">
<!-- 文本内容(流式或固定,Markdown 渲染) --> <!-- 文本内容(流式或固定,Markdown 渲染) -->
@@ -1583,7 +1611,10 @@ defineExpose({
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div> <div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span> <span v-if="isViewingGenerating" class="ai-cursor"></span>
</template> </template>
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) --> <!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮)。
B5 惰性渲染:折叠态仅渲染前 800 字符预览(全量渲染延迟到展开瞬间),
长(>1500字)历史消息的 markdown 渲染成本不再随会话长度线性累积。 -->
<div v-else-if="shouldCollapseLong(item.msg)" v-html="collapsedPreviewHtml(item.msg)"></div>
<div v-else v-html="renderMdDecorated(item.msg.content)"></div> <div v-else v-html="renderMdDecorated(item.msg.content)"></div>
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) --> <!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
<button <button
@@ -1597,7 +1628,7 @@ defineExpose({
:title="$t('aiChat.copyMsg')" :title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)" @click.stop="copyMsgContent(item.msg)"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button> </button>
<button <button
v-if="item.msg.content" v-if="item.msg.content"
@@ -1605,7 +1636,7 @@ defineExpose({
:title="$t('aiChat.copyAsImage')" :title="$t('aiChat.copyAsImage')"
@click.stop="copyMsgAsImage(item.msg)" @click.stop="copyMsgAsImage(item.msg)"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button> </button>
</div> </div>
<!-- 流式开始但尚无文本时显示光标 --> <!-- 流式开始但尚无文本时显示光标 -->
@@ -1622,7 +1653,7 @@ defineExpose({
:disabled="store.state.streaming" :disabled="store.state.streaming"
@click.stop="handleRetry" @click.stop="handleRetry"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.retry') }}</span> <span>{{ $t('aiChat.retry') }}</span>
</button> </button>
<button <button
@@ -1630,7 +1661,7 @@ defineExpose({
class="ai-msg-act" class="ai-msg-act"
@click.stop="emit('go-to-settings')" @click.stop="emit('go-to-settings')"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettingsAction') }}</span> <span>{{ $t('aiChat.goToSettingsAction') }}</span>
</button> </button>
</div> </div>
@@ -1687,6 +1718,23 @@ defineExpose({
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div> </div>
<!-- #12(2026-10-05):会话累计(convTokenTotal)入面板——数据只写不读的闲置数据源接上消费;
累计为前端内存态(切换会话由 switchConversation 重算),会话末条 AI 回复的 popover 展示 -->
<template v-if="isLastAi(item.msg) && store.state.convTokenTotal">
<div class="ai-token-popover-sep"></div>
<div class="ai-token-popover-row ai-token-popover-row--total">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotal') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.total) }} ({{ store.state.convTokenTotal.total }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalIn') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.prompt) }} ({{ store.state.convTokenTotal.prompt }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalOut') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.completion) }} ({{ store.state.convTokenTotal.completion }})</span>
</div>
</template>
<!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 --> <!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row"> <div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span> <span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
@@ -1723,9 +1771,11 @@ defineExpose({
</div> </div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) --> <!-- 回到底部(上滑看历史 / 有新内容时显示) -->
<!-- R11(2026-10-05):生成中且非跟随态 → 按钮加脉冲点,提示「下面有新内容」而非仅导航 -->
<Transition name="back-to-bottom"> <Transition name="back-to-bottom">
<button v-if="showBackToBottom" class="ai-back-to-bottom" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')"> <button v-if="showBackToBottom" class="ai-back-to-bottom" :class="{ 'has-new': store.state.streaming && !isFollowingBottom }" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span v-if="store.state.streaming && !isFollowingBottom" class="ai-btb-new-dot"></span>
</button> </button>
</Transition> </Transition>
@@ -1744,7 +1794,7 @@ defineExpose({
</div> </div>
</div> </div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace"> <button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="ai-agent-peek-body"> <div class="ai-agent-peek-body">
@@ -1809,6 +1859,21 @@ defineExpose({
.back-to-bottom-leave-active { transition: opacity 0.15s, transform 0.15s; } .back-to-bottom-leave-active { transition: opacity 0.15s, transform 0.15s; }
.back-to-bottom-enter-from, .back-to-bottom-enter-from,
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); } .back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
/* R11 新内容脉冲点:生成中非跟随态提示下方有新内容(amber 脉冲,2s 循环) */
.ai-btb-new-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--df-warning, #e8a33d);
animation: ai-btb-pulse 2s ease-in-out infinite;
}
@keyframes ai-btb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.25); }
}
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */ /* -- Empty State 样式已随 EmptyState.vue 提取 -- */
@@ -2285,6 +2350,15 @@ defineExpose({
gap: 12px; gap: 12px;
padding: 2px 0; padding: 2px 0;
} }
/* #12 会话累计段分隔线 + 总计行加重 */
.ai-token-popover-sep {
height: 0.5px;
background: var(--df-border);
margin: 4px 0;
}
.ai-token-popover-row--total .ai-token-popover-label {
font-weight: 600;
}
.ai-token-popover-label { .ai-token-popover-label {
color: var(--df-text-dim); color: var(--df-text-dim);
} }
@@ -2615,6 +2689,13 @@ defineExpose({
flex-shrink: 0; flex-shrink: 0;
font-weight: 600; font-weight: 600;
} }
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq {
flex-shrink: 0;
font-family: var(--df-font-mono, monospace);
font-size: 11px;
opacity: 0.65;
}
.ai-agent-fin-status { .ai-agent-fin-status {
flex-shrink: 0; flex-shrink: 0;
} }
+69 -22
View File
@@ -5,14 +5,16 @@
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 --> (中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
<div class="ai-boundary"> <div class="ai-boundary">
<button type="button" class="ai-boundary-toggle" @click="toggleOpen"> <button type="button" class="ai-boundary-toggle" @click="toggleOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <svg aria-hidden="true" class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span> <span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span>
<span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span> <span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span>
</button> </button>
<div v-if="open" class="ai-boundary-body"> <div v-if="open" class="ai-boundary-body">
<div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div> <div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div>
<!-- A11: 卡展开切会话保留未提交编辑时的可见提示(编辑不静默蒸发,但目标会话已变) -->
<div v-if="open && hasDirtyEdits" class="ai-boundary-dirty">{{ t('ai.taskBoundary.dirtyKept') }}</div>
<!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 --> <!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 -->
<div v-if="loadFailed" class="ai-boundary-loadfail"> <div v-if="loadFailed" class="ai-boundary-loadfail">
@@ -61,29 +63,30 @@
</div> </div>
<button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen"> <button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <svg aria-hidden="true" class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{{ t('ai.taskBoundary.budgetToggle') }} {{ t('ai.taskBoundary.budgetToggle') }}
</button> </button>
<div v-if="budgetOpen" class="ai-boundary-budget"> <div v-if="budgetOpen" class="ai-boundary-budget">
<label class="ai-boundary-field"> <label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span> <span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" /> <input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label> </label>
<label class="ai-boundary-field"> <label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'tokens' }">
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span> <span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" /> <input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label> </label>
<label class="ai-boundary-field"> <label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span> <span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" /> <input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label> </label>
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span> <span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
</div> </div>
<div class="ai-boundary-actions"> <div class="ai-boundary-actions">
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button> <!-- A12: confirm/clear in-flight 防重(busy 禁用,防双击双 IPC 后到覆盖先到) -->
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed || confirming" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 --> <!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 -->
<button v-if="current" type="button" class="ai-boundary-clear" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button> <button v-if="current" type="button" class="ai-boundary-clear" :disabled="confirming" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
</div> </div>
</div> </div>
@@ -125,6 +128,24 @@ const budgetOpen = ref(false)
const rounds = ref('') const rounds = ref('')
const tokens = ref('') const tokens = ref('')
const wallSecs = ref('') const wallSecs = ref('')
// A12: in-flight 防重(双击双 IPC 后到覆盖先到)+ 出错字段定位
const confirming = ref(false)
const budgetErrField = ref<'' | 'rounds' | 'tokens' | 'wallSecs'>('')
// A11: 未提交脏编辑保护(切会话不静默蒸发正在输入的目录/预算)
const hasDirtyEdits = computed(() => {
if (!current.value) {
return dirs.value.length > 0 || dirInput.value.trim() !== ''
|| rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== ''
}
const b = current.value
const dirsDirty = JSON.stringify(dirs.value) !== JSON.stringify([...b.dir_allowlist])
const budgetDirty = (b.budget
? rounds.value.trim() !== String(b.budget.max_rounds)
|| tokens.value.trim() !== String(b.budget.max_tokens)
|| wallSecs.value.trim() !== String(b.budget.wall_secs)
: rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== '')
return dirsDirty || budgetDirty
})
const activeId = computed(() => store.state.activeConversationId) const activeId = computed(() => store.state.activeConversationId)
// 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪 // 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪
@@ -183,11 +204,12 @@ function toggleOpen(): void {
if (open.value) void hydrate() if (open.value) void hydrate()
} }
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台 // 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台。
// A11: 卡展开且有未提交脏编辑时不静默重填(保留编辑至用户手动收起/确认),仅静默拉取喂镜像。
watch(activeId, (id) => { watch(activeId, (id) => {
if (!id) return if (!id) return
if (open.value) void hydrate() void fetchConvBoundary(id)
else void fetchConvBoundary(id) if (open.value && !hasDirtyEdits.value) void hydrate()
}) })
onMounted(() => { onMounted(() => {
if (activeId.value) void fetchConvBoundary(activeId.value) if (activeId.value) void fetchConvBoundary(activeId.value)
@@ -235,12 +257,12 @@ function removeDir(d: string): void {
dirs.value = dirs.value.filter(x => x !== d) dirs.value = dirs.value.filter(x => x !== d)
} }
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null */ /** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null(带字段键定位) */
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null { function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
const fields = [ const fields = [
{ v: rounds.value.trim(), min: 1 }, { v: rounds.value.trim(), key: 'rounds' as const },
{ v: tokens.value.trim(), min: 1 }, { v: tokens.value.trim(), key: 'tokens' as const },
{ v: wallSecs.value.trim(), min: 1 }, { v: wallSecs.value.trim(), key: 'wallSecs' as const },
] ]
if (fields.every(f => !f.v)) return undefined if (fields.every(f => !f.v)) return undefined
const nums: number[] = [] const nums: number[] = []
@@ -250,7 +272,11 @@ function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: num
continue continue
} }
const n = Number(f.v) const n = Number(f.v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
// A12: 记录出错字段,toast 定位提示(替代笼统「预算需为正整数」)
budgetErrField.value = f.key
return null
}
nums.push(n) nums.push(n)
} }
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] } return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
@@ -259,10 +285,16 @@ function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: num
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */ /** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
async function confirmSet(): Promise<void> { async function confirmSet(): Promise<void> {
const id = activeId.value const id = activeId.value
if (!id || locked.value || loadFailed.value) return if (!id || locked.value || loadFailed.value || confirming.value) return
const budget = parseBudget() const budget = parseBudget()
if (budget === null) { if (budget === null) {
emit('toast', { msg: t('ai.taskBoundary.budgetInvalid'), type: 'warning' }) // A12: 出错字段定位(红字标到具体输入框 + toast 指名)
const fieldLabel = budgetErrField.value === 'rounds'
? t('ai.taskBoundary.budgetRounds')
: budgetErrField.value === 'tokens'
? t('ai.taskBoundary.budgetTokens')
: t('ai.taskBoundary.budgetWallSecs')
emit('toast', { msg: t('ai.taskBoundary.budgetInvalidField', { field: fieldLabel }), type: 'warning' })
return return
} }
const boundary: TaskBoundary = { const boundary: TaskBoundary = {
@@ -271,6 +303,7 @@ async function confirmSet(): Promise<void> {
forbid_destructive: true, forbid_destructive: true,
budget: budget ?? null, budget: budget ?? null,
} }
confirming.value = true
try { try {
await saveConvBoundary(id, boundary) await saveConvBoundary(id, boundary)
open.value = false open.value = false
@@ -278,6 +311,9 @@ async function confirmSet(): Promise<void> {
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' }) emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
} finally {
// A12: 防重守卫复位(成功收卡/失败停留都复位;3s 兜底防 IPC 悬挂卡死)
confirming.value = false
} }
} }
@@ -286,8 +322,9 @@ async function confirmSet(): Promise<void> {
* 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */ * 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */
async function clearBoundary(): Promise<void> { async function clearBoundary(): Promise<void> {
const id = activeId.value const id = activeId.value
if (!id) return if (!id || confirming.value) return
if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return
confirming.value = true
try { try {
await saveConvBoundary(id, null) await saveConvBoundary(id, null)
resetForm() resetForm()
@@ -295,6 +332,8 @@ async function clearBoundary(): Promise<void> {
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' }) emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
} finally {
confirming.value = false
} }
} }
@@ -527,4 +566,12 @@ const echoSummary = computed(() => {
} }
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); } .ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; } .ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
/* A12: 预算字段校验错误着色(定位到具体输入框,替代笼统 toast) */
.ai-boundary-field.is-error .ai-boundary-input { border-color: var(--df-danger); }
.ai-boundary-field.is-error span { color: var(--df-danger); }
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
.ai-boundary-dirty {
font-size: 10.5px;
color: var(--df-warning);
}
</style> </style>
+87 -34
View File
@@ -5,24 +5,26 @@
<div class="ai-header"> <div class="ai-header">
<div class="ai-header-left"> <div class="ai-header-left">
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')"> <button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button> </button>
<span class="ai-header-icon"> <span class="ai-header-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</span> </span>
<span class="ai-header-title">{{ $t('ai.assistant') }}</span> <span class="ai-header-title">{{ $t('ai.assistant') }}</span>
<div <button
v-if="pendingApprovalCount > 0" v-if="pendingApprovalCount > 0"
type="button"
class="ai-approval-badge" class="ai-approval-badge"
:title="$t('aiChat.pendingApprovalHint')" :title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')" @click="emit('scroll-to-pending')"
> >
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }} ⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div> </button>
</div> </div>
<!-- 提供商状态 + 模型选择器 --> <!-- 提供商状态 + 模型选择器 -->
<div class="ai-header-center"> <div class="ai-header-center">
<div <button
type="button"
class="ai-provider-bar" class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }" :class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0" v-if="store.state.providers.length > 0"
@@ -31,7 +33,7 @@
> >
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span> <span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span> <span class="provider-name">{{ activeProviderName }}</span>
</div> </button>
<div class="ai-provider-bar ai-provider-empty" v-else> <div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span> <span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div> </div>
@@ -60,7 +62,7 @@
:title="$t('aiChat.moreActions')" :title="$t('aiChat.moreActions')"
@click="moreMenuOpen = !moreMenuOpen" @click="moreMenuOpen = !moreMenuOpen"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button> </button>
<Transition name="ai-more-popout"> <Transition name="ai-more-popout">
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu"> <div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
@@ -70,7 +72,7 @@
:disabled="!hasActiveMessages || store.state.streaming" :disabled="!hasActiveMessages || store.state.streaming"
@click="emit('clear-context'); moreMenuOpen = false" @click="emit('clear-context'); moreMenuOpen = false"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<span>{{ $t('aiChat.clearContext') }}</span> <span>{{ $t('aiChat.clearContext') }}</span>
</button> </button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) --> <!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
@@ -79,10 +81,18 @@
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value" :disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
@click="emit('compress-context'); moreMenuOpen = false" @click="emit('compress-context'); moreMenuOpen = false"
> >
<svg v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg> <svg aria-hidden="true" v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> <svg aria-hidden="true" v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span> <span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
</button> </button>
<!-- I12(2026-10-05):快捷键速查入口(零快捷键提示补课;面板体在 AiChat Teleport 渲染) -->
<button
class="ai-more-item"
@click="emit('show-shortcuts'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6.01" y2="10"/><line x1="10" y1="10" x2="10.01" y2="10"/><line x1="14" y1="10" x2="14.01" y2="10"/><line x1="18" y1="10" x2="18.01" y2="10"/><line x1="7" y1="14" x2="7.01" y2="14"/><line x1="11" y1="14" x2="11.01" y2="14"/><line x1="15" y1="14" x2="15.01" y2="14"/></svg>
<span>{{ $t('aiChat.shortcutsTitle') }}</span>
</button>
</div> </div>
</Transition> </Transition>
</div> </div>
@@ -90,26 +100,31 @@
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 --> <!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
<template v-if="!detached"> <template v-if="!detached">
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')"> <button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
<svg v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg> <svg aria-hidden="true" v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg> <svg aria-hidden="true" v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
</button> </button>
<button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')"> <button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
</button> </button>
<button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')"> <button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</template> </template>
<!-- 分离模式:吸附 + 置顶 + 关闭 --> <!-- 分离模式:收回主面板 + 吸附 + 置顶 + 关闭 -->
<template v-else> <template v-else>
<!-- V11b(2026-10-05):收回分离窗回主面板(emitReattachToMain → 主窗 reattachPanel),
分离窗自关。与「关闭」区别:关闭仅关窗不恢复主面板,收回=分离→主面板合一 -->
<button class="ai-btn-icon" @click="emit('reattach')" :title="$t('aiChat.reattachToMain')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M9 12h6"/><path d="M12 9l0 6"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')"> <button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
</button> </button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')"> <button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
</button> </button>
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')"> <button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</template> </template>
</div> </div>
@@ -121,8 +136,11 @@
<div class="ai-bottom-tools-right"> <div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示, <!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。 静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 --> max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。
<div v-if="taskProgress" class="ai-task-progress"> A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div v-if="taskProgress && !agenticProgressVisible" class="ai-task-progress">
<span class="ai-task-progress-dot"></span> <span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span> <span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span <span
@@ -135,10 +153,10 @@
</div> </div>
<!-- 目标计数 --> <!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline"> <div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')"> <button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }} {{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
</span> </button>
<Transition name="ai-pop"> <Transition name="ai-pop">
<div v-if="goalsExpanded" class="ai-goals-inline-list"> <div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }"> <div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
@@ -152,10 +170,10 @@
</div> </div>
<!-- 历史消息 --> <!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline"> <div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': historyExpanded }" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')"> <button type="button" class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': historyExpanded }" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }} {{ historyMsgs.length }}
</span> </button>
<Transition name="ai-pop"> <Transition name="ai-pop">
<div v-if="historyExpanded" class="ai-history-inline-list"> <div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)"> <div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
@@ -168,10 +186,10 @@
</div> </div>
<!-- 上下文摘要 --> <!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline"> <div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': summaryExpanded }" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')"> <button type="button" class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': summaryExpanded }" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }} {{ summaryMsgs.length }}
</span> </button>
<Transition name="ai-pop"> <Transition name="ai-pop">
<div v-if="summaryExpanded" class="ai-summary-inline-list"> <div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)"> <div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
@@ -183,9 +201,9 @@
</div> </div>
<!-- L3 完整上下文 --> <!-- L3 完整上下文 -->
<div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline"> <div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline">
<span class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': contextExpanded }" @click="togglePanel('context')" :title="$t('aiChat.viewContext')"> <button type="button" class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': contextExpanded }" @click="togglePanel('context')" :title="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</span> </button>
<Transition name="ai-pop"> <Transition name="ai-pop">
<div v-if="contextExpanded" class="ai-context-inline-list"> <div v-if="contextExpanded" class="ai-context-inline-list">
<!-- 系统提示 --> <!-- 系统提示 -->
@@ -276,8 +294,12 @@ const emit = defineEmits<{
(e: 'dock'): void (e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */ /** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void (e: 'toggle-always-on-top'): void
/** V11b 收回分离窗回主面板(父 emitReattachToMain 广播,主窗 reattachPanel 执行) */
(e: 'reattach'): void
/** 关闭分离窗口(store.closeDetachedWindow) */ /** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void (e: 'close-detached'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */ /** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void (e: 'provider-switched', modelName: string): void
}>() }>()
@@ -387,6 +409,11 @@ const taskProgress = computed(() => {
if (!id) return null if (!id) return null
return pendingTaskProgress.get(id) ?? null return pendingTaskProgress.get(id) ?? null
}) })
// A8 双进度矛盾:输入区 agentic 实时进度条可见条件(与 AiChat.vue showAgenticProgress 同口径)——
// 可见时 TopBar 快照徽章让位隐藏(轮数口径不同:实时 vs 每5轮快照,同屏互相矛盾)
const agenticProgressVisible = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
const taskProgressText = computed(() => { const taskProgressText = computed(() => {
const p = taskProgress.value const p = taskProgress.value
if (!p) return '' if (!p) return ''
@@ -654,7 +681,7 @@ onBeforeUnmount(() => {
white-space: nowrap; white-space: nowrap;
max-width: 200px; max-width: 200px;
} }
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */ /* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */
.ai-approval-badge { .ai-approval-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -667,10 +694,18 @@ onBeforeUnmount(() => {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
transition: opacity 0.15s var(--df-ease); transition: opacity 0.15s var(--df-ease);
font-family: inherit;
appearance: none;
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
border: none;
} }
.ai-approval-badge:hover { .ai-approval-badge:hover {
opacity: 0.85; opacity: 0.85;
} }
.ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 1px;
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */ /* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker { .ai-model-picker {
@@ -840,7 +875,7 @@ onBeforeUnmount(() => {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.35; } 50% { opacity: 0.35; }
} }
/* ═══ Provider Bar (header 中间区) ═══ */ /* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
.ai-provider-bar { .ai-provider-bar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -848,6 +883,11 @@ onBeforeUnmount(() => {
font-size: 11px; font-size: 11px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
white-space: nowrap; white-space: nowrap;
font-family: inherit;
appearance: none;
background: none;
border: none;
padding: 0;
} }
.ai-provider-bar--switchable { .ai-provider-bar--switchable {
cursor: pointer; cursor: pointer;
@@ -855,6 +895,11 @@ onBeforeUnmount(() => {
.ai-provider-bar--switchable:hover { .ai-provider-bar--switchable:hover {
color: var(--df-text-primary); color: var(--df-text-primary);
} }
.ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 2px;
border-radius: 2px;
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */ /* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; } .provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
.provider-dot--ok { background: var(--df-success); } .provider-dot--ok { background: var(--df-success); }
@@ -889,6 +934,14 @@ onBeforeUnmount(() => {
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
white-space: nowrap; white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 1px;
} }
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/ .ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover { .ai-tool-badge:hover {
@@ -22,7 +22,7 @@
</div> </div>
<div class="project-meta-row"> <div class="project-meta-row">
<span class="project-meta-item"> <span class="project-meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg>
{{ $t('dashboard.taskUnit', { n: p.activeTasks }) }} {{ $t('dashboard.taskUnit', { n: p.activeTasks }) }}
</span> </span>
<span class="project-meta-time">{{ p.lastActivity }}</span> <span class="project-meta-time">{{ p.lastActivity }}</span>
+11 -11
View File
@@ -3,7 +3,7 @@
<!-- Logo(7b:brand 区只保留品牌标识,收缩按钮已移到底部操作区) --> <!-- Logo(7b:brand 区只保留品牌标识,收缩按钮已移到底部操作区) -->
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="brand-mark"> <div class="brand-mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"> <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22"> <defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/> <stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
@@ -54,7 +54,7 @@
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) --> <!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)"> <button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon"> <span class="nav-link-icon ai-spark-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span> </span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span> <span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span> <span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
@@ -63,15 +63,15 @@
</button> </button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined"> <router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
<span class="nav-link-icon"> <span class="nav-link-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</span> </span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span> <span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link> </router-link>
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) --> <!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')"> <button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon"> <span class="nav-link-icon">
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> <svg aria-hidden="true" v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <svg aria-hidden="true" v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span> </span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span> <span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span> <span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
@@ -151,20 +151,20 @@ const runningConvCount = computed(() => {
const primaryNav = [ const primaryNav = [
{ path: '/dashboard', label: 'nav.overview', badge: null, { path: '/dashboard', label: 'nav.overview', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
{ path: '/ideas', label: 'nav.ideas', badge: null, { path: '/ideas', label: 'nav.ideas', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
{ path: '/projects', label: 'nav.projects', badge: null, { path: '/projects', label: 'nav.projects', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
{ path: '/tasks', label: 'nav.tasks', badge: null, { path: '/tasks', label: 'nav.tasks', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
] ]
const secondaryNav = [ const secondaryNav = [
{ path: '/knowledge', label: 'nav.knowledge', { path: '/knowledge', label: 'nav.knowledge',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
{ path: '/audit', label: 'nav.audit', { path: '/audit', label: 'nav.audit',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' }, svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
] ]
</script> </script>
+1 -1
View File
@@ -30,7 +30,7 @@
type="button" type="button"
:title="$t('fileExplorer.deleteCurrentModule')" :title="$t('fileExplorer.deleteCurrentModule')"
> >
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
<!-- 添加依赖弹窗 --> <!-- 添加依赖弹窗 -->
+6 -6
View File
@@ -46,20 +46,20 @@
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" @click="openEditModule"> <button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button> </button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule"> <button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" @click="openAddModule"> <button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button> </button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh"> <button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span> <span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }} {{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button> </button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" @click="onDetach"> <button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" @click="onDetach">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/> <path d="M15 3h6v6"/>
<path d="M10 14L21 3"/> <path d="M10 14L21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
@@ -74,10 +74,10 @@
<div class="explorer-sidebar"> <div class="explorer-sidebar">
<div class="explorer-view-tabs"> <div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'"> <button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button> </button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges"> <button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span> <span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button> </button>
</div> </div>
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,7 +7,7 @@
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span> <span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span> <span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')"> <button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</button> </button>
</div> </div>
@@ -85,7 +85,7 @@
<div v-else class="gc-history"> <div v-else class="gc-history">
<!-- 提交搜索框(前端过滤已加载提交:subject/author) --> <!-- 提交搜索框(前端过滤已加载提交:subject/author) -->
<div class="gc-search"> <div class="gc-search">
<svg class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg aria-hidden="true" class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input <input
v-model="commitSearch" v-model="commitSearch"
class="gc-search-input" class="gc-search-input"
+2 -1
View File
@@ -153,9 +153,10 @@ async function batchApprove(decision: 'approve' | 'reject') {
} }
} }
_batchProcessing.value = false _batchProcessing.value = false
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项) // #14(2026-10-05):部分失败从静默 console.warn 升级为用户可见 toast(仍保留 warn 留痕诊断)
if (failCount > 0) { if (failCount > 0) {
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`) console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
void emit('ai-toast', { msg: t('aiTool.batchPartialFail', { ok: successCount, fail: failCount }), type: 'warning' })
} }
} }
+27 -6
View File
@@ -5,6 +5,7 @@
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id //! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
import { ref } from 'vue' import { ref } from 'vue'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -15,12 +16,32 @@ import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApp
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types' import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
// ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ──
// 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv'
// 会互相覆盖(后写者赢),重启后两窗恢复到同一会话。按窗口 label 分流:主窗 'df-ai-active-conv'
// (历史 KV 兼容,主窗数据零迁移),分离窗 'df-ai-active-conv-detached';读端(loadConversations
// 启动恢复)与写端(切会话/新建/自动路由/删除回落)统一走本函数取键。
// 每次调用现解析:窗口对象在 webview 启动即存在(getCurrentWebviewWindow 同步),无启动竞态;
// 非 Tauri 环境(单测/工具链)兜底主窗键。useAiEvents 的自动路由写入点同源 import 消费。
export function activeConvKvKey(): string {
try {
return getCurrentWebviewWindow().label === 'main'
? 'df-ai-active-conv'
: 'df-ai-active-conv-detached'
} catch {
return 'df-ai-active-conv'
}
}
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */ /** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
async function loadConversations() { async function loadConversations() {
try { try {
state.conversations = await aiApi.listConversations() // includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表,
// 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。
// 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。
state.conversations = await aiApi.listConversations(50, true)
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。 // 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。 // 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。 // 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
@@ -42,7 +63,7 @@ async function loadConversations() {
}) })
} }
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时) // 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
const pending = appSettings.get<string | null>('df-ai-active-conv', null) const pending = appSettings.get<string | null>(activeConvKvKey(), null)
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) { if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending) await switchConversation(pending)
} }
@@ -101,7 +122,7 @@ async function newConversation() {
) )
if (!result) return // 失败已推气泡,保持当前视图 if (!result) return // 失败已推气泡,保持当前视图
state.activeConversationId = result.id state.activeConversationId = result.id
void appSettings.set('df-ai-active-conv', result.id) void appSettings.set(activeConvKvKey(), result.id)
// G3.5:新对话无历史,load_more 游标复位 // G3.5:新对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null loadMoreCursor.earliestSeq = null
@@ -319,7 +340,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id) console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation() const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return 'ok' if (mySwitchId !== _latestSwitchId) return 'ok'
void appSettings.set('df-ai-active-conv', created.id) void appSettings.set(activeConvKvKey(), created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
state.activeConversationId = created.id state.activeConversationId = created.id
@@ -418,7 +439,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
return 'ok' return 'ok'
} }
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv) // 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id) void appSettings.set(activeConvKvKey(), id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。 // P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
@@ -552,7 +573,7 @@ async function deleteConversation(id: string) {
clearLastDelta(id) clearLastDelta(id)
discardConvPendingApprovals(id) discardConvPendingApprovals(id)
if (wasActive) { if (wasActive) {
void appSettings.remove('df-ai-active-conv') void appSettings.remove(activeConvKvKey())
// 删除活跃会话 → load_more 游标复位(无 active 视图) // 删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null loadMoreCursor.earliestSeq = null
+2 -2
View File
@@ -19,7 +19,7 @@ import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared' import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations' import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState' import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary' import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents' import { handleStreamingEvent } from './useAiStreamingEvents'
@@ -187,7 +187,7 @@ export function handleEvent(event: AiChatEvent) {
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化 // 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
if (convId && !state.activeConversationId) { if (convId && !state.activeConversationId) {
state.activeConversationId = convId state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId) void appSettings.set(activeConvKvKey(), convId)
} }
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理 // L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态) // (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
+7 -1
View File
@@ -14,7 +14,7 @@
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用, //! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
//! 打破 events↔send 循环依赖);本模块模块级监听该事件 //! 打破 events↔send 循环依赖);本模块模块级监听该事件
import { listen } from '@tauri-apps/api/event' import { listen, emit } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue' import { ref } from 'vue'
import { aiApi } from '@/api' import { aiApi } from '@/api'
@@ -537,6 +537,12 @@ async function stopChat() {
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。 // 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」; // 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。 // 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
// I8(2026-10-05):停止≠清队,用户预期常是「全部停下」——队列非空时一次性提示
// 「停止后 N 条排队消息仍将自动发送」,给用户明确预期(要全停可逐条取消/清队列)。
const pendingQueued = state.queue.filter(q => q.conversationId === state.activeConversationId).length
if (pendingQueued > 0) {
void emit('ai-toast', { msg: t('ai.queueWillContinue', { n: pendingQueued }), type: 'info' })
}
try { try {
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。 // F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
await aiApi.stopChat(state.activeConversationId) await aiApi.stopChat(state.activeConversationId)
+12 -3
View File
@@ -25,10 +25,19 @@ export function handleToolEvent(event: AiChatEvent): boolean {
} }
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡) // 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) { if (!findToolCall(event.id)) {
// #15(2026-10-05):末条非 assistant(如 system 占位/思考 pill/完成轨迹折叠重排)时
// 原直接丢弃工具卡 → running 卡消失但工具仍在执行。兜底向前找最近 assistant 落卡;
// 全程无 assistant(极端:首轮工具前无任何回复)才真丢弃(与原行为一致)。
const lastMsg = state.messages[state.messages.length - 1] const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') { let target = (lastMsg && lastMsg.role === 'assistant') ? lastMsg : null
lastMsg.toolCalls = lastMsg.toolCalls || [] if (!target) {
lastMsg.toolCalls.push(info) for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].role === 'assistant') { target = state.messages[i]; break }
}
}
if (target) {
target.toolCalls = target.toolCalls || []
target.toolCalls.push(info)
} }
} }
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) // 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
+52 -1
View File
@@ -12,6 +12,7 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
import { nextMsgId, getConvState, convStates } from './aiShared' import { nextMsgId, getConvState, convStates } from './aiShared'
import type { MessageId } from '@/api/types' import type { MessageId } from '@/api/types'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
@@ -58,6 +59,45 @@ export async function emitDetachedOpenConversation(convId: string): Promise<void
} }
} }
// ── V11b(2026-10-05)跨窗收回分离窗:reattachPanel 的 UI 入口 ──
// 病灶:reattachPanel(关闭全部分离窗+回主面板)全仓零 UI 调用——分离窗内「关闭」按钮走
// closeDetachedWindow(仅关窗不恢复主面板),用户想收回只能手动重开主面板。主/分离窗是
// 独立 JS context,分离窗按钮无法直调主窗 store;走 Tauri 全局事件广播(对齐 OPEN_CONV_EVENT
// 模式):分离窗 emit → 主窗(label 判定)监听 → 执行 reattachPanel。
const REATTACH_EVENT = 'ai-reattach-panel'
/** 发起方(分离窗)侧:广播收回事件,主窗口消费 */
export async function emitReattachToMain(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(REATTACH_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗收回事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册收回监听。handler=reattachPanel(由 AiChat 挂载时注入,归窗口域) */
let _reattachListenerRegistered = false
export function registerMainReattachListener(handler: () => Promise<void>): void {
if (_reattachListenerRegistered) return
_reattachListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (getCurrentWebviewWindow().label !== 'main') return
await listen<Record<string, never>>(REATTACH_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口收回监听注册失败:', e)
_reattachListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口, /** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。 * 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */ * handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
@@ -114,6 +154,12 @@ async function detachPanel(convId?: string) {
localStorage.setItem(textKey(targetConv), state.currentText) localStorage.setItem(textKey(targetConv), state.currentText)
} }
} }
// V11c: 种子分离窗活跃会话 KV——分离窗 AiChat 挂载同样跑 loadConversations(),
// 读按窗口域分流的 'df-ai-active-conv-detached'(独立 JS context 不与主窗共 pinia state),
// 不种子则分离窗启动回落列表首条而非主窗正看的会话。目标 conv 为空(无活跃会话)跳过。
if (targetConv) {
void useAppSettingsStore().set('df-ai-active-conv-detached', targetConv)
}
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
@@ -133,7 +179,10 @@ async function detachPanel(convId?: string) {
minHeight: 400, minHeight: 400,
center: true, center: true,
decorations: true, decorations: true,
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见) // V11a(2026-10-05):默认不置顶——原 true 会让分离窗盖住其他应用(VSCode/浏览器)妨碍工作;
// 用户开过置顶的偏好走 AiChat.vue 的 df-ai-always-on-top KV 恢复链(分离窗挂载时
// setAlwaysOnTop(saved)),默认改 false 不影响已开置顶用户,只改变首次/未开者的初始态。
alwaysOnTop: false,
}) })
win.once('tauri://created', () => { win.once('tauri://created', () => {
state.panelOpen = false state.panelOpen = false
@@ -183,6 +232,8 @@ async function reattachPanel() {
state.docked = false state.docked = false
state.panelOpen = true state.panelOpen = true
persistUiState() persistUiState()
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
void useAppSettingsStore().remove('df-ai-active-conv-detached')
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步, // 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。 // onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
stopListener() stopListener()
+20 -16
View File
@@ -15,7 +15,7 @@
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。 //! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue' import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
import { useMarkdown } from '../useMarkdown' import { useMarkdown, wrapNakedDiff } from '../useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings' import { useAppSettingsStore } from '../../stores/appSettings'
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key) /// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
@@ -122,7 +122,9 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
const purify = getPurify() const purify = getPurify()
if (!marked || !purify) return escapeFallback(block) if (!marked || !purify) return escapeFallback(block)
try { try {
return purify.sanitize(marked.parse(block) as string) // R8:流式块同源裸 diff 预处理(原仅完成态 renderMd 有),流式中裸 +/- 行即红绿代码块,
// 不再等完成态突变丢阅读锚点。守卫3(围栏内不动)保证与完成态 renderMd 幂等不双重包裹。
return purify.sanitize(marked.parse(wrapNakedDiff(block)) as string)
} catch { } catch {
return escapeFallback(block) return escapeFallback(block)
} }
@@ -150,7 +152,8 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为) // AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。 // mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) { if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中) // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)→ 三元组清零走统一入口(DRY)
resetIncrementalBaseline()
return [{ html: escapeFallback(text), key: 'fallback' }] return [{ html: escapeFallback(text), key: 'fallback' }]
} }
// 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。 // 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
@@ -159,13 +162,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let blocks: string[] let blocks: string[]
if (appendOnly) { if (appendOnly) {
const growth = text.slice(_prevStreamText.length) const growth = text.slice(_prevStreamText.length)
const combined = _prevTail + growth // splitBlocks 输出无论 1 块(旧末块未跨边界,新末块=旧末块+增长)还是 N 块
const combinedBlocks = splitBlocks(combined) //(旧末块跨边界拆出若干已完成块+新末块),语义均为「追加到已完成块序列之后」,
blocks = combinedBlocks.length <= 1 // 原 if/else 两分支 concat 结果恒等价 → 收敛单 concat(死分支清除,DRY)。
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长 blocks = _prevCompleted.concat(splitBlocks(_prevTail + growth))
? _prevCompleted.concat(combinedBlocks)
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
} else { } else {
blocks = splitBlocks(text) blocks = splitBlocks(text)
} }
@@ -240,6 +240,14 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
}) })
} }
/// 三元组增量基线清零(唯一写入口):纯文本降级路径/清流式状态/清块缓存三处共用。
/// 手抄三行式已废弃,清基线必须走本函数(DRY 2026-10-05)。
function resetIncrementalBaseline(): void {
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调) /// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。 /// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。
function clearStreamingState(): void { function clearStreamingState(): void {
@@ -247,9 +255,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
streamingBlocks.value = [] streamingBlocks.value = []
lastStreamText = '' lastStreamText = ''
// 清增量基线(防新轮/新会话误命中旧帧已完成块)。 // 清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = '' resetIncrementalBaseline()
_prevCompleted = []
_prevTail = ''
} }
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。 /// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -263,9 +269,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。 /// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void { function resetStreamCache(): void {
_blockCache.clear() _blockCache.clear()
_prevStreamText = '' resetIncrementalBaseline()
_prevCompleted = []
_prevTail = ''
} }
return { return {
+5 -1
View File
@@ -205,6 +205,10 @@ function renderMd(text: string): string {
* 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的 * 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的
* 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。 * 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。
* *
* R8(2026-10-05)改 export:流式渲染链(useStreamRenderer.renderStreamingBlocks)同源复用,
* 治「流式中裸 diff 渲染为普通段落,完成后突变红绿代码块」的阅读锚点丢失。
* 幂等安全:守卫3(围栏内不动)保证流式已包的块,完成态 renderMd 再处理不双重包裹。
*
* 守卫: * 守卫:
* 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。 * 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。
* 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。 * 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。
@@ -221,7 +225,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定) const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪) const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
function wrapNakedDiff(text: string): string { export function wrapNakedDiff(text: string): string {
if (!text) return text if (!text) return text
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段) // 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
if (!/[+-]/.test(text)) return text if (!/[+-]/.test(text)) return text
+5 -1
View File
@@ -27,7 +27,7 @@ export default {
aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…', aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…',
// Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead // Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.', notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.', streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). Click Retry to continue (reruns the whole turn; completed tools will execute again).',
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.', streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
// Light-convergence notice when the watchdog probe reports the backend already finished (only the closing event was lost) // Light-convergence notice when the watchdog probe reports the backend already finished (only the closing event was lost)
streamFinishedConverged: 'The response was interrupted during finalization and has been converged.', streamFinishedConverged: 'The response was interrupted during finalization and has been converged.',
@@ -45,6 +45,7 @@ export default {
queuedSendFailed: 'Failed to send queued message: {error}', queuedSendFailed: 'Failed to send queued message: {error}',
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent) // UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
queueCleared: 'Cleared {n} queued message(s)', queueCleared: 'Cleared {n} queued message(s)',
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}', approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) // conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.', loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
@@ -181,6 +182,9 @@ export default {
echoDirs: '{n} dirs', echoDirs: '{n} dirs',
echoBudget: 'budget {rounds} rounds / {tokens} tokens / {secs}s', echoBudget: 'budget {rounds} rounds / {tokens} tokens / {secs}s',
echoNoBudget: 'no budget limit', echoNoBudget: 'no budget limit',
// A12: budget validation field locator; A11: dirty-edit kept notice
budgetInvalidField: '{field} must be a positive integer',
dirtyKept: 'Unsaved edits kept (target conversation switched, verify before confirming)',
}, },
}, },
} }
+22 -2
View File
@@ -49,6 +49,19 @@ export default {
pinOnTop: 'Pin on top', pinOnTop: 'Pin on top',
unpin: 'Unpin', unpin: 'Unpin',
closeWindow: 'Close window', closeWindow: 'Close window',
// I9(2026-10-05) text/code file attachment
pickFileTitle: 'Select text/code files',
textFileFilter: 'Text/code files',
fileTooLargeTruncated: 'File too large, truncated on insert',
fileReadFailed: 'Failed to read file, skipped',
shortcutsTitle: 'Keyboard shortcuts',
scNewConv: 'New conversation',
scFocusInput: 'Focus message input',
scRegenerate: 'Regenerate last reply',
scClearChat: 'Clear current conversation',
scSearch: 'Focus conversation search',
scToggleSidebar: 'Toggle sidebar',
scCloseOverlay: 'Close dialog / panel',
// ── Approval overlay (global top-right) ── // ── Approval overlay (global top-right) ──
approvalWaitingTitle: 'Approval required', approvalWaitingTitle: 'Approval required',
@@ -163,9 +176,10 @@ export default {
copyAsImageFailed: 'Failed to copy as image', copyAsImageFailed: 'Failed to copy as image',
// ── Message actions bar ── // ── Message actions bar ──
regenerate: 'Regenerate', // regenerateUnavailable: Ctrl+R / retry paths still call store.regenerate(); precheck toast key
regenerateFailed: 'Regenerate failed: {msg}', // (I6 revert removed the UI button only — key still referenced by useAiSend.ts, review 2026-10-05 ②)
regenerateUnavailable: 'No reply to regenerate', regenerateUnavailable: 'No reply to regenerate',
regenerateFailed: 'Regenerate failed: {msg}',
// fetch_url "Install obscura via AI" button triggers sendMessage; failure toast (mirrors editMessageFailed) // fetch_url "Install obscura via AI" button triggers sendMessage; failure toast (mirrors editMessageFailed)
sendFailed: 'Send failed: {msg}', sendFailed: 'Send failed: {msg}',
@@ -237,6 +251,8 @@ export default {
compressing: 'Compressing…', compressing: 'Compressing…',
clearSuccess: 'Context cleared', clearSuccess: 'Context cleared',
compressSuccess: 'Context compressed', compressSuccess: 'Context compressed',
// B4: one-shot auto-compress notice (loop auto-compress no longer fully silent)
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
compressError: 'Compression failed: {msg}', compressError: 'Compression failed: {msg}',
clearError: 'Clear failed: {msg}', clearError: 'Clear failed: {msg}',
// Separator expand / collapse // Separator expand / collapse
@@ -279,6 +295,10 @@ export default {
tokenInLabel: 'Input (cache miss, full price)', tokenInLabel: 'Input (cache miss, full price)',
tokenCacheLabel: 'Cache hit (low price)', tokenCacheLabel: 'Cache hit (low price)',
tokenOutLabel: 'Output', tokenOutLabel: 'Output',
// #12 conversation-cumulative tokens (token detail popover footer)
tokenConvTotal: 'Conversation total',
tokenConvTotalIn: 'Total in',
tokenConvTotalOut: 'Total out',
tokenReasonLabel: 'Reasoning', tokenReasonLabel: 'Reasoning',
tokenCacheRateLabel: 'Cache hit rate', tokenCacheRateLabel: 'Cache hit rate',
tokenModelLabel: 'Model', tokenModelLabel: 'Model',
+6
View File
@@ -14,6 +14,10 @@ export default {
argsCollapse: 'Collapse args', argsCollapse: 'Collapse args',
// batch approve high-risk interception (count badge + confirm text) // batch approve high-risk interception (count badge + confirm text)
batchHighRiskHint: '{n} high-risk', batchHighRiskHint: '{n} high-risk',
// R7 group-header failure badge: folded group contains failed tool cards
groupFailCount: '{n} failed',
// #14 batch approve partial failure toast
batchPartialFail: 'Batch done: {ok} approved, {fail} failed.',
batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?', batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?',
rejectedHint: 'User rejected this action', rejectedHint: 'User rejected this action',
// tool execution slow hint (frontend fallback, no backend cancel IPC) // tool execution slow hint (frontend fallback, no backend cancel IPC)
@@ -30,6 +34,8 @@ export default {
// Singular expand/collapse (ToolCard single-card content, command output toggle) // Singular expand/collapse (ToolCard single-card content, command output toggle)
expand: 'Expand', expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
diffExpand: 'Expand all {n} lines',
diffCollapse: 'Collapse diff',
written: 'written', written: 'written',
items: 'items', items: 'items',
lines: 'lines', lines: 'lines',
+5 -1
View File
@@ -27,7 +27,7 @@ export default {
aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…', aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…',
// Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试 // Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
notRetryable: '此错误无法通过重试解决,请检查配置', notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。', streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点「重试」续跑(整轮重跑,已完成工具会重新执行)。',
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。', streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
// 看门狗探测到后端已正常收敛(仅收尾事件丢失)时补的轻收敛提示,非错误语气 // 看门狗探测到后端已正常收敛(仅收尾事件丢失)时补的轻收敛提示,非错误语气
streamFinishedConverged: '响应在收尾阶段中断,已自动完成收敛。', streamFinishedConverged: '响应在收尾阶段中断,已自动完成收敛。',
@@ -45,6 +45,7 @@ export default {
queuedSendFailed: '队列消息发送失败:{error}', queuedSendFailed: '队列消息发送失败:{error}',
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈) // UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
queueCleared: '已清 {n} 条排队消息', queueCleared: '已清 {n} 条排队消息',
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}', approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接', loadConvFail: '对话列表加载失败,请检查网络或后端连接',
@@ -176,6 +177,9 @@ export default {
echoDirs: '{n} 个目录', echoDirs: '{n} 个目录',
echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s', echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s',
echoNoBudget: '预算不限', echoNoBudget: '预算不限',
// A12: 预算校验错误定位字段;A11: 脏编辑保留提示
budgetInvalidField: '{field} 需为正整数',
dirtyKept: '有未提交的编辑已保留(目标会话已切换,确认前请核对)',
}, },
}, },
} }
+22 -2
View File
@@ -49,6 +49,19 @@ export default {
pinOnTop: '置顶', pinOnTop: '置顶',
unpin: '取消置顶', unpin: '取消置顶',
closeWindow: '关闭窗口', closeWindow: '关闭窗口',
// I9(2026-10-05)文本/代码文件附件
pickFileTitle: '选择文本/代码文件',
textFileFilter: '文本/代码文件',
fileTooLargeTruncated: '文件过大,已截断插入',
fileReadFailed: '文件读取失败,已跳过',
shortcutsTitle: '键盘快捷键',
scNewConv: '新建对话',
scFocusInput: '聚焦输入框',
scRegenerate: '重新生成回复',
scClearChat: '清空当前对话',
scSearch: '聚焦会话搜索',
scToggleSidebar: '展开/收起侧栏',
scCloseOverlay: '关闭弹窗/面板',
// ── 审批浮层(全局右上角)── // ── 审批浮层(全局右上角)──
approvalWaitingTitle: '有待审批请求', approvalWaitingTitle: '有待审批请求',
@@ -164,9 +177,10 @@ export default {
copyAsImageFailed: '复制为图片失败', copyAsImageFailed: '复制为图片失败',
// ── 消息操作栏── // ── 消息操作栏──
regenerate: '重新生成', // regenerateUnavailable:Ctrl+R / 重试链仍走 store.regenerate(),前置校验失败 toast 用
regenerateFailed: '重新生成失败:{msg}', // (I6 回滚只撤了 UI 按钮,此键仍被 useAiSend.ts 引用——review 2026-10-05 ②)
regenerateUnavailable: '没有可重新生成的回复', regenerateUnavailable: '没有可重新生成的回复',
regenerateFailed: '重新生成失败:{msg}',
// fetch_url "AI 帮装 obscura" 按钮触发 sendMessage 失败的 toast(对齐 editMessageFailed 模式) // fetch_url "AI 帮装 obscura" 按钮触发 sendMessage 失败的 toast(对齐 editMessageFailed 模式)
sendFailed: '发送失败:{msg}', sendFailed: '发送失败:{msg}',
@@ -238,6 +252,8 @@ export default {
compressing: '压缩中…', compressing: '压缩中…',
clearSuccess: '上下文已清空', clearSuccess: '上下文已清空',
compressSuccess: '上下文已压缩', compressSuccess: '上下文已压缩',
// B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默)
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
compressError: '压缩失败:{msg}', compressError: '压缩失败:{msg}',
clearError: '清空失败:{msg}', clearError: '清空失败:{msg}',
// 折叠条展开 / 收起 // 折叠条展开 / 收起
@@ -280,6 +296,10 @@ export default {
tokenInLabel: '输入(未命中,全价)', tokenInLabel: '输入(未命中,全价)',
tokenCacheLabel: '缓存命中(低价)', tokenCacheLabel: '缓存命中(低价)',
tokenOutLabel: '输出', tokenOutLabel: '输出',
// #12 会话累计 token(token 详情面板尾部段)
tokenConvTotal: '会话累计',
tokenConvTotalIn: '会话累计·输入',
tokenConvTotalOut: '会话累计·输出',
tokenReasonLabel: '思考(reasoning)', tokenReasonLabel: '思考(reasoning)',
tokenCacheRateLabel: '缓存命中率', tokenCacheRateLabel: '缓存命中率',
tokenModelLabel: '模型', tokenModelLabel: '模型',
+6
View File
@@ -14,6 +14,10 @@ export default {
argsCollapse: '收起参数', argsCollapse: '收起参数',
// 批量批准 High 风险拦截(计数徽标 + 二次确认文案) // 批量批准 High 风险拦截(计数徽标 + 二次确认文案)
batchHighRiskHint: '含 {n} 项高风险', batchHighRiskHint: '含 {n} 项高风险',
// R7 组头失败徽标:折叠组内含失败工具卡
groupFailCount: '{n} 失败',
// #14 批量审批部分失败提示
batchPartialFail: '批量完成:{ok} 项成功,{fail} 项失败',
batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?', batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?',
rejectedHint: '用户拒绝了此操作', rejectedHint: '用户拒绝了此操作',
// 工具执行超时提示(前端降级,无后端取消 IPC) // 工具执行超时提示(前端降级,无后端取消 IPC)
@@ -29,6 +33,8 @@ export default {
clickToCopy: '点击复制完整路径', clickToCopy: '点击复制完整路径',
collapse: '收起', collapse: '收起',
expand: '展开', expand: '展开',
diffExpand: '展开全部 {n} 行',
diffCollapse: '收起 diff',
written: '已写入', written: '已写入',
items: '项', items: '项',
lines: '行', lines: '行',
+9 -9
View File
@@ -12,7 +12,7 @@
<!-- 顶部拖动条(双击不最大化,因 decorations:false) --> <!-- 顶部拖动条(双击不最大化,因 decorations:false) -->
<div class="popup-header" data-tauri-drag-region @mousedown="onDragStart" @mouseup="onDragEnd"> <div class="popup-header" data-tauri-drag-region @mousedown="onDragStart" @mouseup="onDragEnd">
<div class="popup-header-icon"> <div class="popup-header-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" /> <path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" /> <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg> </svg>
@@ -20,7 +20,7 @@
</div> </div>
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div> <div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
<button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose"> <button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -71,7 +71,7 @@
@click.stop="onApproveOnce(tc)" @click.stop="onApproveOnce(tc)"
> >
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span> <span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span> {{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span>
</button> </button>
<button <button
@@ -79,7 +79,7 @@
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@click.stop="onApproveSession(tc)" @click.stop="onApproveSession(tc)"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }} {{ $t('aiChat.dirAuthSession') }}
</button> </button>
<button <button
@@ -87,7 +87,7 @@
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@click.stop="onApproveAlways(tc)" @click.stop="onApproveAlways(tc)"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span> {{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span>
</button> </button>
<button <button
@@ -95,7 +95,7 @@
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@click.stop="onReject(tc)" @click.stop="onReject(tc)"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span> {{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span>
</button> </button>
</div> </div>
@@ -106,7 +106,7 @@
@click.stop="onApprove(tc)" @click.stop="onApprove(tc)"
> >
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span> <span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span> {{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span>
</button> </button>
<button <button
@@ -114,7 +114,7 @@
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@click.stop="onReject(tc)" @click.stop="onReject(tc)"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span> {{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span>
</button> </button>
</div> </div>
@@ -123,7 +123,7 @@
<!-- 底部:打开审批面板(聚焦主窗口) --> <!-- 底部:打开审批面板(聚焦主窗口) -->
<button class="popup-footer" @click="onActivate"> <button class="popup-footer" @click="onActivate">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" /> <path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" />
</svg> </svg>
{{ $t('aiChat.popupOpenPanel') }} {{ $t('aiChat.popupOpenPanel') }}
+4 -4
View File
@@ -4,7 +4,7 @@
<h1>{{ t('auditLog.title') }}</h1> <h1>{{ t('auditLog.title') }}</h1>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'"> <button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'">
<svg v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> <svg aria-hidden="true" v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
{{ t('auditLog.refresh') }} {{ t('auditLog.refresh') }}
</button> </button>
</div> </div>
@@ -13,11 +13,11 @@
<!-- Tab 导航 --> <!-- Tab 导航 -->
<nav class="detail-tabs audit-tabs"> <nav class="detail-tabs audit-tabs">
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')"> <button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
{{ t('auditLog.tabAudit') }} {{ t('auditLog.tabAudit') }}
</button> </button>
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')"> <button class="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
{{ t('auditLog.tabConversations') }} {{ t('auditLog.tabConversations') }}
</button> </button>
</nav> </nav>
@@ -43,7 +43,7 @@
<!-- 状态条 --> <!-- 状态条 -->
<div v-if="loading" class="empty-state audit-loading"> <div v-if="loading" class="empty-state audit-loading">
<svg class="audit-spinner audit-spinner--lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> <svg aria-hidden="true" class="audit-spinner audit-spinner--lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
</div> </div>
<div v-else-if="errorMsg" class="empty-state audit-error">⚠ {{ errorMsg }}</div> <div v-else-if="errorMsg" class="empty-state audit-error">⚠ {{ errorMsg }}</div>
<div v-else-if="filteredRecords.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div> <div v-else-if="filteredRecords.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
+2 -2
View File
@@ -8,8 +8,8 @@
</div> </div>
<div class="dash-header-right"> <div class="dash-header-right">
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')"> <button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
<svg v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> <svg aria-hidden="true" v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> <svg aria-hidden="true" v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button> </button>
</div> </div>
</header> </header>
+3 -3
View File
@@ -130,7 +130,7 @@
<!-- 标题行操作入口 --> <!-- 标题行操作入口 -->
<div class="module-manager-header"> <div class="module-manager-header">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule"> <button class="btn btn-primary btn-sm" type="button" @click="openAddModule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ $t('fileExplorer.addModule') }} {{ $t('fileExplorer.addModule') }}
</button> </button>
<button <button
@@ -171,10 +171,10 @@
<span class="module-path-overview" :title="m.path">{{ m.path }}</span> <span class="module-path-overview" :title="m.path">{{ m.path }}</span>
<span class="module-row-actions"> <span class="module-row-actions">
<button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)"> <button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button> </button>
<button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)"> <button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
</span> </span>
</div> </div>