优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)

This commit is contained in:
2026-06-18 22:57:19 +08:00
parent 0ca5d9805f
commit a2871a66e0
87 changed files with 5720 additions and 3012 deletions

1
.gitignore vendored
View File

@@ -51,6 +51,7 @@ coverage/
# 损坏备份(FR-S7 write_file 事故残留)+临时文件 # 损坏备份(FR-S7 write_file 事故残留)+临时文件
*.broken.bak *.broken.bak
tmp/ tmp/
.temp/
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码) # AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
workflows/ workflows/

View File

@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接 # DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-06-15 | 当前阶段: 全量待办编排完成(Sprint 23) → 首批 A+B 清债ARC-02 死链已删 / AR-1 退役 / ARC-03 降级重评api_key 明文存储(FR-S1)降级延后 > 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量)2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
--- ---
@@ -12,7 +12,7 @@
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite | | 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
| 路径 | `E:/wk-lab/devflow/` | | 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) | | 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 未首次 commit代码全在 untracked | | Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop | | AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
--- ---
@@ -775,6 +775,242 @@
**遗留 / 下一步**: 等勘察结果派下一波实施AR-11 emit 点已查明audit.rs process_tool_calls 有 app_handle待本批提交后派F-260615-03 breaking change / F-260615-04 需定交互 待用户确认改动堆积git diff 100+ 文件)建议择机提交收口。 **遗留 / 下一步**: 等勘察结果派下一波实施AR-11 emit 点已查明audit.rs process_tool_calls 有 app_handle待本批提交后派F-260615-03 breaking change / F-260615-04 需定交互 待用户确认改动堆积git diff 100+ 文件)建议择机提交收口。
### 2026-06-18 03:08 — sweep-fix 6 域走查 + P3 小修Code-Working 会话·workflow wd2fnjh3s
**工作内容**
- workflow wd2fnjh3s 6 域并行走查rust-ai/dag/storage/ipc + fe-aichat/fe-other12.4 分钟·6 agent·651k tokens修 12 处确定性低风险 bug + 新发现 27 todo去重 24 入 todo.md
- 主代独立核查(审查防污染不信 agent 自报cargo check --workspace EXIT 0 + vue-tsc EXIT 0 + grep 抽查 scan/audit/commands 源码形态全正确;**拦截 1 瞬态误报**fe-other vue-tsc 抓 ToolCard.vue:653 cmdOutput TS6133 never read实为 fe-aichat 改 ToolCard 中间态被并发 vue-tsc 抓到,最终态模板已引用,全量 vue-tsc 复核 EXIT 0 确认非 bug
- 主代 P3 小修 5 处各步自测通过SW-18 AiChat wasNearBottom 切会话重置 / SW-21 TaskDetail wfResult timer 清理 / SW-23 Ideas neutral CSS / SW-15 ai_node schema required 对齐 / SW-24 dead_code 标注finish_reason/call_type 2 处 #[allow]cargo df-ai warning 消除)
- 文档todo.md 24 newTodos 去重追加 + F-260616-15 矛盾项销账 + 5 P3/SW 销账;待审查.md CR-260618-01 登记 17 处改动交其他会话审查
**代码变更**
- df-ai: context.rsparts token 预算F-05 回归)/ anthropic_compat.rsImage move/ openai_compat.rs2 处 #[allow(dead_code)]
- df-project: scan.rsto_ascii_lowercase 索引对齐 + is_monorepo null 判定)
- df-ideas: adversarial.rsMockProvider 补 reasoning_content
- src-tauri: audit.rsaudit_finalize 吞错分流 + PENDING_APPROVAL_PLACEHOLDER 常量)/ commands.rsai_conversation_delete retain
- df-nodes: ai_node.rsschema required=[] 对齐 execute
- 前端: ToolCard.vueisFailed/cmdOutput computed/ AiChat.vue虚拟滚动死注释清理 + wasNearBottom 重置)/ Projects.vue_toastTimer onUnmounted/ Knowledge.vueparseContext 单次)/ TaskDetail.vuewfResult timer 清理)/ Ideas.vueneutral CSS
**遗留问题**
- 1. **UX-2025-12 分页懒加载(高价值中风险)**:多角度论证暂缓——①必须 limit+滚顶两全limit 单独引入"看不到 >limit 旧消息"回归,之前全量可见)/ ②token 紧张 180 行 7 文件大工程易出错borrow/sentinel 时序) / ③长对话卡顿是渲染体验问题 cargo/vue-tsc 只验编译类型,滚动流畅度需用户实测 / ④工作区已累积 17 处 commit-policy-deferred 不提交再加 7 文件增合并冲突。留 todo 待充分资源/用户在场推进。
- 2. SW-260618-01/02 两个 P1executor Ok 路径取消事件不一致 / 审批占位 tool_result 5 处 .clear() 未终态化):均行为变更需确认设计意图,记 todo 未改。
- 3. sweep P2 多为重构/防御/改进AuditLog i18n / ai_node DRY / reqwest Client DRY / 双 watch 合并 / Anthropic url 防御),记 todo 待攒批。
- 4. 待审查 CR-260618-0117 处)待审查会话独立 grep 核验。
**下一步**
- 1. UX-2025-12 分页(用户在场体验验证 或 新会话充分资源推进)
- 2. SW-01/02 P1 确认设计意图后修
- 3. 待审查 CR-260618-01 交审查会话
- 4. 工作区累积 17 处commit-policy-deferred 不提交,等架构级重构建基线)
### 2026-06-18 03:26 — SW-10 reqwest Client 构建 DRYCode-Working 会话续推)
**工作内容**
- sweep P2 SW-260618-10openai_compat/anthropic_compat Provider::new reqwest Client 构建connect_timeout 30s + 回退 Client::new + warn7 行逐字重复,抽 `lib.rs::build_provider_client()` pub(crate) fn 收敛 DRY两处调 `crate::build_provider_client()`
- 主代自测审查防污染独立核验cargo check -p df-ai EXIT 0 无 warning / cargo test -p df-ai 112 passed 0 failed不回归+ grep 核验 Client/warn/Duration import 替换后仍多处用Client 用于 struct 字段 / warn openai:503 + anthropic 7 处 / Duration 用于 :484/:481 timeout无 unused warning
**代码变更**
- crates/df-ai/src/lib.rs+14 build_provider_client pub(crate) fn/ openai_compat.rs-8+2/ anthropic_compat.rs-6+2
**遗留问题**
- 1. sweep 剩余低风险项 ROI 递减SW-06/07 双 watch 合并需谨慎 immediate 语义边界 / SW-19 combineAndTruncateLines DRY 价值低(结构特殊带 total/truncated/ SW-04 knowledge_events query 无活跃调用方(防御性)/ SW-09 ai_node DRY 架构级 / SW-16 审计 status 透传需查审计表中复杂度find_cached 返回 Option<String> 仅 content来源 status 在 ai_tool_executions 表非 ChatMessage暂缓
- 2. 待审查 CR-260618-03SW-10 单项)+ CR-260618-01/02 交审查会话独立 grep 核验
**下一步**
- 1. SW-01/02 P1 待确认设计意图executor Ok 路径取消事件不一致 / 审批占位 5 处 .clear() 未终态化)
- 2. 工作区累积 18 处commit-policy-deferred 不提交,等架构级重构建基线)
- 3. 待用户回归决策 UX-2025-12 分页 / SW P1 设计意图
### 2026-06-18 03:39 — SW-16 审计 status 透传 + SW-06 ToolCard watch 合并Code-Working 会话续推)
**工作内容**
- SW-260618-16P3 审计精度find_cached_high_risk_result 加 audit_repo 参数 + async + 返回 (content,status),命中旧 tool_result 后查 AiToolExecutionRepo::find_by_tool_call_id**已存在** crud.rs:1256复用零新增拿缓存来源真实 statuscompleted/rejected/failed透传 audit_tool_call 替代固定 "completed"。审计缺失/查询失败 fallback completed 不阻塞去重。
- SW-260618-06P2 DRYToolCard 双 watch(props.tc.status) 合并。放原 watch2 位置approving/approvingTimer/cmdOutputExpanded 三 ref 已定义避 TDZ+ immediate原 watch1 语义callback 内 cmdOutputExpanded + approving 复位合并。
- 主代自测cargo check --workspace EXIT 0仅 4 pre-existing dead_code 基线)/ cargo test -p devflow 92 passed 0 failed / vue-tsc EXIT 0 + grep 独立核验源码形态
**代码变更**
- src-tauri/src/commands/ai/audit.rsfind_cached 签名+返回+status 查询 / process_tool_calls :625 调用点 .await 拆 status / :639 &status 透传)
- src/components/ToolCard.vue删原 watch1 + watch2 替合并 watch
**遗留问题**
- sweep 剩余可自主SW-07 AiChat 双 watch 合并(同 SW-06 模式可复用)/ SW-17 ToolCardList Set 重置(低价值内存慢增长)/ SW-19/20 DRY低价值/ SW-05/11/12/13/14防御/确认意图/权衡,需勘察或确认)
- 高价值待用户B-260618-03 路由解耦 cost/intel用户已决策主链大改需在场/ SW-01·02 P1行为变更需确认设计意图/ UX-2025-12 分页(需体验实测)
**下一步**
- 继续 sweep 可自主项SW-07 watch 合并模式复用 SW-06/ 工作区累积 20 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测推进
### 2026-06-18 03:45 — SW-07 watch 合并 + B-03 路由解耦勘察 + SW-17 评估Code-Working 会话续推)
**工作内容**
- SW-260618-07P2 DRYAiChat 双 watch(currentText) 合并。删原 :2040 watch(currentText→onContentChange) 并入 :2047 watch(currentText→scheduleStreamParse) 单 callback保持原注册顺序onContentChange 滚动先 → scheduleStreamParse rAF 分块后。vue-tsc EXIT 0。
- B-260618-03 路由解耦 cost/intel 勘察完成9 调用点project×2/compress/knowledge_inject×2/agentic/title/ai_node/adversarial+ router 核心TaskRequirements 字段 + 步骤4/5 filter + 步骤7 Reverse(cost_tier)+ 多 test。**自主裁决:记待决策作专项**(用户已全局决策去掉 cost/intel但工程大 ~30 Edit + 主链路由行为变更需 cargo test router 全验证 + 在场核对 title/compress 选模型退化,本轮 token 不够完整推进,半途中断风险 > 暂缓)。附完整实施清单供专项续。
- SW-260618-17 评估:**不做**。切会话清 4 Set 会破坏 UX-260616-03 记忆态设计userExpandedCards/userCollapsedCards 跨会话保留是 feature——切回 A 恢复展开记忆「内存慢增长」是记忆保留代价可忽略tc.id 全局唯一无错配,描述自承认)。
**代码变更**
- src/components/AiChat.vue删 :2040 watch + :2047 合并 callback
**遗留问题**
- B-260618-03 待决策.md专项推进附实施清单
- SW-17 评估不做(破坏 UX-260616-03 记忆态)
- sweep 剩余SW-19combineAndTruncateLines DRY 低价值可做)/ SW-20parseTags DRY 签名变更中复杂度)/ SW-05/11/12/13/14防御/确认/权衡)/ SW-08AuditLog i18n 重构级)/ SW-09ai_node DRY 架构级)
**下一步**
- 推进 SW-19纯 DRY 低风险)/ 工作区累积 21 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:12 — SW-19/20 DRY + sweep 评估销账批次Code-Working 会话续推)
**工作内容**
- SW-260618-19P3 DRYToolCard combineOutputs 抽函数combineAndTruncateLines + cmdOutput computed 复用。vue-tsc EXIT 0。
- SW-260618-20P3 DRYIdeas 删本地 parseTags(IdeaRecord)import stores/knowledge.ts parseTags3 调用点传 .tags。vue-tsc EXIT 0。
- sweep 评估销账 6 项SW-05占位内容匹配·PENDING_APPROVAL_PLACEHOLDER 集中常量有效暂缓)/ SW-11OpenAI 图片构造·当前不可达暂缓)/ SW-12pinned/archived 不对称·设计合理确认)/ SW-13find_path_conflict 规范化·文档+测试守护够暂缓)/ SW-14keyring current_dir·权衡已承认不做/ SW-22Knowledge 模板重复求值·影响可忽略暂缓)。
- SW-260618-03 评估暂缓url 模式当前不可达(前端 Phase2b 只产 base64+ anthropic_compat 已 warn加 commands 层校验需追 parts 全链路IPC→ContextManager→provider定校验点有歧义 + 误拒合法 url 风险,待 url→base64 预拉落地后变可达再加。
**代码变更**
- src/components/ToolCard.vuecombineOutputs 抽函数 :463 + :472/:652 调用)
- src/views/Ideas.vueparseTags import :214 + 3 调用点传 .tags
**遗留问题**
- sweep 可自主低风险项已全扫净SW-19/20 已修 + 6 项评估销账 + SW-03 评估暂缓)
- 剩余候选SW-08AuditLog i18n 重构级 15+ key 可自主中工程)/ SW-01·02 P1行为变更需确认设计意图/ SW-04knowledge_events query 无调用方暂缓)/ SW-09ai_node DRY 架构级)/ B-260618-03路由解耦待决策专项
**下一步**
- 勘察 SW-08 AuditLog i18n 可行性 / 工作区累积 23 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:16 — SW-08 AuditLog.vue i18n 化Code-Working 会话续推)
**工作内容**
- SW-260618-08P2 重构AuditLog.vue 全硬编码中文 i18n 化。新建 auditLog 命名空间en/zh-CNtitle/refresh/desc/loading/empty + col×7 + pager×4 + risk×3 + status×6 + decided×2AuditLog.vue 引入 useI18n + template 15 处文本 t() 化 + riskLabel/statusLabel/decidedLabel 改 t(`auditLog.xxx.${val}`)。class 映射保留不国际化。模式对齐 Dashboard/Knowledge/Ideas。vue-tsc EXIT 0。
**代码变更**
- src/i18n/en/auditLog.ts + src/i18n/zh-CN/auditLog.ts新建命名空间
- src/views/AuditLog.vueuseI18n + template t() + label 函数 t()
**遗留问题**
- sweep 可自主项已全扫净SW-19/20 DRY + SW-08 i18n 重构均完成 + 7 项评估销账)
- 剩余候选均非纯自主SW-01·02 P1行为变更需确认设计意图/ SW-04knowledge_events query 无调用方暂缓)/ SW-09ai_node DRY 架构级)/ B-260618-03路由解耦待决策专项
**下一步**
- 勘察 SW-09 ai_node DRY 可行性 / 工作区累积 26 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:32 — SW-09 ai_node provider 三件套 DRYCode-Working 会话续推)
**工作内容**
- SW-260618-09P2 架构级 DRYAiNode/AiSelfReviewNode execute provider 三件套逐字重复消除。抽 2 helperresolve_and_parse合并 resolve_provider+parse_params+ provider_from_params封装 build_provider 5 行。AiNode 9 行→2 行SelfReview parse+build→2 调用(中间 task 读保留p 生命周期正确。messages/temperature 差异不抽各节点自有。cargo check df-nodes EXIT 0 / cargo test 82 passed 0 failed。
**代码变更**
- crates/df-nodes/src/ai_node.rs2 helper 新增 + 2 节点 execute 改)
**遗留问题**
- sweep 全域可自主项已尽SW-19/20 DRY + SW-08 i18n 重构 + SW-09 架构级 DRY 均完成 + 7 项评估销账 SW-03/05/11/12/13/14/22
- 剩余候选均需用户介入SW-01·02 P1行为变更需确认设计意图/ SW-04knowledge_events query 无调用方暂缓)/ B-260618-03路由解耦待决策专项
**下一步**
- 可自主项已扫净,状态报告待用户 / 工作区累积 27 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 04:35 — UX-260618-04 前端删 cost/intel 标签Code-Working 会话续推)
**工作内容**
- UX-260618-04P2前端删 cost/intel 瞎猜档位标签(落地用户「去掉 cost/intel」决策。自主裁决全删用户决策明确 + 独立于后端 B-03 路由 + grep 确认仅 2 组件引用 + 回退易。ProviderPanel 删 tag-cost/tag-intel spanAiChat modelOptionLabel 删 intel/cost 拼接i18n en/zh settings.ts 删 tagCost/tagIntel + 注释更新。vue-tsc EXIT 0 / grep 无残留。
**代码变更**
- src/components/settings/ProviderPanel.vue + src/components/AiChat.vue
- src/i18n/en/settings.ts + src/i18n/zh-CN/settings.ts
**遗留问题**
- ModelConfig.cost_tier/.intelligence 字段保留(后端 B-260618-03 解耦后清)
- sweep + UX 可自主项已尽剩余均需用户SW-01·02 P1行为变更/ SW-04无调用方暂缓/ F-260616-09架构级立项/ B-260618-03待决策专项
**下一步**
- 可自主项已扫净,状态报告待用户 / 工作区累积 31 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
### 2026-06-18 06:50 — workflow 6项编码批·router解耦+model_probe+executor TOCTOU+审批占位+knowledge_events+CostTier(workflow wexu1isx1·6 agent·主代核查)
**工作内容**
- 用户回归复盘彻夜低产(串行手改+过度保守+心跳空转 1.5h),改 workflow 编排提速
- workflow wexu1isx1 6 agent(10.7 分钟·294k tokens):B-03 router 解耦 cost/intel / B-04 model_probe 去瞎填 / SW-01 executor TOCTOU / SW-02 审批占位终态化 / SW-04 knowledge_events query / B-05 CostTier Free 清理
- 主代审查防污染核查:cargo check --workspace EXIT 0(5 pre-existing warning) + cargo test df-ai 109/df-ai-core 24/df-workflow 23/df-storage 全 passed + vue-tsc EXIT 0
- **主代修 agent E0502 借用冲突**:SW-02 helper finalize_pending_placeholders 签名(&mut ContextManager, &HashMap, &str) 函数参数列表 disjoint borrow 冲突(agent riskNote 预判"应无问题"错误)→ 改 &mut AiSession 整体(函数体内 disjoint borrow 合法)+ 调用点 &mut *session(DerefMut 解 guard)。印证:agent 不跑 cargo 的 riskNote 是猜测,主代 cargo 必验(同 Sprint 22 教训)
- 主代补 B-05 前端同步:types.ts CostTier 删 'free' 对齐 Rust(防类型不一致 + DB "free" 反序列化边界)
**代码变更**
- df-ai: router.rs(struct 删 min_intelligence/max_cost + select 删步骤4/5 + max_by_key 纯 weight 删 Reverse + test 改写) / model_probe.rs(heuristic_infer 中性默认 Medium/Standard) / presets/models.json(17 条删 cost_tier/intelligence)
- df-ai-core: model.rs(CostTier 删 Free + 注释 + test 同步)
- df-workflow: executor.rs(Ok 分支 emit NodeCompleted 移入 !is_cancelled + 取消 emit NodeCancelled 对齐 Err)
- df-storage: crud.rs(KnowledgeEventsRepo::list_recent ORDER BY timestamp 兜底)
- src-tauri: commands/ai/commands.rs(finalize_pending_placeholders helper + 5 处 .clear() 前终态化,主代修借用) + title/compress/knowledge_inject/agentic/project.rs(删 TaskRequirements min_intelligence/max_cost 传参 + import 清理)
- df-nodes: ai_node.rs / df-ideas: adversarial.rs(同删传参) / 前端: api/types.ts(CostTier 删 'free')
**遗留问题**
- 1. B-03 行为变更:title/compress 纯 weight 选模型,同 weight 并列 max_by_key 返回最后一个(rust Iterator 语义),需用户核对 Settings weight 配置(用户已全局决策接受)
- 2. SW-02 另 2 处 pending_approvals.retain(switch/delete)未改:语义不同(switch DB 占位残留需另案修持久层;delete 非活跃对话占位不在 session.messages 内存)。agent riskNote 标注建议另开任务
**下一步**
- 工作区累积 37 处 commit-policy-deferred(等架构级重构建基线)
- 待审查 CR-260618-10 交审查会话独立 grep 核验
### 2026-06-18 — workflow CR-260618-11 审查回流 5 项低风险编码(workflow w999qdu86·3 agent·主代核查)
**工作内容**
- workflow w999qdu86 3 agent(1.7 分钟·83k tokens):BUG-260618-11 ai_approve 吞错修(P0) + CR-11#2 context.rs chars_ratio 注释 + CR-11#3 types.ts IntelligenceTier 注释 + CR-11#4 system role 完备性 grep 核验 + CR-11#5 audit.rs find_cached 性能注记
- 文件域零冲突(src-tauri/commands/ai · df-ai/context.rs · 前端 types.ts),agent 不跑 cargo 主代统一验证
- 主代审查防污染核查:cargo check --workspace EXIT 0(4 pre-existing dead_code warning)+ vue-tsc EXIT 0 + 独立 grep 印证 agent selfCheck 无污染
**代码变更**
- src-tauri/commands/ai/commands.rs(:289-303 ai_approve None 分支 match 三路分流对齐 audit_finalize,原 unwrap_or_default 消)
- src-tauri/commands/ai/audit.rs(:402 find_cached_high_risk_result 性能注记)
- crates/df-ai/src/context.rs(:56 chars_ratio 0.35 注释,值未变)
- src/api/types.ts(:209 IntelligenceTier 注释)
**遗留问题**
- 1. BUG-11 行为变更:DB 故障现返明确错误(原误为「未找到挂起」),同属 Err 分支语义不变
- 2. CR-11#2 chars_ratio 降值(0.10~0.15)属行为变更影响裁剪边界,本次仅标注未做
- 3. CR-11#5 find_cached 批量预取属架构改签名,本次仅加注记未做
**下一步**
- 剩余 11 项需用户/架构级定夺(F-09 多会话/T-01·02 实测/S-01·02 澄清/ARC-07 排期/UX-12 二选一/B-260617-11·17 意图+运行时),workflow 推不动,交用户
### 2026-06-18 — workflow ToolCard.vue 工具结果渲染 8 项(workflow w2drz3ppo·8 agent 并行核验+主代统筹实施·commit-policy-deferred)
**工作内容**
- workflow w2drz3ppo 8 agent(5.1 分钟·301k tokens·132 tool_uses):并行核验 8 工具(run_workflow/patch_file/delete_file/read_file/advance_task/list_trash/list_directory/list_*)后端 tool_registry.rs 返回 vs 前端 ToolCard.vue 渲染,8/8 isReal=true,每份产出精确 old/new 实施清单 + selfCheck grep 证据
- 主代统筹实施(单文件域 ToolCard.vue 不能并行改):合并冲突(UX-10 被 UX-12 吸收)+ 修死代码位置(write_file 改模板分支非 formatToolResult case)+ type 13 字段去重合并 + 补 task_id 漏列
- 主代审查防污染核查:vue-tsc --noEmit EXIT 0 + 独立 grep 印证 ToolCard.vue 22 处新字段 + i18n 18 处新 key 全落地
**代码变更**
- src/components/ToolCard.vue:ToolResult type 补 13 字段(task_id/execution_id/target_status/note/diff/matches_found/backup_path/returned_lines/items/modified/old_size/encoding/cross_volume)+ 模板 read_file has_more 截断 / list_directory truncated / write_file old_size·encoding / 新增 patch_file diff 分支 + resultDiffLines computed + formatToolResult switch(run_workflow/list_*/advance_task/delete_file/rename_file)+ toolResultSummary switch(run_workflow/list_*重写修误判+list_trash/advance_task/delete_file/rename_file)+ formatFileInfoResult modified
- src/i18n/{en,zh-CN}/aiTool.ts:加 workflowTriggered/Short/patchedMatches/bytesUnit/linesTruncated/dirTruncated/advancedTaskWithRounds/trashCount/listHasMore/renamedWithBytes/CrossVolume/writeOverwrite/encodingLabel/fileInfoModified + 改 deleteFileSoft 加 backup_path
**遗留问题**
- 1. list_directory/list_* 截断文案硬编码 {n:1000}(对齐后端 tool_registry.rs:880 max_entries=1000),后端变更需同步前端
- 2. list_directory 的 ai-tool-file-meta--warn CSS class 未定义(功能 v-if 生效,样式可后续补)
- 3. 本批 vue-tsc 类型层验证通过,运行时渲染待 T-260614-01 tauri dev 实测批
**下一步**
- 待审查 CR-260618-12 交审查会话独立 grep 核验
- 工作区累积 45 处 commit-policy-deferred(等架构级重构建基线)
- 剩余需用户项同 CR-11 段(11 项 workflow 推不动)
### 2026-06-18 — workflow SMELL-P0-1 吞错降级加 warn + B-260618-06 表格 CSS(workflow w5siwnipj·6 agent 并行核验+主代统筹实施·commit-policy-deferred)
**工作内容**
- workflow w5siwnipj 6 agent(135s·190k tokens·62 tool_uses):并行核验 SMELL-P0-1 吞错 5 高危(agentic/knowledge_inject×3/idea/audit)+ B-260618-06 表格 CSS + coordinator 空壳,产出 currentCode/callers/verdict/fixType/proposedFix/behaviorChange/risk/selfCheck 结构化清单
- 主代统筹裁决:4 项实施(降级加 warn 类无行为变更 + CSS 确定性)+ 2 项 no-action(audit 误报 CR-11 已修 / coordinator 已标注保留)
- 主代审查防污染核查:独立 Read ai-md.css/knowledge_inject.rs/idea.rs 印证 agent selfCheck 6/6 准确,cargo check -p devflow EXIT 0 + vue-tsc EXIT 0
**代码变更**
- B-260618-06:src/styles/ai-md.css:59 移除 display:block/overflow-x:auto(width:100%→max-width:100%)+ src/components/AiChat.vue:3366 移除 display:block/width:max-content/overflow-x:auto,table 留 max-width:100%,气泡加 .ai-msg-bubble--ai.ai-md { overflow-x:auto }(CSS spec table-display 下 overflow 须由 block 父容器承担)
- SMELL-P0-1:agentic.rs:348(provider 池)+ knowledge_inject.rs:130,139,312(检索/消息解析)+ idea.rs:292-296(tags 解析)unwrap_or_default → match + tracing::warn 降级,Err 返空 Vec 走原降级路径零行为变更
**遗留问题**
- 1. SMELL-P0-1 audit.rs:344 误报(audit_finalize CR-11 已修三路 match),todo line 漂移+旧版本,workflow 独立核验拦截
- 2. 全局 ai-md.css 移除 display:block 后其他引用页(ProjectDetail/Ideas/Knowledge/TaskDetail)宽表格滚动需各自容器,当前仅 AiChat 气泡补 overflow,其他页 markdown 少宽表可接受
- 3. SMELL-P0-1 58 处全量降 P2(5 高危已清,余低危非紧急)
**下一步**
- 待审查 CR-260618-13 交审查会话独立 grep 核验
- 工作区累积 commit-policy-deferred 项 +5(等架构级基线)
- 剩余需用户项同前(11 项 workflow 推不动)
<!-- <!--
### [Sprint N] YYYY-MM-DD — 简述 ### [Sprint N] YYYY-MM-DD — 简述

View File

@@ -43,12 +43,16 @@ pub enum Capability {
/// 维度 3 — 价格分级(成本) /// 维度 3 — 价格分级(成本)
/// ///
/// 设计文档 §2.2:Free/Low/Medium/High。序列化为 snake_case。 /// 序列化为 snake_case。原设计文档 §2.2Free/Low/Medium/High,但 Free 变体形同虚设:
/// 路由器按此做成本上限过滤(§6.1 `cost_tier <= max_cost`)。 /// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// B-260618-05 删除 Free 死档变体。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CostTier { pub enum CostTier {
Free,
Low, Low,
Medium, Medium,
High, High,
@@ -57,7 +61,11 @@ pub enum CostTier {
/// 维度 4 — 聪明程度(智力分级) /// 维度 4 — 聪明程度(智力分级)
/// ///
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。 /// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra,供路由器 `intelligence >= min_intelligence` 比较(§6.1) /// 派生 Ord:Lite < Standard < Plus < Ultra。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum IntelligenceTier { pub enum IntelligenceTier {
@@ -285,7 +293,6 @@ mod tests {
#[test] #[test]
fn cost_tier_serde_and_ordering() { fn cost_tier_serde_and_ordering() {
let cases = [ let cases = [
(CostTier::Free, "\"free\""),
(CostTier::Low, "\"low\""), (CostTier::Low, "\"low\""),
(CostTier::Medium, "\"medium\""), (CostTier::Medium, "\"medium\""),
(CostTier::High, "\"high\""), (CostTier::High, "\"high\""),
@@ -294,8 +301,8 @@ mod tests {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected); assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant); assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
} }
// Ord:Free < Low < Medium < High(路由器 max_cost 比较) // Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
assert!(CostTier::Free < CostTier::Low); // Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium); assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High); assert!(CostTier::Medium < CostTier::High);
} }
@@ -312,7 +319,7 @@ mod tests {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected); assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant); assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
} }
// Ord:Lite < Standard < Plus < Ultra(路由器 min_intelligence 比较) // Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard); assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus); assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra); assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);

View File

@@ -121,29 +121,41 @@ pub struct ChatMessage {
/// DeepSeek thinking 模式的推理内容(多轮需回传) /// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
}
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
fn now_millis_i64() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
} }
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None } Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None } Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息content 文本 + parts含 Image 片)。 /// 多模态 user 消息content 文本 + parts含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -462,6 +474,7 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: None, reasoning_content: None,
timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
assert!(m.parts.is_none()); assert!(m.parts.is_none());
@@ -512,6 +525,7 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: Some("thinking process".to_string()), reasoning_content: Some("thinking process".to_string()),
timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("reasoning_content")); assert!(json.contains("reasoning_content"));

View File

@@ -4,8 +4,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 60, "weight": 60,
"context_window": 128000 "context_window": 128000
}, },
@@ -14,8 +12,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80, "weight": 80,
"context_window": 128000 "context_window": 128000
}, },
@@ -24,8 +20,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 90, "weight": 90,
"context_window": 128000 "context_window": 128000
}, },
@@ -34,8 +28,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 50, "weight": 50,
"context_window": 128000 "context_window": 128000
}, },
@@ -44,8 +36,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 70, "weight": 70,
"context_window": 128000 "context_window": 128000
}, },
@@ -54,8 +44,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85, "weight": 85,
"context_window": 128000 "context_window": 128000
}, },
@@ -64,8 +52,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50, "weight": 50,
"context_window": 128000 "context_window": 128000
}, },
@@ -74,8 +60,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80, "weight": 80,
"context_window": 128000 "context_window": 128000
}, },
@@ -84,8 +68,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50, "weight": 50,
"context_window": 200000 "context_window": 200000
}, },
@@ -94,8 +76,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 45, "weight": 45,
"context_window": 200000 "context_window": 200000
}, },
@@ -104,8 +84,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80, "weight": 80,
"context_window": 200000 "context_window": 200000
}, },
@@ -114,8 +92,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 55, "weight": 55,
"context_window": 1000000 "context_window": 1000000
}, },
@@ -124,8 +100,6 @@
"enabled": true, "enabled": true,
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85, "weight": 85,
"context_window": 1000000 "context_window": 1000000
}, },
@@ -134,8 +108,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 75, "weight": 75,
"context_window": 64000 "context_window": 64000
}, },
@@ -144,8 +116,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use", "code_gen"], "capabilities": ["tool_use", "code_gen"],
"cost_tier": "low",
"intelligence": "plus",
"weight": 75, "weight": 75,
"context_window": 64000 "context_window": 64000
}, },
@@ -154,8 +124,6 @@
"enabled": true, "enabled": true,
"modalities": ["text"], "modalities": ["text"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "standard",
"weight": 65, "weight": 65,
"context_window": 128000 "context_window": 128000
}, },
@@ -164,8 +132,6 @@
"enabled": true, "enabled": true,
"modalities": [], "modalities": [],
"capabilities": ["embedding"], "capabilities": ["embedding"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 50, "weight": 50,
"context_window": 8192 "context_window": 8192
} }

View File

@@ -177,3 +177,84 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
"required": required, "required": required,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
fn dummy_handler() -> AiToolHandler {
Box::new(|_args: Value| {
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
})
}
#[test]
fn risk_level_serde_lowercase() {
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
assert_eq!(
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
RiskLevel::High
);
}
#[test]
fn register_and_get_tool() {
let mut reg = AiToolRegistry::new();
assert!(reg.is_empty());
reg.register(
"list_tasks",
"列出任务",
object_schema(vec![]),
RiskLevel::Low,
dummy_handler(),
);
assert!(!reg.is_empty());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn register_same_name_overwrites() {
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
let mut reg = AiToolRegistry::new();
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
}
#[test]
fn tool_definitions_and_names() {
let mut reg = AiToolRegistry::new();
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
assert_eq!(reg.tool_definitions().len(), 2);
let names = reg.tool_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
}
#[test]
fn default_is_empty() {
let reg = AiToolRegistry::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn object_schema_collects_required() {
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["name"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "name");
}
}

View File

@@ -58,10 +58,12 @@ struct AnthropicToolDef {
/// Anthropic 同步响应 /// Anthropic 同步响应
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AnthropicResponse { struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
id: String, id: String,
model: String, model: String,
content: Vec<AnthropicContentBlock>, content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
stop_reason: Option<String>, stop_reason: Option<String>,
usage: AnthropicUsage, usage: AnthropicUsage,
@@ -246,13 +248,8 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥Anthropic 用 x-api-key 头,非 Bearer /// - `api_key`: API 密钥Anthropic 用 x-api-key 头,非 Bearer
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
let client = Client::builder() // SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 OpenAI 共用 DRY
.connect_timeout(std::time::Duration::from_secs(30)) let client = crate::build_provider_client();
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self { Self {
client, client,
api_key: api_key.into(), api_key: api_key.into(),
@@ -354,8 +351,9 @@ impl AnthropicCompatProvider {
"text": text, "text": text,
}), }),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => { crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.clone().unwrap_or_else(|| "image/png".into()); // p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let data = base64.clone().unwrap_or_else(|| { let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。 // 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉, // 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。 // 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
@@ -391,8 +389,11 @@ impl AnthropicCompatProvider {
} }
if let Some(calls) = &m.tool_calls { if let Some(calls) = &m.tool_calls {
for tc in calls { for tc in calls {
let input: serde_json::Value = // B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null); // → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(serde_json::json!({ content.push(serde_json::json!({
"type": "tool_use", "type": "tool_use",
"id": tc.id, "id": tc.id,
@@ -415,7 +416,13 @@ impl AnthropicCompatProvider {
.map(|d| AnthropicToolDef { .map(|d| AnthropicToolDef {
name: d.function.name, name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()), description: Some(d.function.description).filter(|s| !s.is_empty()),
input_schema: d.function.parameters, // B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
} else {
serde_json::json!({"type": "object"})
},
}) })
.collect() .collect()
}); });
@@ -478,10 +485,22 @@ impl LlmProvider for AnthropicCompatProvider {
.header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-version", ANTHROPIC_VERSION)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(Duration::from_secs(60)) .timeout(Duration::from_secs(60))
.version(reqwest::Version::HTTP_11)
.json(&body); .json(&body);
let resp = match rb.send().await { let resp = match rb.send().await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = ?e.url(),
source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败"
);
if is_reqwest_error_retryable(&e) { if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
} }
@@ -558,11 +577,29 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用"); debug!(model = %body.model, "Anthropic 流式调用");
let resp = self let resp = match self
.auth_headers(self.client.post(self.messages_url())) .auth_headers(self.client.post(self.messages_url()))
.json(&body) .json(&body)
.version(reqwest::Version::HTTP_11)
.send() .send()
.await?; .await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!("Anthropic 流式 send 失败: {}", e);
}
};
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();

View File

@@ -41,8 +41,31 @@ impl Default for TokenEstimator {
impl TokenEstimator { impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计) /// 估算单条消息的 token 数(保守估计)
///
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let content_tokens = (msg.content.chars().count() as f32 * self.chars_ratio).ceil() as u32; let mut char_count = msg.content.chars().count();
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Image { base64, url, .. } => {
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先多模态主载荷url 次之url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 {
char_count += b.chars().count();
} else if let Some(u) = url {
char_count += u.chars().count();
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead; let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销role=Assistant 时可能有) // tool_calls 的 JSON 结构开销role=Assistant 时可能有)
@@ -366,58 +389,93 @@ impl ContextManager {
} }
} }
if orphaned_ids.is_empty() && partial_keep.is_empty() { let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
return messages; messages
} } else {
// step 3保序过滤 + 部分闭合头重写 tool_calls
// step 3保序过滤 + 部分闭合头重写 tool_calls let sanitized: Vec<ChatMessage> = messages
let sanitized: Vec<ChatMessage> = messages .into_iter()
.into_iter() .enumerate()
.enumerate() .filter_map(|(i, mut m)| match m.role {
.filter_map(|(i, mut m)| match m.role { MessageRole::Assistant => {
MessageRole::Assistant => { let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) }; if calls.is_empty() {
if calls.is_empty() { return Some(m);
return Some(m); }
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
} }
if let Some(keep) = partial_keep.get(&i) { MessageRole::Tool => {
// 部分闭合:重写 tool_calls 为仅已闭合子集 // 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
m.tool_calls = Some(keep.clone()); if m
Some(m) .tool_call_id
} else { .as_deref()
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃 .is_some_and(|id| orphaned_ids.contains(id))
let all_orphaned = {
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None None
} else { } else {
Some(m) Some(m)
} }
} }
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 4序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
Self::ensure_sequence_legal(after_triplet)
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32;
let result: Vec<ChatMessage> = messages
.into_iter()
.skip_while(|m| {
if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1;
true
} else {
false
} }
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
}) })
.collect(); .collect();
if skipped > 0 {
tracing::warn!( tracing::warn!(
full_drop_heads, skipped,
partial_rewrite_heads = partial_heads, "序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)"
orphaned_tool_results = orphaned_ids.len(), );
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)" }
); result
sanitized
} }
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响) /// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -758,15 +816,17 @@ mod tests {
#[test] #[test]
fn sanitize_keeps_well_formed_triplet() { fn sanitize_keeps_well_formed_triplet() {
// 正常三元组:head 的每个 tool_call 有匹配 tool_result → 剔除 // 正常三元组:user → head(tool_call 有匹配 tool_result)tool_result,不被剔除
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools( mgr.push(ChatMessage::assistant_with_tools(
"", "",
vec![ToolCall::new("call_a", "fn_a", "{}")], vec![ToolCall::new("call_a", "fn_a", "{}")],
)); ));
mgr.push(ChatMessage::tool_result("call_a", "结果")); mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0); let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除"); assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
} }
#[test] #[test]
@@ -838,6 +898,46 @@ mod tests {
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量"); assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
} }
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
// 纯文本基线
let text_msg = ChatMessage::user("短文本");
let text_tokens = est.estimate_message(&text_msg);
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
let multimodal = ChatMessage::user_parts(
"短文本",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
);
let mm_tokens = est.estimate_message(&multimodal);
assert!(
mm_tokens > text_tokens,
"含图消息 token({}) 应高于纯文本({})",
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token实际 {}",
mm_tokens
);
// url 模式(无字节)也按 URL 长度估算,不爆
let url_msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let url_tokens = est.estimate_message(&url_msg);
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}
#[test] #[test]
fn short_history_no_trim() { fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));

View File

@@ -15,10 +15,26 @@ pub mod router;
pub mod retry; pub mod retry;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 // df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core; pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY
///
/// connect_timeout 30s 防连接阶段静默 hang网络静默断不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用warn 日志)。
pub(crate) fn build_provider_client() -> Client {
Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
})
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match
/// ///
/// `anthropic` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方), /// `anthropic` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方),

View File

@@ -83,23 +83,25 @@ pub fn probe(model_id: &str) -> ModelConfig {
// 启发式推断(模型名命名模式 → 4 维度) // 启发式推断(模型名命名模式 → 4 维度)
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
/// 模型名启发式推断。命名是行业惯例(flash/mini/v/embed/code),可信度 Medium。 /// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
/// ///
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐): /// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text) /// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision /// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
/// - `flash`/`mini`/`lite`/`nano`/`air` → IntelligenceTier::Lite(air 归 Lite,对齐设计 §4.4 air→Standard 但与 Lite 规则并存时取 Lite;
/// 此处 air 单独命中归 Standard — 见 issues)
/// - `plus`/`pro`/`max`/`ultra` → IntelligenceTier::Plus/Ultra
/// - `code`/`coder` → capabilities 加 CodeGen /// - `code`/`coder` → capabilities 加 CodeGen
/// - `4o`/`4.5`/`5`(高价旗舰标识) → CostTier::High /// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
/// - 默认 → Standard / Medium / [Text] / [ToolUse]
fn heuristic_infer(model_id: &str) -> ModelConfig { fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase(); let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new(); let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new(); let mut capabilities: Vec<Capability> = Vec::new();
let mut cost_tier = CostTier::Medium; // 中性默认:不靠模型名猜档位(B-260618-04)
let mut intelligence = IntelligenceTier::Standard; let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
// —— Embedding 优先判定(改变模态集合,且通常独占) —— // —— Embedding 优先判定(改变模态集合,且通常独占) ——
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首 // 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
@@ -118,8 +120,8 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
label: None, label: None,
modalities, modalities,
capabilities, capabilities,
cost_tier: CostTier::Low, // embedding 普遍低价 cost_tier,
intelligence: IntelligenceTier::Lite, // embedding 无智力概念,归 Lite intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
probe_source: None, probe_source: None,
@@ -144,24 +146,6 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
// —— 对话模型默认 ToolUse(主流支持 function calling) —— // —— 对话模型默认 ToolUse(主流支持 function calling) ——
capabilities.push(Capability::ToolUse); capabilities.push(Capability::ToolUse);
// —— 智力分级 ——
if has_lite_token(&name) {
intelligence = IntelligenceTier::Lite;
} else if has_ultra_token(&name) {
intelligence = IntelligenceTier::Ultra;
} else if has_plus_token(&name) {
intelligence = IntelligenceTier::Plus;
} else if has_standard_token(&name) {
intelligence = IntelligenceTier::Standard;
}
// —— 价格分级 ——
if has_high_cost_token(&name) {
cost_tier = CostTier::High;
} else if has_lite_token(&name) {
cost_tier = CostTier::Low;
}
ModelConfig { ModelConfig {
model_id: model_id.to_string(), model_id: model_id.to_string(),
enabled: true, enabled: true,
@@ -184,41 +168,6 @@ fn has_vision_token(name: &str) -> bool {
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v") name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
} }
/// Lite 智力词素:flash / mini / lite / nano / air(air 对齐设计 §4.4 模糊规则 Lite)。
/// 注:设计 §4.4 文本规则列了 air,但 §4.4 精确例 glm-4-air→Standard。
/// 本启发式取模糊规则(air→Lite)以保持一致 — 见 issues。
fn has_lite_token(name: &str) -> bool {
let toks = ["flash", "mini", "lite", "nano", "air", "haiku", "small"];
toks.iter().any(|t| name.contains(t))
}
/// Plus 智力词素:plus / pro / max / sonnet。
fn has_plus_token(name: &str) -> bool {
let toks = ["plus", "pro", "max", "sonnet"];
toks.iter().any(|t| name.contains(t))
}
/// Ultra 智力词素:ultra / opus / largest。
fn has_ultra_token(name: &str) -> bool {
let toks = ["ultra", "opus", "largest"];
toks.iter().any(|t| name.contains(t))
}
/// Standard 智力词素:standard / chat(对话主力模型默认归 Standard)。
fn has_standard_token(name: &str) -> bool {
let toks = ["standard", "chat", "haiku"];
toks.iter().any(|t| name.contains(t))
}
/// 高价旗舰词素:4o / 4.5 / 5 / opus / o1 / o3(OpenAI/Anthropic 旗舰命名)。
fn has_high_cost_token(name: &str) -> bool {
let toks = ["4o", "4.5", "opus", "o1", "o3"];
// "5" 单字符误伤大(如 "5b"),仅匹配词边界 -5 / 5- 或以 5 结尾
toks.iter().any(|t| name.contains(t))
|| name.ends_with("-5")
|| name.contains("-5-")
}
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// 测试 // 测试
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
@@ -257,7 +206,9 @@ mod tests {
assert_eq!(m.model_id, "glm-4"); assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]); assert_eq!(m.modalities, vec![Modality::Text]);
assert_eq!(m.intelligence, IntelligenceTier::Plus); // B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
} }
#[test] #[test]
@@ -326,20 +277,22 @@ mod tests {
assert!(m.modalities.contains(&Modality::Vision)); assert!(m.modalities.contains(&Modality::Vision));
} }
// ── 启发式:Lite 智力 ── // ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
#[test] #[test]
fn heuristic_lite_from_flash() { fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash"); let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert_eq!(m.intelligence, IntelligenceTier::Lite); // B-260618-04:cost/intel 一律中性,不靠名字猜
assert_eq!(m.cost_tier, CostTier::Low); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
} }
#[test] #[test]
fn heuristic_lite_from_mini() { fn heuristic_mini_keeps_neutral_tier() {
let m = probe("test-mini-model"); let m = probe("test-mini-model");
assert_eq!(m.intelligence, IntelligenceTier::Lite); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
} }
// ── 启发式:CodeGen ── // ── 启发式:CodeGen ──
@@ -377,24 +330,25 @@ mod tests {
assert!(m.modalities.is_empty()); assert!(m.modalities.is_empty());
} }
// ── 启发式:Plus/Ultra 智力 + High 价格 ── // ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
#[test] #[test]
fn heuristic_plus_from_pro() { fn heuristic_pro_keeps_neutral_tier() {
let m = probe("acme-pro"); let m = probe("acme-pro");
assert_eq!(m.intelligence, IntelligenceTier::Plus); assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
} }
#[test] #[test]
fn heuristic_ultra_from_opus() { fn heuristic_opus_keeps_neutral_tier() {
let m = probe("acme-opus"); let m = probe("acme-opus");
assert_eq!(m.intelligence, IntelligenceTier::Ultra); assert_eq!(m.intelligence, IntelligenceTier::Standard);
} }
#[test] #[test]
fn heuristic_high_cost_from_4o() { fn heuristic_4o_keeps_neutral_cost() {
let m = probe("acme-4o"); let m = probe("acme-4o");
assert_eq!(m.cost_tier, CostTier::High); assert_eq!(m.cost_tier, CostTier::Medium);
} }
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ── // ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
@@ -413,10 +367,10 @@ mod tests {
#[test] #[test]
fn probe_preset_beats_heuristic() { fn probe_preset_beats_heuristic() {
// "glm-4-flash" 在预设表 = Lite;若走启发式也会是 Lite,但 source 应为 PresetTable // "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash"); let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.intelligence, IntelligenceTier::Lite);
} }
#[test] #[test]

View File

@@ -77,6 +77,8 @@ struct OpenAiResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct OpenAiChoice { struct OpenAiChoice {
message: OpenAiMessageResp, message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
finish_reason: Option<String>, finish_reason: Option<String>,
} }
@@ -92,6 +94,8 @@ struct OpenAiMessageResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct OpenAiToolCallResp { struct OpenAiToolCallResp {
id: String, id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")] #[serde(rename = "type")]
call_type: String, call_type: String,
function: OpenAiFunctionResp, function: OpenAiFunctionResp,
@@ -255,16 +259,9 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥 /// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// connect_timeout 防连接阶段无限 hang网络静默断不设总 timeout—— // SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 Anthropic 共用 DRY
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍 // connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。 let client = crate::build_provider_client();
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self { Self {
client, client,
api_key: api_key.into(), api_key: api_key.into(),

View File

@@ -8,61 +8,58 @@
//! //!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。 //! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
use std::cmp::Reverse;
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}` // 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点 // 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。 // 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig}; pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(5 维度,对齐设计 §6.1)。 /// 任务对模型的需求(3 维度)。
/// ///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/智力/成本/上下文, /// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。 /// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskRequirements { pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里) /// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>, pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse) /// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool, pub needs_tool_use: bool,
/// 最低智力要求(模型 intelligence 必须 >= min_intelligence)
pub min_intelligence: IntelligenceTier,
/// 成本上限(可选:None 表示不设上限)
pub max_cost: Option<CostTier>,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值) /// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
pub estimated_context: usize, pub estimated_context: usize,
} }
/// 模型路由器(单元结构,无状态)。 /// 模型路由器(单元结构,无状态)。
/// ///
/// select 为关联函数:给定需求 + 候选池,执行 7 步过滤链选最优模型。 /// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter; pub struct ModelRouter;
impl ModelRouter { impl ModelRouter {
/// 在候选池中选出最优模型(7 步过滤链,严格对齐设计 §6.1 L469-478)。 /// 在候选池中选出最优模型(过滤链)。
/// ///
/// 步骤: /// 步骤:
/// 1. enabled — 只选启用的 /// 1. enabled — 只选启用的
/// 2. 模态匹配 — 任务所需模态全在模型模态里 /// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse /// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 智力达标intelligence >= min_intelligence /// 4. 窗口够大context_window >= estimated_context
/// 5. 成本可控 — max_cost 设定时 cost_tier <= max_cost /// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
/// 6. 窗口够大 — context_window >= estimated_context
/// 7. max_by_key 选最优:权重优先,同权重选便宜(cost_tier 小)
/// ///
/// 第 7 步取反说明:CostTier derive Ord(Free<Low<Medium<High), /// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// "选便宜"=选小 Ord 值,但 max_by_key 默认选大 /// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导
/// 用 `std::cmp::Reverse(m.cost_tier)` 包装让小值变"大": /// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// Free(Reverse 值大)胜过 High(Reverse 值小)。比 `-(as i32)` 取反更 idiomatic /// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> { pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter() pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的 .filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配 .filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配 .filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.intelligence >= req.min_intelligence) // 4. 智力达标 .filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
.filter(|m| req.max_cost.map_or(true, |max| m.cost_tier <= max)) // 5. 成本可控 .max_by_key(|m| m.weight) // 5. 纯 weight 主导
.filter(|m| m.context_window >= req.estimated_context) // 6. 窗口够大
.max_by_key(|m| (m.weight, Reverse(m.cost_tier))) // 7. 权重优先,同权重选便宜
} }
} }
@@ -105,8 +102,6 @@ mod tests {
TaskRequirements { TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: None,
estimated_context: 0, estimated_context: 0,
} }
} }
@@ -163,6 +158,63 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none()); assert!(ModelRouter::select(&r, &pool).is_none());
} }
#[test]
fn modality_subset_multimodal_required_filters_text_only() {
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
let pool = vec![ModelConfig {
modalities: vec![Modality::Text],
..model("text-only")
}];
let r = TaskRequirements {
modalities: vec![Modality::Text, Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
let pool_ok = vec![ModelConfig {
modalities: vec![Modality::Text, Modality::Vision],
..model("vision-capable")
}];
assert_eq!(
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
"vision-capable"
);
}
#[test]
fn enabled_false_excluded_from_routing() {
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
let pool = vec![
ModelConfig {
enabled: false,
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
..model("disabled-heavy")
},
ModelConfig {
enabled: true,
weight: 30,
..model("enabled-light")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"enabled-light"
);
}
#[test]
fn empty_candidate_pool_returns_none() {
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 3:能力匹配(ToolUse) ── // ── 步骤 3:能力匹配(ToolUse) ──
#[test] #[test]
@@ -196,53 +248,7 @@ mod tests {
); );
} }
// ── 步骤 4:智力达标 ── // ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
#[test]
fn intelligence_below_min_filtered() {
// 任务最低 Plus,池里模型 Standard
let pool = vec![model("standard")];
let r = TaskRequirements {
min_intelligence: IntelligenceTier::Plus,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 5:成本可控 ──
#[test]
fn max_cost_filters_expensive() {
// 任务上限 Low,池里模型 High
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: Some(CostTier::Low),
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn max_cost_none_allows_any() {
// 任务不设上限,池里模型 High 仍入选
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: None,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"expensive"
);
}
// ── 步骤 6:窗口够大 ──
#[test] #[test]
fn context_window_insufficient() { fn context_window_insufficient() {
@@ -255,7 +261,7 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none()); assert!(ModelRouter::select(&r, &pool).is_none());
} }
// ── 步骤 7:max_by_key (weight, Reverse(cost)) ── // ── 步骤 5:max_by_key (weight) ──
#[test] #[test]
fn single_match_returns_it() { fn single_match_returns_it() {
@@ -286,8 +292,9 @@ mod tests {
} }
#[test] #[test]
fn same_weight_picks_cheaper_cost_tier() { fn same_weight_picks_first_match() {
// 同 weight 70,cost Free 胜 cost High(Reverse 让 Free 的 Ord 值变大) // 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 70, weight: 70,
@@ -296,28 +303,29 @@ mod tests {
}, },
ModelConfig { ModelConfig {
weight: 70, weight: 70,
cost_tier: CostTier::Free, cost_tier: CostTier::Low,
..model("free") ..model("low-cost")
}, },
]; ];
assert_eq!( assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id, ModelRouter::select(&req(), &pool).unwrap().model_id,
"free" "low-cost"
); );
} }
#[test] #[test]
fn all_dimensions_match_picks_best() { fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证 7 步全过 + max_by_key 选最优组合 // 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
// //
// 候选: // 候选:
// a: weight 60, cost Medium → 通过全部过滤,key=(60, Reverse(Medium)) // a: weight 60 → 通过全部过滤,key=60
// b: weight 80, cost High → 通过,key=(80, Reverse(High)) — weight 最高,胜 // b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80, cost Free → 通过,key=(80, Reverse(Free)) — 同 weight 80,Free 比 High 便宜,胜过 b // c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90, cost Low, 但 intelligence Lite(< min Standard) → 步骤 4 滤掉 // d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉 // e: enabled=false → 步骤 1 滤掉
// //
// 预期:c 胜(weight 80 最高档中,Free 最便宜) // 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
let pool = vec![ let pool = vec![
ModelConfig { ModelConfig {
weight: 60, weight: 60,
@@ -333,14 +341,14 @@ mod tests {
}, },
ModelConfig { ModelConfig {
weight: 80, weight: 80,
cost_tier: CostTier::Free, cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Plus, intelligence: IntelligenceTier::Plus,
..model("c") ..model("c")
}, },
ModelConfig { ModelConfig {
weight: 90, weight: 90,
cost_tier: CostTier::Low, cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Lite, // 低于 min,滤掉 intelligence: IntelligenceTier::Lite,
..model("d") ..model("d")
}, },
ModelConfig { ModelConfig {
@@ -352,10 +360,8 @@ mod tests {
let r = TaskRequirements { let r = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: true, needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "c"); assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
} }
} }

View File

@@ -52,7 +52,8 @@ pub struct ShellRequest {
/// 执行 Shell 命令 /// 执行 Shell 命令
/// ///
/// TODO: 完整实现,支持超时、环境变量、工作目录等 /// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();

View File

@@ -0,0 +1,161 @@
//! Shell 执行器基础集成测试。
//!
//! 覆盖 execute() 五类行为,跨平台(Windows=Cmd, 非 Windows=Sh):
//! 1. 成功:exit_code=Some(0) + stdout 非空 + duration_ms>0
//! 2. 非零退出:exit_code=Some(非0),返回 Ok(退出码在 ShellResult 内,非 Err)
//! 3. 超时:timeout_secs=1 + 长睡命令,返回 Err
//! 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证
//! 5. working_dir:传临时目录,命令回显当前目录验证
//!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, ShellRequest, ShellType};
use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
fn default_shell() -> ShellType {
if cfg!(windows) {
ShellType::Cmd
} else {
ShellType::Sh
}
}
/// 构造最小 ShellRequest,默认无超时/无 env/无 working_dir
fn req(command: &str) -> ShellRequest {
ShellRequest {
command: command.to_string(),
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
}
}
// ---------- 1. 成功:exit_code=0 + stdout 非空 + duration_ms>0 ----------
#[tokio::test]
async fn execute_success_echo() {
// 平台无关:Cmd/Sh 都认 echo。Cmd 输出无尾换行,Sh 输出带 \n——只断言非空与包含。
let result = execute(req("echo hello_df_execute")).await;
assert!(result.is_ok(), "execute 应返回 Ok: {:?}", result.err());
let res = result.unwrap();
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
assert!(
res.stdout.trim().contains("hello_df_execute"),
"stdout 应包含回显文本,实际: {:?}",
res.stdout
);
assert!(res.duration_ms > 0, "duration_ms 应 > 0,实际: {}", res.duration_ms);
}
// ---------- 2. 非零退出:exit_code=Some(非0),返回 Ok ----------
#[tokio::test]
async fn execute_nonzero_exit() {
// 平台无关:false 是 POSIX 内建 + Windows 的 cmd 也内置(返回退出码 1)。
// 非 Windows 用 sh -c 'false';Windows 用 cmd /C 'cmd /C exit 1' 更稳——
// 这里统一用 false,Windows cmd 内置 false.exe 自 Win10 起可用,失败则 exit_code 非 0 即满足断言。
let result = execute(req("exit 1")).await;
assert!(result.is_ok(), "非零退出应仍返回 Ok(退出码在 ShellResult 内),实际: {:?}", result);
let res = result.unwrap();
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0,实际: {:?}", res.exit_code);
}
// ---------- 3. 超时:timeout_secs=1 + 长睡命令,返回 Err ----------
#[tokio::test]
async fn execute_timeout_returns_err() {
// 平台无关睡眠命令:Cmd 用 timeout(Windows 内置),Sh 用 sleep。各自分支。
let sleep_cmd = if cfg!(windows) {
// cmd 的 timeout /T 需按键中断,用 ping 假睡更稳(>1s);但 timeout 内置更简洁,
// 这里用 ping -n 5 (≈4s) 跨 Win 版本稳。
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute(request).await;
assert!(
result.is_err(),
"超时应返回 Err,实际: {:?}",
result.as_ref().err()
);
// 错误信息应含「超时」(对齐 shell.rs:101 文案)
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
// ---------- 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证 ----------
#[tokio::test]
async fn execute_env_injection() {
let mut env = HashMap::new();
env.insert("DF_EXECUTE_TEST_KEY".to_string(), "env_value_42".to_string());
// 平台无关回显 env 变量:Cmd 用 %VAR%,Sh 用 $VAR
let echo_cmd = if cfg!(windows) {
"echo %DF_EXECUTE_TEST_KEY%"
} else {
"echo $DF_EXECUTE_TEST_KEY"
};
let request = ShellRequest {
command: echo_cmd.to_string(),
working_dir: None,
env,
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("env 注入命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
assert!(
res.stdout.trim().contains("env_value_42"),
"stdout 应含注入的 env 值,实际: {:?}",
res.stdout
);
}
// ---------- 5. working_dir:传临时目录,命令回显当前目录验证 ----------
#[tokio::test]
async fn execute_working_dir() {
// 用 std::env::temp_dir()(标准库,零新增依赖)。生成唯一子目录避免并行测试互相干扰。
let unique = format!("df_exec_test_{}", std::process::id());
let tmp = std::env::temp_dir().join(&unique);
std::fs::create_dir_all(&tmp).expect("创建临时目录失败");
// 测试后清理(即便失败也尽量不残留)
let tmp_for_cleanup = tmp.clone();
// 平台无关回显当前目录:Cmd 用 cd(无参数)不靠谱,用 echo %CD%;Sh 用 pwd。
let pwd_cmd = if cfg!(windows) { "echo %CD%" } else { "pwd" };
let request = ShellRequest {
command: pwd_cmd.to_string(),
working_dir: Some(tmp.to_string_lossy().to_string()),
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("working_dir 命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
// 规范化比较(去尾空白/大小写在 Windows 不敏感但路径用户自定义,只断言包含)
let stdout_norm = res.stdout.trim().to_lowercase().replace('\\', "/");
let tmp_norm = tmp.to_string_lossy().to_lowercase().replace('\\', "/");
assert!(
stdout_norm.contains(&tmp_norm),
"stdout 应含 working_dir 路径,实际: {:?},期望包含: {:?}",
res.stdout,
tmp.to_string_lossy()
);
// 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
}

View File

@@ -158,8 +158,6 @@ impl AdversarialEngine {
let eval_req = df_ai::router::TaskRequirements { let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text], modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: df_ai_core::model::IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default(); let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
@@ -577,7 +575,10 @@ fn extract_json(text: &str) -> String {
if let Some(body_inner) = body.strip_suffix("```") { if let Some(body_inner) = body.strip_suffix("```") {
return body_inner.trim().to_string(); return body_inner.trim().to_string();
} }
return body.trim().to_string(); // 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。)
} }
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2 // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。 // (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
@@ -775,6 +776,9 @@ mod tests {
model: "mock-model".to_string(), model: "mock-model".to_string(),
usage: df_ai_core::provider::TokenUsage::default(), usage: df_ai_core::provider::TokenUsage::default(),
tool_calls: None, tool_calls: None,
// 对抗评估路径不消费推理内容,留 None 即可(CompletionResponse 新增字段,
// 老构造点漏填 → 测试编译失败,回归补齐)。
reasoning_content: None,
}) })
} }

View File

@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use df_ai::df_ai_core::model::{IntelligenceTier, Modality, ModelConfig}; use df_ai::df_ai_core::model::{Modality, ModelConfig};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 // F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 // (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
@@ -66,6 +66,27 @@ struct ResolvedProvider {
/// 无任何 provider → 友好错误「未配置 AI Provider」。 /// 无任何 provider → 友好错误「未配置 AI Provider」。
/// ///
/// modelconfig["model"] 非空用之,否则 record.default_model再否则 "gpt-4o-mini" 占位。 /// modelconfig["model"] 非空用之,否则 record.default_model再否则 "gpt-4o-mini" 占位。
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并
/// (AiNode/AiSelfReviewNode execute 逐字重复)。
async fn resolve_and_parse(
db: &Arc<Database>,
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?;
parse_params(config, inputs, provider_cfg)
}
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
)
}
async fn resolve_provider( async fn resolve_provider(
db: &Arc<Database>, db: &Arc<Database>,
config: &serde_json::Value, config: &serde_json::Value,
@@ -90,23 +111,16 @@ async fn resolve_provider(
} }
// ── 路径 2老明文路径兼容过渡warn ── // ── 路径 2老明文路径兼容过渡warn ──
let has_plain_base = config.get("base_url").and_then(|v| v.as_str()).is_some(); // base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。
let has_plain_key = config.get("api_key").and_then(|v| v.as_str()).is_some(); let plain_base = config.get("base_url").and_then(|v| v.as_str());
if has_plain_base && has_plain_key { let plain_key = config.get("api_key").and_then(|v| v.as_str());
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
tracing::warn!( tracing::warn!(
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \ "AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
老路径将在后续版本移除" 老路径将在后续版本移除"
); );
let base_url = config let base_url = base_url_str.to_string();
.get("base_url") let api_key = api_key_str.to_string();
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let api_key = config
.get("api_key")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ensure_resolved_key("(明文注入)", &api_key) ensure_resolved_key("(明文注入)", &api_key)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
let protocol = config let protocol = config
@@ -201,14 +215,12 @@ fn parse_params(
let model = if !config_model.is_empty() { let model = if !config_model.is_empty() {
config_model config_model
} else { } else {
// F-01 阶段5: AiNode 默认路由 — Standard + needs_tool_use=true(工作流无人值守 AI 步骤 // F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。 // 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。 // select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
let node_req = TaskRequirements { let node_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: true, needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
select_model_id(&node_req, &provider.model_pool).unwrap_or_default() select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
@@ -258,15 +270,8 @@ impl Node for AiNode {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id); tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。 // FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。
let provider_cfg = resolve_provider(&self.db, &ctx.config).await?; let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let p = parse_params(&ctx.config, &ctx.inputs, provider_cfg)?; let provider: Box<dyn LlmProvider> = provider_from_params(&p);
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
);
// ── 构建消息 ── // ── 构建消息 ──
let mut messages = Vec::with_capacity(2); let mut messages = Vec::with_capacity(2);
@@ -343,7 +348,8 @@ impl Node for AiNode {
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" }, "base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_idFR-S1 下经 secret 解析" } "api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_idFR-S1 下经 secret 解析" }
}, },
"required": ["provider_id"] // SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
"required": []
}), }),
output: serde_json::json!({ output: serde_json::json!({
"type": "object", "type": "object",
@@ -468,8 +474,7 @@ impl Node for AiSelfReviewNode {
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id); tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。 // FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。
let provider_cfg = resolve_provider(&self.db, &ctx.config).await?; let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let p = parse_params(&ctx.config, &ctx.inputs, provider_cfg)?;
// ── 读任务(需求 + 产出) ── // ── 读任务(需求 + 产出) ──
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。 // task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
@@ -496,12 +501,7 @@ impl Node for AiSelfReviewNode {
let prompt = Self::build_review_prompt(&task.description, &output_text); let prompt = Self::build_review_prompt(&task.description, &output_text);
let provider: Box<dyn LlmProvider> = df_ai::build_provider( let provider: Box<dyn LlmProvider> = provider_from_params(&p);
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
);
let request = CompletionRequest { let request = CompletionRequest {
model: p.model.clone(), model: p.model.clone(),

View File

@@ -72,13 +72,4 @@ impl ProjectManager {
}) })
} }
/// 更新项目状态
///
/// TODO: 状态转换校验
pub fn transition_status(project: &mut Project, new_status: ProjectStatus) -> anyhow::Result<()> {
// TODO: 校验状态转换是否合法
project.status = new_status;
project.updated_at = chrono::Utc::now();
Ok(())
}
} }

View File

@@ -118,12 +118,14 @@ pub fn is_monorepo(root: &Path) -> bool {
return true; return true;
} }
} }
// package.json workspaces 字段(npm/yarn) // package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json"); let pkg_path = root.join("package.json");
if pkg_path.is_file() { if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) { if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) { if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some() { if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true; return true;
} }
} }
@@ -540,9 +542,13 @@ fn is_pure_badge_line(line: &str) -> bool {
let mut rest = line; let mut rest = line;
loop { loop {
// 找下一个 ![ 或 <img 或 <a // 找下一个 ![ 或 <img 或 <a
// 注:HTML 标签为纯 ASCII,用 to_ascii_lowercase 做「不区分大小写」匹配即可;
// 若用 to_lowercase(),非 ASCII 字符(如 İ→i̇、ß→ss)在小写化时改变字节长度,
// 返回的索引会偏离原 rest 的字节边界,后续 &rest[..pos]/&rest[pos..] 切到错位/panic。
// to_ascii_lowercase 仅改 ASCII 字母、保持非 ASCII 字节不变 → 字节长度不变 → 索引对齐。
let img_md = rest.find("!["); let img_md = rest.find("![");
let img_html = rest.to_lowercase().find("<img"); let img_html = rest.to_ascii_lowercase().find("<img");
let anchor_html = rest.to_lowercase().find("<a "); let anchor_html = rest.to_ascii_lowercase().find("<a ");
let earliest = [img_md, img_html, anchor_html] let earliest = [img_md, img_html, anchor_html]
.into_iter() .into_iter()
.flatten() .flatten()
@@ -896,4 +902,114 @@ mod tests {
assert!(desc.contains("已截断")); assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok(); fs::remove_dir_all(&d).ok();
} }
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
// ── collect_images 徽章过滤逻辑补强 ──
#[test]
fn collect_images_filters_badge_by_keyword() {
// 非徽章域名,但 alt/src 含关键词(build/license/coverage)应过滤
let content = "![build status](https://example.com/build.svg)\n![my license](https://example.com/lic.svg)\n![coverage](https://example.com/cov.svg)\n![architecture](./arch.png)\n";
let imgs = collect_images(content);
assert!(imgs.iter().all(|i| !i.alt.contains("build")), "build 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("license")), "license 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("coverage")), "coverage 关键词徽章未过滤");
assert_eq!(imgs.len(), 1, "应仅保留架构图: {imgs:?}");
assert_eq!(imgs[0].src, "./arch.png");
}
#[test]
fn collect_images_dedups_same_src() {
// 同 src 不同 alt 去重,只留首个
let content = "![first](./dup.png)\n![second](./dup.png)\n";
let imgs = collect_images(content);
assert_eq!(imgs.len(), 1, "同 src 应去重: {imgs:?}");
assert_eq!(imgs[0].alt, "first");
}
#[test]
fn collect_images_handles_multiple_in_one_line() {
// 同行多图 + 徽章混排:徽章过滤、内容图保留
let content = "![build](https://img.shields.io/x) ![shot](./shot.png) ![alt2](./two.png)\n";
let imgs = collect_images(content);
let srcs: Vec<_> = imgs.iter().map(|i| i.src.as_str()).collect();
assert!(srcs.contains(&"./shot.png"), "同行内容图丢失: {srcs:?}");
assert!(srcs.contains(&"./two.png"), "同行内容图丢失: {srcs:?}");
assert!(!srcs.contains(&"https://img.shields.io/x"), "同行徽章未过滤: {srcs:?}");
}
#[test]
fn collect_images_empty_and_malformed() {
// 无图片 / 不闭合语法不 panic,返回空
assert!(collect_images("plain text no images").is_empty());
assert!(collect_images("![unclosed alt").is_empty());
assert!(collect_images("![alt](no-close-paren").is_empty());
}
} }

View File

@@ -1070,7 +1070,11 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
// F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。 // F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。 // from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0, enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
weight: row.get::<_, i32>("weight").unwrap_or(50).max(0) as u32, // weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
weight: row.get::<_, i32>("weight")
.unwrap_or(50)
.clamp(0, 100) as u32,
}) })
} }
@@ -1657,6 +1661,40 @@ impl KnowledgeEventsRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 跨知识列最近 N 条事件(全表 timestamp DESC,top-N)。
///
/// **专用兜底方法**:本表时间列名是 `timestamp` 而非 `created_at`,但
/// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at`
/// (见宏内 `ORDER BY created_at DESC` 字面量),误调 `state.knowledge_events.query(...)`
/// 会触发 SQLite "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,
/// 供需要跨知识按时间倒序浏览事件的调用方使用(对标 AiToolExecutionRepo::list_recent
/// 对 ai_tool_executions 表的同款兜底处理——那张表同样无 created_at 列)。
/// limit 上限钳制 200,防前端恶意/失误传超大值。
pub async fn list_recent(&self, limit: u32) -> Result<Vec<KnowledgeEventRecord>> {
let conn = self.conn.clone();
// 钳制 limit 防滥用(最大 200)
let safe_limit = limit.min(200) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,knowledge_id,event_type,source_ref,context_json,timestamp \
FROM knowledge_events ORDER BY timestamp DESC LIMIT ?1",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![safe_limit], |row| knowledge_event_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
} }
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 // AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。

View File

@@ -595,3 +595,65 @@ CREATE TABLE IF NOT EXISTS app_settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
"; ";
#[cfg(test)]
mod tests {
use super::*;
// column_exists 是幂等迁移的通用探测辅助(V4+ 全部迁移靠它判断列存在性),
// 本组用 in-memory SQLite(:memory:) 建临时表覆盖四种语义:
// 列存在→true / 列不存在→false / 表不存在→false / 多列中正确匹配目标列名。
/// 列存在返回 true:建表后探测真实列名。
#[test]
fn column_exists_returns_true_for_existing_column() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE probe_t (id TEXT PRIMARY KEY, name TEXT NOT NULL, score INTEGER);",
)
.expect("create probe table");
assert!(column_exists(&conn, "probe_t", "id"));
assert!(column_exists(&conn, "probe_t", "name"));
assert!(column_exists(&conn, "probe_t", "score"));
}
/// 列不存在返回 false:表存在但目标列名未建。
#[test]
fn column_exists_returns_false_for_missing_column() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch("CREATE TABLE probe_t (id TEXT PRIMARY KEY, name TEXT);")
.expect("create probe table");
assert!(!column_exists(&conn, "probe_t", "archived"));
assert!(!column_exists(&conn, "probe_t", "deleted_at"));
}
/// 表不存在返回 false:PRAGMA table_info 对未知表返回空结果集,函数应安全降级为 false。
#[test]
fn column_exists_returns_false_when_table_absent() {
let conn = Connection::open_in_memory().expect("open in-memory db");
// 不建任何表,直接探测
assert!(!column_exists(&conn, "ghost_table", "any_col"));
}
/// 多列表中正确匹配目标列名(不误命中前缀/子串):验证精确比较非 contains。
#[test]
fn column_exists_matches_exact_name_among_many() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE probe_t (
id TEXT PRIMARY KEY,
prompt_tokens INTEGER,
completion_tokens INTEGER,
model TEXT
);",
)
.expect("create probe table");
// 命中真实列
assert!(column_exists(&conn, "probe_t", "prompt_tokens"));
assert!(column_exists(&conn, "probe_t", "completion_tokens"));
// 不误命中子串(prefix/suffix 近似名)
assert!(!column_exists(&conn, "probe_t", "tokens"));
assert!(!column_exists(&conn, "probe_t", "prompt"));
assert!(!column_exists(&conn, "probe_t", "model_configs"));
}
}

View File

@@ -101,6 +101,8 @@ impl Dag {
for edge in &self.edges { for edge in &self.edges {
// 仅收录两端均为已注册节点的边,跳过野节点 // 仅收录两端均为已注册节点的边,跳过野节点
if node_ids.contains(&edge.source) && node_ids.contains(&edge.target) { if node_ids.contains(&edge.source) && node_ids.contains(&edge.target) {
// safe: edge.target 已在 node_ids 中(上一行 contains 守卫),而 in_degree
// 对每个 node_id 都插入了表项line 97-99 初始化),故 get_mut 必命中
*in_degree.get_mut(&edge.target).unwrap() += 1; *in_degree.get_mut(&edge.target).unwrap() += 1;
adjacency_out adjacency_out
.entry(edge.source.clone()) .entry(edge.source.clone())
@@ -127,6 +129,9 @@ impl Dag {
// 出边已索引O(出度) 遍历而非 O(E) 全表扫描 // 出边已索引O(出度) 遍历而非 O(E) 全表扫描
if let Some(succs) = adjacency_out.get(&id) { if let Some(succs) = adjacency_out.get(&id) {
for succ in succs { for succ in succs {
// safe: succ 来自 adjacency_out其元素均为通过 line 103 contains 守卫
// 的 edge.targetline 108 push而 in_degree 覆盖所有 node_idline 97-99
// 故 succ 必在 in_degree 表内get_mut 必命中
let deg = in_degree.get_mut(succ).unwrap(); let deg = in_degree.get_mut(succ).unwrap();
*deg -= 1; *deg -= 1;
if *deg == 0 { if *deg == 0 {

View File

@@ -135,13 +135,25 @@ impl DagExecutor {
// 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态 // 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态
if !self.state_machine.is_cancelled(&node_id) { if !self.state_machine.is_cancelled(&node_id) {
self.state_machine.set_completed(node_id.clone())?; self.state_machine.set_completed(node_id.clone())?;
// 未取消才 emit NodeCompleted,取消时 emit NodeCancelled(对齐 Err 分支语义):
// 避免 状态机=Cancelled 但事件=NodeCompleted 的矛盾信号
self.event_bus
.send(WorkflowEvent::NodeCompleted {
node_id: node_id.clone(),
duration_ms: duration,
})
.await;
} else {
// 对齐 Err 分支:取消节点 emit NodeCancelled(非 NodeCompleted),
// 前端按 type 归「取消」,与状态机 Cancelled 一致
self.event_bus
.send(WorkflowEvent::NodeCancelled {
node_id: node_id.clone(),
})
.await;
} }
self.event_bus // outputs.insert 保留现状不动:取消时仍写入 output 供下游消费,
.send(WorkflowEvent::NodeCompleted { // 当前测试锁定"取消不中止后续层",改 insert 会变行为致回归
node_id: node_id.clone(),
duration_ms: duration,
})
.await;
outputs.insert(node_id, output); outputs.insert(node_id, output);
} }
Err(e) => { Err(e) => {

View File

@@ -30,8 +30,6 @@ pub struct NodeContext {
pub struct NodeOutput { pub struct NodeOutput {
/// 输出数据 /// 输出数据
pub data: serde_json::Value, pub data: serde_json::Value,
/// 输出元数据
pub metadata: std::collections::HashMap<String, String>,
} }
impl NodeOutput { impl NodeOutput {
@@ -39,16 +37,12 @@ impl NodeOutput {
pub fn empty() -> Self { pub fn empty() -> Self {
Self { Self {
data: serde_json::Value::Null, data: serde_json::Value::Null,
metadata: std::collections::HashMap::new(),
} }
} }
/// 从 JSON 值创建输出 /// 从 JSON 值创建输出
pub fn from_value(data: serde_json::Value) -> Self { pub fn from_value(data: serde_json::Value) -> Self {
Self { Self { data }
data,
metadata: std::collections::HashMap::new(),
}
} }
} }

View File

@@ -8,6 +8,45 @@
--- ---
> ## 实施状态(2026-06-18 核对)
>
> **②-⑤ 全部已落地**(原设计 1.2 表中标 ⚠️ 的 crud/白名单缺口已补全,②③④⑤ 已实施)。
>
> **① df-storage 迁移 — 已完成**(原 ⚠️ 项已补)
> - `TaskRecord.output_json: Option<String>``crates/df-storage/src/models.rs:72`。
> - 白名单含 output_json`crates/df-storage/src/crud.rs:344`(`allowed_columns_for("tasks")` 已含,注释"ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象")。
> - migrations`crates/df-storage/src/migrations.rs:303`(注释同上)。
>
> **② AiNode 持 db + 写产出 — 已落地**
> - `AiNode` struct`crates/df-nodes/src/ai_node.rs:256`(持 db 字段,`AiNode::new(db)` 构造)。
> - state.rs 工厂闭包注入 db`src-tauri/src/state.rs:351-352`(`registry.register("ai", ... AiNode::new(ai_db.clone()))`)。
> - execute 写产出:`crates/df-nodes/src/ai_node.rs:319-325`(若 config 含 task_id → `repo.update_field(task_id, "output_json", &json_str)`)。
>
> **③ AiSelfReviewNode 独立节点 — 已落地**(原设计 2.3 推荐独立节点路径已采纳,非按 node_id 分支)
> - `AiSelfReviewNode` struct`crates/df-nodes/src/ai_node.rs:438`。
> - `REVIEW_SYSTEM_PROMPT`(四维度 prompt 模板)`crates/df-nodes/src/ai_node.rs:387`。
> - JSON 兜底解析 `parse_review_json`(fn 定义,非 JSON/缺 verdict → verdict=unknown)`crates/df-nodes/src/ai_node.rs:396`,单测 `:1011-1058`(valid/invalid/fence 三场景)。
> - `build_review_prompt`(fn 定义)`crates/df-nodes/src/ai_node.rs:449`,单测 `:1060`。
> - state.rs 注册:`src-tauri/src/state.rs:359-360`(`registry.register("ai_self_review", ... AiSelfReviewNode::new(review_db.clone()))`)。
>
> **④ human_review 展示(经 DAG inputs 透传HumanNode 零改动)— 已落地**
> - review 摘要塞 NodeOutput.data`crates/df-nodes/src/ai_node.rs:586`(注释「NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读」)。
> - testing 模板 edge ai_self_review → human_review`crates/df-nodes/src/task_workflow_templates.rs:80`,单测 `:149` 验证边方向(`assert_eq!(edge.source, "ai_self_review")`)。
> - HumanNode 本身零改动(git 历史核验 human_node.rs 在 AiNode 自审批 commit 中无变动)。
>
> **⑤ 前端展示 — 已落地**TaskDetail.vue 加 output_json 区块(本轮未逐行核验前端 file:line但后端 output_json schema 已定型、前端按 schema 渲染)。
>
> **超出原设计、后追加的能力 — gate 闸门**
> - testing 模板 `ai_self_review` 启用 `gate:true``crates/df-nodes/src/task_workflow_templates.rs:54-67`(阶段3 起 verdict=fail → AiSelfReviewNode 返 Err → 工作流 failed不经 human_review)。原设计 2.3 标"首版保守verdict 仅作展示信号",实际已升级为 DAG 节点闸门(激进方案落地)`crates/df-nodes/src/ai_node.rs:598-626`(gate==true 时 verdict=fail 返 Err)。
>
> **⑥ 端到端联调 — 代码层完成,实测类待用户**:联调依赖 secret 下沉+provider 注入链(`docs/02-架构设计/secret下沉与provider注入方案-2026-06-16.md``AiNode/AiSelfReviewNode` 改经 `provider_id` + df_storage::secret 解析FR-S1 mask 对齐)。实测类(tauri dev 跑 testing 模板 ai_self_review→human_review 闭环)待用户执行。
>
> **DRY 优化(SW-260618-09)**AiNode/AiSelfReviewNode execute provider 三件套逐字重复已抽 `resolve_and_parse` + `provider_from_params` helper(`crates/df-nodes/src/ai_node.rs:70/80` 注释)。
>
> 原文以下设计正文保持不变,作为历史设计记录;落地形态以上方"实施状态"为准。
---
## 一、现状盘点file:line 证据) ## 一、现状盘点file:line 证据)
### 1.1 已就绪(自审闭环地基) ### 1.1 已就绪(自审闭环地基)

View File

@@ -3,11 +3,34 @@
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。 > **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
> >
> 背景B-260614-03 — df-workflow `HumanNode` 假实现(`human_node.rs:55` 注释"等待审批"但首次迭代直接 return "同意")。 > 背景B-260614-03 — df-workflow `HumanNode` 假实现(`human_node.rs:55` 注释"等待审批"但首次迭代直接 return "同意")。
> 状态:📐 **设计完成,未实施** | 创建2026-06-14 | 来源:多代理探索 > 状态:📐 设计完成 | 创建2026-06-14 | 来源:多代理探索
> 依赖B-260614-06(execution_id 硬编码)、B-260614-07(每节点全新空 StateMachine) > 依赖B-260614-06(execution_id 硬编码)、B-260614-07(每节点全新空 StateMachine)
--- ---
> ## 实施状态(2026-06-18 核对)
>
> **B-03a(响应等待 + 超时)— 已落地**。`HumanNode.execute` 完整实现 subscribe → send(Request)(`.await` 修复 send 缺 poll 死 bug) → `select!` 循环(响应/超时/取消)
> - `crates/df-nodes/src/human_node.rs:70` 先 subscribe`:74-83` `.send(HumanApprovalRequest).await`(原审查报告头号 bug 已修)`:90-175` `select!` 循环(rx.recv() / sleep_until(deadline) / cancel_tick)。
> - 单测覆盖:`human_node.rs:286 normal_approval_returns_decision` / `:339 mismatched_execution_id_filtered_then_timeout` / `:378 timeout_when_no_response` / `:388 invalid_decision_ignored_then_timeout` 等。
>
> **B-03b(取消机制)— 已落地**(原设计标"待做",实际已实施)
> - `StateMachine::set_cancelled` 已加:`crates/df-workflow/src/state.rs:95`(注释为"唯一受控旁路")。
> - `cancel_workflow_node` IPC 已加:`src-tauri/src/commands/workflow.rs:486`,并在 `src-tauri/src/lib.rs:107` 注册;含终态前置守卫(Pending/Running/Waiting 才允许 set_cancelled)。
> - 前端取消按钮已接:`src/views/ProjectDetail.vue:425` + `src/stores/project/workflow.ts:131`(经 `src/api/workflow.ts:66` invoke)。
> - B-07(共享 StateMachine)已解:`NodeContext.node_status` 为 `StateMachine` clone内部 `Arc<Mutex<HashMap>>` 共享(`crates/df-workflow/src/executor.rs:39` 注释、`state.rs:88-95`)`run_workflow` 把执行器状态机注册到 AppState 全局表IPC 经 execution_id 取引用直达运行中节点。
> - 端到端测试:`human_node.rs:488 end_to_end_human_approval_completes_workflow` / `:551 end_to_end_human_approval_cancelled`。
>
> **超出原设计、后追加的能力**
> - F-260615-01 多选审批(`select_type=single|multiple` + `decisions` 数组)`human_node.rs:63-66` 解析、`:102-111` 数量/合法性校验、`src-tauri/src/commands/workflow.rs:404 approve_human_approval` 签名含 `decisions/select_type`。
> - F-260616-06 阶段2 审批拒绝语义化decision 命中拒绝关键字(`human_node.rs:19-22 REJECT_KEYWORDS`)→ 返 Err 触发工作流 failed(原设计拒绝与同意一样 Ok 的行为已反转)。
>
> **B-06(execution_id 下沉)— 未单独核验状态**,本设计标注当时为"dummy-execution-id";现 `approve_human_approval` IPC 签名已显式收 `execution_id: String`(`workflow.rs:407`),由调用方传入。是否已从 `run_workflow` 真 ID 下沉到 executor 再到 NodeContext本次仅标注未深核。
>
> 原文以下设计正文保持不变,作为历史设计记录;落地形态以上方"实施状态"为准。
---
## 一、背景与问题 ## 一、背景与问题
`HumanNode` 是工作流中唯一的阻塞节点,用于在 DAG 执行链路上插入人工确认门控(如"发布前确认""删除前确认")。当前实现 `crates/df-nodes/src/human_node.rs` 已正确发送 `WorkflowEvent::HumanApprovalRequest` 到事件总线,但**紧接着直接 `return NodeOutput { decision: "同意" }`**,从不等待前端审批响应。这导致: `HumanNode` 是工作流中唯一的阻塞节点,用于在 DAG 执行链路上插入人工确认门控(如"发布前确认""删除前确认")。当前实现 `crates/df-nodes/src/human_node.rs` 已正确发送 `WorkflowEvent::HumanApprovalRequest` 到事件总线,但**紧接着直接 `return NodeOutput { decision: "同意" }`**,从不等待前端审批响应。这导致:

View File

@@ -8,6 +8,23 @@
--- ---
> ## ⚠️ 实施状态2026-06-18 核对:已落地,路由部分改方向)
>
> **阶段 1-6 全部落地**(数据模型 / 探测器 / 厂商拉取 / 路由器 / 7+ 调用点接入 / 前端):
> - 阶段 1 数据模型:`crates/df-ai-core/src/model.rs:106` `ModelConfig`、`:20/35/55/71` 四维度枚举Modality/Capability/CostTier/IntelligenceTier、`:211` `deserialize_model_configs` 向后兼容;`crates/df-storage/src/models.rs:159-160` `AiProviderRecord.model_configs` 字段(**注意:实际落地字段名 `model_configs` 而非本文档 §1.1/§2.3 描述的老 `models` JSON 字段扩展**,新字段经 V18 迁移幂等补列 `crates/df-storage/src/migrations.rs:317-325`)。
> - 阶段 2 探测器:`crates/df-ai/src/model_probe.rs`(已建)+ `crates/df-ai/presets/models.json`(已建)。注意预设表/PatternRule 未拆独立 `preset_table.rs`,合并在 model_probe 内。
> - 阶段 3 厂商拉取:`crates/df-ai/src/model_fetch.rs`已建fetch_models 分派)。
> - 阶段 4 路由器:`crates/df-ai/src/router.rs:40/56/76` `ModelRouter::select` / `select_model_id`。
> - 阶段 5 调用点:主对话 `src-tauri/src/commands/ai/agentic.rs:418/423`、标题 `title.rs:86/91`、知识提炼/嵌入 `knowledge_inject.rs:58-63/359-364`、压缩 `compress.rs:59-64`、项目扫描 `project.rs:533-538/627-632`、灵感评估 `crates/df-ideas/src/adversarial.rs:158-163`、AiNode `crates/df-nodes/src/ai_node.rs:221-226`。
>
> **§6.1 路由逻辑已改方向2026-06-18 决策 B-260618-03**:本文档 §6.1 描述的 `TaskRequirements` 含 `min_intelligence`/`max_cost` 两字段、`select` 含「智力达标」「成本可控」两过滤步、`max_by_key((weight, Reverse(cost_tier)))` 同权重选便宜——**均已删除**。
> - 实际形态:`crates/df-ai/src/router.rs:27-35` `TaskRequirements` 仅 3 字段(`modalities`/`needs_tool_use`/`estimated_context``min_intelligence`/`max_cost` 已删;`:56-63` `select` 过滤链仅 4 步enabled / 模态 / 能力 / 窗口),`:62` 排序纯 `max_by_key(m.weight)`,无 cost tie-break。
> - 根因provider `/v1/models` API 不返回 cost_tier/intelligence两维度 100% 靠预设表写死 + 模型名启发式猜数据无客观依据不可信不参与硬路由。枚举CostTier/IntelligenceTier保留在 `model.rs` 供未来出现真实判别源再接回。
>
> **§6.2 场景路由表 / §6.3 调用点表**:表中行号(如 `agentic.rs:50`/`title.rs:63`/`project.rs:378`)已漂移,实际调用点见上方阶段 5 行号清单。
---
## 0. 摘要 ## 0. 摘要
将当前「一个 Provider 一个 default_model 跑全场」升级为「多模型池 + 能力感知 + 智能路由」。 将当前「一个 Provider 一个 default_model 跑全场」升级为「多模型池 + 能力感知 + 智能路由」。

View File

@@ -7,6 +7,27 @@
--- ---
> ## ⚠️ 实施状态2026-06-18 核对:阶段 1-2 已落地,数据模型形态偏离设计)
>
> **阶段 1数据模型+ 阶段 2provider 适配)已落地**,阶段 3前端+ 阶段 5F-06 联动)未做。
>
> **关键偏离:本文档 §2.1/§2.3 设计「`content: Vec<ContentPart>`」,实际落地改为「`content: String` + `parts: Option<Vec<ContentPart>>`」**`crates/df-ai-core/src/provider.rs:97/105-106`)。
> - 落地理由(见 `provider.rs:44-51` 注释未接入多模态的调用方audit/title/commands/knowledge_inject 等读 `content` 当字符串零回归避免一次性改全仓。content 字段始终保留人类可读文本,多模态片挂在 parts。
> - 后果:本文档 §2.2`deserialize_content` String→单 Text 片、§2.3(构造器签名 `impl Into<String>` 改 `Vec`、§2.4`content_text()` 辅助、§2.5`truncate_for_persist` 改 Vec描述均**不适用**——实际未改 content 类型truncate 仍作用 String content老调用点零改动。
> - 实际辅助方法:`provider.rs:157` `user_parts(content, parts)`、`:162` `has_image()`、`:173` `flattened_parts()`content 前置 Text 片 + parts 追加,供 provider 生成 blocks
>
> **已落地项grep 佐证)**
> - `ContentPart` enum`provider.rs:51-91`Text/Image 两变体,含 `text()`/`image_base64()`/`image_url()`/`is_image()` 构造与判定)。
> - **token 预算修正**`crates/df-ai/src/context.rs:49-67` `estimate_message` 已把 parts 的 Image.base64 / Text.text 同 chars_ratio 计入(此前只算 content 致含图消息 token 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。注意 `context.rs:57-58` 标注 0.35 比例偏高CR-260618-11#2偏保守致含图消息高估、过度裁剪本次未改值仅标注。
> - **provider 转换**OpenAI 兼容 `crates/df-ai/src/openai_compat.rs:331-358`has_image 走 `flattened_parts` → text/image_url 数组纯文本走字符串简写零回归Anthropic 兼容 `crates/df-ai/src/anthropic_compat.rs:344-372`Image → `source.base64 + media_type`Anthropic 不接受 URL 直传的设计约束落地)。
>
> **未做项**
> - 阶段 3 前端(`src/api/types.ts` ContentPart 类型对齐 / AiChat.vue 粘贴拖拽渲染 / store sendMessage payload—— 未做。
> - 阶段 5 F-06 联动(`commands/project.rs:509-543` extract_description_via_llm 消费 `sample.images` 喂 ContentPart::Image—— 未做,仍走纯文本 prompt对齐 §7 注「没图也能跑纯文本降级」)。
> - 阶段 4 vision 路由F-01 已落地,但 F-01 §6.1 路由已去 cost/intel 硬过滤B-260618-03vision 路由仍可按 `TaskRequirements.modalities=[Vision]` + `has_image()` 筛选候选模型,设计方向不变。
---
## 0. 摘要 ## 0. 摘要
`ChatMessage.content: String` 升级为 `Vec<ContentPart>{Text/Image}`,打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路并在 provider 转换层对非 vision 模型做文本降级。Phase 1F-01 `ModelCapability`)落地后,由 `ModelRouter``has_image` 自动路由到带 vision 能力的模型F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06`scan.rs::ImageRef` 现仅采集 alt+srcPhase 2 后可由 commands 层读 base64 喂 vision 抽 description。 `ChatMessage.content: String` 升级为 `Vec<ContentPart>{Text/Image}`,打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路并在 provider 转换层对非 vision 模型做文本降级。Phase 1F-01 `ModelCapability`)落地后,由 `ModelRouter``has_image` 自动路由到带 vision 能力的模型F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06`scan.rs::ImageRef` 现仅采集 alt+srcPhase 2 后可由 commands 层读 base64 喂 vision 抽 description。

View File

@@ -7,6 +7,26 @@
--- ---
> ## ✅ 实施状态2026-06-18 核对:方案 B 全量落地)
>
> **推荐方案 Bdf-storage 加薄 secret 查询方法)已全量实施**,行为与设计一致。
>
> **下沉层**`crates/df-storage/src/secret.rs`(新建,纯密钥逻辑唯一源)—— `KEYRING_SERVICE` 常量(`:26`)、failcount sidecar(`:34-56`)、get/set/delete/resolve/ensure/migrate 全套函数均下沉至此。`crates/df-storage/src/lib.rs` `pub mod secret` 暴露。
>
> **df-storage 依赖**`crates/df-storage/Cargo.toml:16-18` 已加 `keyring = { workspace = true }`(注释标注 FR-S1 密钥解析下沉)。
>
> **src-tauri 转发壳**`src-tauri/src/commands/ai/secret.rs:20` `pub use df_storage::secret::*;`12 调用点路径不变),`:32-43` 保留 `build_provider_for`(依赖 `df_ai::build_provider` 不下沉,对齐设计「留 app 层避免 df-storage→df-ai 循环」)。
>
> **AiNode 注入链(阶段 3**`crates/df-nodes/src/ai_node.rs:21` import `resolve_provider_secret`/`ensure_resolved_key``:60-64/101-139` 三路径解析provider_id 优先 → 老明文兼容 → 空兜底取 is_default 首条);`:166-178` 从 record 解析 provider 构造要素resolve→ensure→base_url/api_key。AiSelfReviewNode 同构(`:476` 起)。
>
> **schema**`ai_node.rs:344-351` schema 描述 provider_id/base_url/api_keybase_url/api_key 标注「已废弃过渡」),`:351` required=[]SW-260618-15prompt/provider_id 均「留空走兜底」与 required 矛盾,改 required=[] 对齐运行时)。
>
> **设计偏离**:无功能性偏离。仅 schema required 值与设计 §3.4「`["provider_id"]`」略不同(实际 `required=[]`对齐「留空走兜底」运行时语义SW-260618-15 决策)。
>
> **本文档 §1.2/§五的 file:line 索引**:原指向 `src-tauri/src/commands/ai/secret.rs` 的函数行号resolve:97-102 等)下沉后已迁移至 `crates/df-storage/src/secret.rs`src-tauri 文件已瘦身为转发壳(原 228 行 → 现 43 行)。查阅实际函数请走 `crates/df-storage/src/secret.rs`。
---
## 一、现状核验(file:line 证据) ## 一、现状核验(file:line 证据)
### 1.1 secret.rs 函数清单(src-tauri/src/commands/ai/secret.rs) ### 1.1 secret.rs 函数清单(src-tauri/src/commands/ai/secret.rs)

View File

@@ -4,6 +4,34 @@
--- ---
> ## 实施状态(2026-06-18 核对)
>
> **本设计文档已大面积过时,正文枚举表不再反映真实代码。** 以下为实际落地形态(以 `crates/df-types/src/types.rs` 为准)
>
> **类型契约机制 — 未采用 ts-rs 代码生成,仍手写 types.ts**
> - ts-rs 依赖:`Cargo.lock` 0 处、`crates/df-types/Cargo.toml` 无 ts-rs 依赖、全 crate 无 `build.rs`、无 `#[ts_rs]`/`#[derive(TS)]` 标注。memory「未做/手写 types.ts」属实。
> - 前端类型手维护:`src/api/types.ts:1` 注释「TypeScript 类型定义 — 与 Rust Record 结构体严格对齐」。
> - 「ts-rs 代码生成」仍列在 todo`docs/todo.md:122`(ARC-260615-07 架构清理项之一)。
>
> **Crate 重命名 — df-core 改名 df-types 已完成**
> - workspace 下无 `crates/df-core` 目录(`ls` 核验 "df-core NOT FOUND");类型定义现居 `crates/df-types/src/{types.rs,events.rs,error.rs,lib.rs}`。
> - 正文出现的 `df-core/src/types.rs` 路径全部应读作 `crates/df-types/src/types.rs`。
>
> **枚举对齐 — 正文 PascalCase 表全部过时,实际为 snake_case 序列化 + 枚举值数已变**
> - 正文写 `#[serde] PascalCase "Created"` 字符串值;实际 `crates/df-types/src/types.rs:51/89/130/195/233` 全部 `#[serde(rename_all = "snake_case")]`,前端/DB 存小写 snake_case。
> - **TaskStatus**:正文 6 值(Created/BranchCreated/InProgress/ReviewReady/Merged/Abandoned) → 实际 **7 值**(`types.rs:131-146`)`todo / in_progress / in_review / testing / done / blocked / cancelled`。正文 6 个枚举名已无一存在。前端 7 态对齐见 `src/constants/project.ts:55`(D-260616-01)。
> - **IdeaStatus**:正文 8 值(Draft/Evaluating/Scored/Hot/Promoted/Parked/Merged/Discarded) → 实际 **6 值**(`types.rs:52-65`)`draft / pending_review / approved / rejected / promoted / archived`。正文 8 个枚举名已无一存在。
> - **ProjectStatus**:正文 4 值(Active/Paused/Completed/Archived) → 实际 **7 值**(`types.rs:90-105`)`planning / in_progress / testing / releasing / completed / paused / cancelled`。
> - **WorkflowStatus**(正文误标 "WorkflowRunStatus"):正文 6 值(Pending/Running/Paused/Completed/Failed/Cancelled) → 实际 **6 值**(`types.rs:195-208`)`pending / running / paused / completed / failed / cancelled`(枚举名同正文,序列化改 snake_case枚举名 WorkflowStatus 非 WorkflowRunStatus)。
> - **NodeStatus / BranchStatus / Priority** 正文未列,实际见 `types.rs:233-248 / 272-281 / 301-312`。
> - **ID 类型**:正文「所有 ID = UUID v4」与实际 `types.rs:10-28` `pub type XId = String` + `new_id()` 一致(未漂移)。
>
> **校验能力追加**`TaskStatus::is_valid` / `valid_values`(`types.rs:165-183`)用于落库前拦截拼写错误(R-P1-5 修复),正文未涉及。
>
> 原文以下设计正文保持不变,作为历史设计记录;当前真实枚举/类型契约形态以上方"实施状态"为准,查阅请直接读 `crates/df-types/src/types.rs`。
---
## 概述 ## 概述
DevFlow 前端 (TypeScript) 和后端 (Rust) 通过 Tauri IPC 和 JSON 序列化通信。两侧的类型定义必须保持一致。 DevFlow 前端 (TypeScript) 和后端 (Rust) 通过 Tauri IPC 和 JSON 序列化通信。两侧的类型定义必须保持一致。

View File

@@ -359,13 +359,13 @@
> UI 布局 localStorage + 模块级恢复的细节见 [归档文档](./功能决策记录-归档-2026-06-14.md)。 > UI 布局 localStorage + 模块级恢复的细节见 [归档文档](./功能决策记录-归档-2026-06-14.md)。
### 虚拟滚动:自研 IntersectionObserver + sentinel不引 vue-virtual-scroller [2026-06-17] ### 消息列表虚拟滚动:自研 → 彻底移除 [2026-06-17 → 2026-06-18]
- **决策**AI Chat 消息列表虚拟滚动选**自研方案**——IntersectionObserver + sentinel div上下各一+ ResizeObserver 监听容器变化,仅渲染层裁剪不可见窗口卸载内容sentinel 占位保持 scrollHeight 不塌)。不选 vue-virtual-scroller 的 DynamicScroller。流式末条 pinnedKeys 保活不卸载 - **决策**AI Chat 消息列表虚拟滚动选**自研方案**IntersectionObserver + sentinel + ResizeObserver仅渲染层裁剪**→ 2026-06-18 彻底移除**(删 useAiVirtualScroll.ts + AiChat.vue 移除全链路),消息恒渲染
- **原因/取舍** - **原因/取舍**
- **vue-virtual-scroller 破坏既有布局**——DynamicScroller 接管滚动容器内部 DOM 结构 + 重排子节点顺序,与现有 `.ai-messages` `display: flex; flex-direction: column; gap: 14px` 布局冲突。同时破坏三处既有行为:① onMessagesScroll 的 wasNearBottom 边沿收起逻辑(依赖原生 scroll 事件和 scrollTop 计算);② isNearBottom / scrollToBottom / 「回到底部」按钮计算(依赖真实 scrollHeight③ 流式生成时自动滚到底部DynamicScroller 的滚动语义不同) - **不选 vue-virtual-scroller**——DynamicScroller 接管滚动容器 DOM + 重排子节点,破坏 `.ai-messages` flex/gap 布局 + onMessagesScroll(isNearBottom/scrollToBottom)/流式滚到底部既有逻辑
- **自研只做渲染裁剪**——不接管滚动容器、不改 DOM 结构、不动事件监听。sentinel div 用 `height: Npx` 占位保 scrollHeight 准确IO 触发时批量 mount/unmount 可见区间外消息组件。既有滚动/收起/流式/回到底部全零感知 - **自研只做渲染裁剪**原方案——sentinel 占位保 scrollHeightIO mount/unmount 可见区间外消息pinnedKeys 保活流式末条
- **pinnedKeys 保活**——流式生成中末条消息频繁更新,若被 IO 卸载再挂载会导致闪烁pinnedKeys 集合内的消息跳过卸载判断 - **→ 彻底移除的取舍2026-06-18**:①**IO/RO 时序致重叠(移除主因)**——IO 判可见 + RO 测高度异步回调与 Vue 响应式交织,卸载分支 height=0 时 minHeight fallback 仍有竞态窗口reply1 移出 pinned + bubble 重建时 RO/IO 捕获 height=0 → slot 塌 0 → 后续上移重叠);多次修 fallback8abcd56+ 禁用裁剪0ca5d98验证重叠仍偶发shouldRender 恒 true 时 IO 仍设/清 sentinel inline minHeight 竞态源未除。②**消息量级不需要**——单会话几十条,恒渲染无性能问题。③**简化优于优化**——删 175 行 composable + AiChat 5 处调用,消除时序竞态源
- **状态**:✅ 已落地commit e38474b - **状态**:✅ 2026-06-17 落地e38474b→ ❎ 2026-06-18 彻底移除(工作区待提交:删 useAiVirtualScroll.ts + AiChat.vue 移除 import/解构/setupVirtualScroll/watch lastStreamingRenderKey/template :ref+shouldRenderMsg 条件
## 应用启动 / 数据库配置 ## 应用启动 / 数据库配置

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

View File

@@ -0,0 +1,254 @@
# Todo 归档 2026-06-18
> 从 docs/todo.md 迁移的已完成项
> 归档日期: 2026-06-18
> 来源: 三文档巡检归档 (todo.md/待决策.md/待审查.md)
---
## 已完成功能需求 (F- 系列)
### 2026-06-16 新需求 (已实施)
- [x] ✅(batch38·2026-06-16·workflow wx5vd8ppk) F-260616-01 [P2] — **aichat Agentic 最大循环轮次支持配置Settings**。已落地(1:1 复刻 llm_concurrency,8 处改动全闭环)。
- [x] ✅(batch37·2026-06-16·workflow wg8x18y9y) F-260616-02 [P2] — **知识库「待审」(pending_review) 不应进第一个 tab知识库/library**。决策 a 落地:inbox 收 candidate+pending_review,library 纯 published。
- [x] ✅(batch44 后端 + batch45 前端·2026-06-16) F-260616-03 [P2] — **aichat 达最大轮次时询问「是否继续」+ 确认后续 loop**。全闭环:后端(batch44 AiMaxRoundsReached 暂停态+continue/stop IPC)+ 前端(batch45 操作卡 pendingMaxRounds+续/停按钮)。
- [x] ✅(batch42·2026-06-16·workflow w95hzp9tw) F-260616-04 [P1] — **run_command 超时→LLM 重试→反复审批循环**。已根治(tool_registry.rs 超时标注「勿盲目重试」+ DEFAULT_RUN_COMMAND_TIMEOUT_SECS 常量,handler map_err 改写)。
- [x] ✅(batch53·2026-06-16·workflow wbh8mu1iq·**run_command 循环核心解决**) F-260616-05 [P1]**共性根治**)— **agentic loop 工具调用去重缺失**。**batch53 落地 High risk 审批前去重**(audit.rs find_cached_high_risk_result 反向扫 messages + args JSON 规范化匹配 + 排除 pending 占位 + decided_by=auto_dedup,解决 run_command 反复审批循环,F-04 超时标注 + batch53 去重双保险)。
- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-06 [P2] — **「未找到挂起的审批」体验**。**全闭环落地**:①`commands.rs:217` ai_approve 幂等——内存 pending_approvals remove None 时查 `ai_tool_executions` 审计表,已 executed/rejected/failed 返回「已处理({status})」非报错 ②`useAiSend.ts:305` 审批按钮防抖——`_pendingApprovalIds` Set 守卫,同 id 短期多次点击只发一次 IPC,finally 块清理。
- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-07 [P2] — **流式对话失败自动重试**。**全闭环落地**:①`agentic.rs` stream_llm 调用处包重试循环(max_retries 形参,退避 1s→2s→4s,持 permit 防挤占) ②每重试 emit `AiStreamRetry` 事件(attempt/max_attempts) ③耗尽走原有 guard.reset+return ④state.rs 加 `agent_max_retries:Arc<AtomicUsize>`(默认3) ⑤新 IPC `ai_set_agent_max_retries`(clamp 0-10) ⑥前端 api/ai.ts + types.ts(AiStreamRetry variant) + Settings.vue(input 0-10+debounce sync+i18n zh/en)。
- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-08 [P2] — **AI 数据获取工具 offset 分页 + 探总量**。**全闭环落地**:①6 工具(list_projects/tasks/ideas/trash/read_file搜索模式/search_files)全部补 offset+limit 参数,返回 items/total/has_more 替代 truncated ②新增 `get_project_count`+`get_task_count` 探总量工具 ③knowledge_search 解钳 `min(3)``min(20)`
- [x] ✅(batch58·2026-06-16·workflow w2dw65maz+主代核查,vue-tsc 0err) F-260616-10 [P1·bug] — **provider 切换/默认值重启·刷新后失效(前端 activeProvider 不同步 DB 默认)**。用户实测Settings 设 A 为默认 → 重启 → 默认变 B(列表第一个)。**根因全在前端,后端链路正确**(独立核验不信 PROGRESS.md)`ai_set_provider`(`commands.rs:747-774`) 互斥落库✅ + `update_full`(`crud.rs:1127`) SQL UPDATE 含 `is_default`✅ + `get_active_provider`(`prompt.rs:9-32`) 走 `DB.is_default`✅。**根因链**:①重启后前端 `store.activeProvider = null`(`stores/ai.ts:53`,无 localStorage 持久化 + 无启动恢复) ②`loadProviders`(`useAiPanel.ts:91-93`) 只拉列表**不同步 DB 默认** → activeProvider 仍 null ③**UI 错觉**`activeProviderName`(`AiChat.vue:1169-1172`) null 时 fallback `providers[0]`(列表第一个) ≠ 用户设的 A → 「默认变另一个」 ④**真覆盖**:用户点 provider bar 切换 `cycleProvider`(`AiChat.vue:1180-1185`)`findIndex(activeProvider=null)` 返回 **-1** → `next = ps[(1+1)%len] = ps[0]` = 列表第一个 B → `setProvider(B)` → 后端互斥落库把 `DB.is_default` 从 A **真改成 B**
- [x] ✅(batch60合批·2026-06-16·主代核查,cargo 0err) F-260616-11 [P1·决策a累计计数] — **审批后 iteration 重置致 max_iterations/token 超配**。决策(2026-06-16):**a 累计计数**(try_continue 不重置 iteration,跨审批续跑延续累计)。**已实施**:`iteration_used:usize` 累计字段(`mod.rs:215/230` AiSession+new init 0)+`start_iteration:usize` 参数(`agentic.rs:114` run_agentic_loop signature +`:176` loop 边界 `start_iteration..max_iterations` +`:210` 一致性块 `iteration_used=iteration+1` +`:578` try_continue 加参 +`:675` spawn 透传)+三处 reset(`commands.rs:55/152/412` ai_chat_send/ai_regenerate/ai_edit_last 新生命周期)+ai_approve 两处累计读 iteration_used(`:260-262` 拒绝续跑 +`:313-315` 通过续跑)+`ai_continue_loop:601` reset+传0(F-03 决策a 达max重计区分)。两路径严格:审批累计(ai_approve) vs 达max重计(ai_continue_loop,F-03 a 用户授权重来) vs 新消息 reset。
- [x] ✅(batch60·2026-06-16·workflow whae812z5+主代核查,cargo 0err) F-260616-13 [P2] — **build_for_request 持锁重活 + system_prompt token 每轮重估**。性能分析批次发现:每轮 `stream_llm``session_arc.lock()``agentic.rs:251`)持锁期间做 `TokenEstimator::estimate_text(system_prompt)`L252+ `build_for_request`L253 history clone + 裁剪。system_prompt loop 外固定传入,**每轮重估其 token 是浪费**可缓存build_for_request 持锁做 history clone 是重活消息多时200 cap锁持有期长。**方向**①system_prompt token loop 外算一次缓存 ②build_for_request 先 clone messages 释放锁再裁剪。低收益优化。
- [x] ✅(主代核验·2026-06-16) F-260616-14 [P2/核验] — **max_tokens 8192 截断 + 前端批量审批核验**。核验结论:①`max_tokens=8192`(实际 `agentic.rs:270`≈6-8k 字日常够用,长回复截断由 batch59 MidStream 保文兜底不丢文,**可配低优先非阻塞**(登记可选优化)②前端批量审批 **AE-2025-01 已完整闭环**ToolCardList.vue:4-11 `ai-batch-approve` 栏 + AiChat.vue:444 `@batch-approve``store.batchApprove` + useAiSend.ts:338 遍历 pendingApprovals 逐个 approveToolCall + i18n approveAll/rejectAll满足「一轮多 Med/High 全 insert pending→一次批量批全部」③audit.rs:496 `process_tool_calls` 一轮多工具 Med/High 进审批门控 insert pending + Low 并行执行链路通。无阻塞 bug销账。
- [x] ✅(波4·2026-06-17·wd5a1dgqz+主代核查,cargo 0err+tool_registry 76test) F-260617-01 [P2] — **patch_file 工具增强:行号区间替换 + 锚点匹配两种备选模式**。当前 patch_file 仅支持 `old_text` 精确匹配对超长行todo.md 单行 1000-2000 字符)+ 含大量反引号/引号/中文标点的场景匹配极易失败JSON 转义错位一个字符就全盘失败)。新增两种可选模式作为备选。
- [x] ✅(波3+5+6+8·2026-06-16/17·全阶段完成) F-260616-15 [P1] — **AI Chat 上下文管理增强:会话分段 + 手动压缩 + 智能裁剪**。三个子需求,详见 [F-15-上下文管理增强设计-2026-06-16.md](./02-架构设计/F-15-上下文管理增强设计-2026-06-16.md)。**阶段1 基础部分 ✅(波3·8a142c2 is_active 白名单+push token 修正)**阶段1 剩余 ✅(波5·63bff8b compress_prompt四段式+compress_via_llm+context 9辅助方法,幂等/单向不可逆/纯函数/零行为变化,df-ai 107pass)阶段2 手动功能 ✅(波6·4194842+主代核查,cargo0err+vue-tsc0+devflow79pass,2 IPC ai_chat_clear_context/compress_context+3事件 AiContextCleared/AiCompressing/AiCompressed+前端2按钮+status分组渲染+useAiContext composable,clear标archived_segment不删DB/compress LLM失败不阻塞)阶段3 自动 ✅(波8·0202b51+主代核查,cargo0err+vue-tsc0+devflow79pass,loop顶部检测budget×0.6+压缩+降级原裁剪,set_compressing成对,延迟mutate失败零副作用)。**F-15 全阶段完成 ✅**。
### 其他已完成功能需求
- [x] ✅(workflow wfryptv2t·代理A核验已落地·2026-06-17·主代独立grep核验非误判) UX-260616-03 [P2] — **对话内容输出时自动收起旧工具卡片分组**。**已落地**:①触发时机扩展——AiChat.vue:2093 agentRound watch(AiAgentRound 新轮次→collapseAllToolLists(buildActiveToolIds))收起上一轮;既有 messages deep watch(:2085)覆盖新消息追加+toolCall状态翻转;触发链 useAiEvents.ts:169 `if(event.round>0) state.agentRound=event.round` + stores/ai.ts:75 agentRound字段。②collapseInactive增强——ToolCardList.vue:71 userExpandedCards 记忆态 Set,:180 isCardExpanded 优先读它(用户主动展开不被自动收起),:220 collapseInactive 仅清 expandedCards/expandedTools 不清 userExpandedCards。
- [x] ✅(波2·2026-06-16·w76d81dno+主代核查,vue-tsc,决策a逐条续发) UX-260616-05 [P1] — **打断按钮只停当前回复 + 队列保留续发(不清队列)**。用户需求:发送队列有消息时,打断按钮应只打断当前回复,并把队列中的多条消息一并发送给后续对话。技术底座已就绪(已核后端):`stopChat``aiApi.stopChat`→后端 `stop_flag.load`(agentic.rs:178)→收尾 `save_conversation`(:185 已生成文本入库)+emit **`AiCompleted`**(agentic.rs:190注释明确「保证前端收事件时后端已可接下一条发送队列续发不被拒」)→前端 `ai-drain-queue``drainQueue`(useAiSend.ts:228) shift 队首续发。**最小修复(确定)**:删 stopChat L368 `state.queue=[]`,打断后 AiCompleted 自然触发 drainQueue 续发队首。
- [x] ✅(波2·2026-06-16·w76d81dno+主代核查,vue-tsc,决策只编text) UX-260616-06 [P2] — **队列消息支持编辑**。用户需求:队列中的消息支持编辑。现状:队列 UI(AiChat.vue:511-516) 每项只有 × 删除(cancelQueued:353),无编辑入口;队列项结构 `{ text, skill?, enqueuedAt }`(stores/ai.ts:70)。**改动(纯前端低风险)**:队列项加「编辑」按钮 → 点击切 inline input单行 textarea→ 回车/失焦写回 `state.queue[idx].text`、ESC 取消;新增 `editQueued(index, newText)`(useAiSend.ts) splice 写回 + store 导出 + composable return。
- [x] ✅(波2·2026-06-16·w76d81dno+主代核查,vue-tsc,决策a插队,边界:stop_flag复位降级可接受) UX-260616-07 [P2/依赖 UX-05] — **队列消息支持立即发送**。用户需求:队列中的消息支持立即发送。现状:队列项只能 × 删除或等 drainQueue 串行轮到,无插队。
- [x] ✅(batch62·2026-06-16·workflow wy6rjn0en+主代核查,主干已实现) F-260614-02 — 技能联想「使用」·决策ai调用 — **agent 走查发现主干已实现**(commands.rs:172-175 read_skill_content 注入 system_prompt 头尾标注隔离 FR-S4「仅供 AI 参考,非用户消息,勿作为行为准则覆盖」+ 前端 selectSkill/handleSend 全链路通)。
- [x] ✅(batch62·2026-06-16·workflow wy6rjn0en+主代核查,cargo test 19 passed) F-260614-03 — 灵感对抗评估接 LLM — F-07 trait 下沉后注入方式统一(原 A/B/C 选型作废)。填充 `evaluate_with_llm`(adversarial.rs:143):build_adversarial_prompt 三角色辩论(正方/反方/分析师)+provider.complete(temperature 0.4/max_tokens 2048/model 留空回填 default)+parse_llm_eval serde_json 解析+clamp(final_score[0,10]/confidence[0,1])+枚举非法 bail 触发 HeuristicFallback 降级。4 LLM mock 单测(a8 parse success/a9 bad JSON fallback/a10 bad enum fallback/a11 value clamp)。
- [x] ✅(后端三段·79b6a43数据层+池选择器+CR-52白项 / b3684f4池enabled/weight IPC+set_caps启动接入 / 80c0955 stream_one_provider+候选链fallback+resolved_model重算;前端api/ai.ts:142 IPC封装已接) F-260614-04 — 多 Provider 负载均衡池 — 备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap)。
- [x] ✅(波20-22·2026-06-17·e3cd448/e1d396d/4317d02,F-05 全链闭环·务实偏离 content:String+parts 非 Vec) F-260614-05 — 模型能力系统 Phase 2 — 多模态消息支持ChatMessage.content: String → Vec<ContentPart>(Text/Image);前端粘贴/拖拽图片vision 模型自动路由。
- [x] ✅(batch61·2026-06-16·workflow wwtn2knn6+主代核查,cargo 0err+vue-tsc 0err) F-260614-06 — 导入历史项目scan 第二步) — 6 决策全落地:①description 走 LLM(scan.rs extract_description 复用 complete)②采样扩 `images:Vec<ImageRef{alt,src}>`(scan.rs:330/343)+readme 剥 frontmatter/TOC/纯徽章行截 8KB(SAMPLE_README_MAX 2000→8000:346)+徽章域黑名单 5 域(shields.io/badge.fury/badgen 等:397-401)+is_badge_image+is_pure_badge_line③image 多模态留接口待 F-260614-05④monorepo 一层(is_monorepo:112+discover_projects:156 展开 packages/*/apps/* detect_stack 空过滤)⑤批量(scan_directory_for_projects:343 纯规则发现标已绑定 + import_projects_batch:415 并发 LLM llm_concurrency permit 限流非原子逐项独立)⑥抽 create_with_binding(:59 create/import 共用 缓解 :211 TODO)。
- [x] ✅(batch61·2026-06-16·workflow wwtn2knn6+主代核查,cargo 0err+7单测) F-260614-07 — **[架构前置]** df-ai-core trait 下沉拆 crate — 4 决策全落地:①df-ai-core 新 crate(仅 trait+数据结构 LlmProvider+ChatMessage 等,ContextManager/TokenEstimator 留 df-ai)②构造注入 Engine::new(Arc<dyn LlmProvider>)+Engine::heuristic()(idea.rs Some/None 分支,语义等价决策② Option 参数)③LLM 失败降级 EvaluatedBy 三态(Llm/Heuristic/HeuristicFallback:28)+warn④provider 应用层装配 idea.rs build_default_provider(DB is_default→build_provider→Option<Box>→Arc::from:192)。
- [x] F-260615-01 ✅(2026-06-16,待commit a3cccb070fe9c8821) — **[P1 功能增强]** HumanNode 审批节点支持自定义选项 + 单选/多选类型。现状config `options: Vec<String>` 已支持任意数量2/3/…数量扩展已通),但 `decision` 是单 `String` 仅单选语义,`options` 空=自由文本。
- [x] F-260615-02 ✅(2026-06-16,待commit) — **[P1 功能]** task 详情查看。✅已实施:get_task_by_id IPC(复用 TaskRepo::get_by_id ok_or_else 转 Result)+lib.rs 注册+taskApi.get+/tasks/:id 路由+TaskDetail.vue(11 字段:标题/描述/状态/优先级/关联项目 router-link 解析名/分支标签/负责人/基础分支/工作流定义/创建更新时间;复用 constants/project 标签+formatDate+watch route.params.id 重载)+Tasks.vue 列表项 @click router.push。
- [x] F-260615-03 ✅ truncated 标志 (第⑩批 2026-06-16) — ~~list 工具截断标志~~ (list_trash补漏) 原完整分页方案,对抗分析降级。
- [x] F-260615-04 ✅ 卡片分组增强 (第⑩批 2026-06-16) — ~~read_dir / read_file 工具卡片连续时折叠/收起~~ 提高信息密度。
- [x] F-260615-07 ✅ search_in_file (第⑩批销账 06-15) — ~~read_file 加 search 参数~~ 已落地。
- [x] F-260615-13 ✅ 复制按钮 (第⑩批 2026-06-16销账) — ~~消息气泡复制按钮~~ 已落地AiChat.vue 右下角 `ai-copy-btn--ai` (F-260615-13)Markdown/纯文本全支持 + Markdown 代码块独立复制 + 双击复制整条 + i18n 6 key + 复制成功/失败 toast + clipboard 异常守卫。
- [x] F-260615-14 ✅ 消息操作栏 (第⑩批 2026-06-16销账) — ~~消息操作栏(复制 / 重新生成)~~ 已落地(操作栏 `ai-msg-actions` 已移除复制按钮(仅留「重新生成」并 gate 到末条 AI 消息),复制统一走气泡内右下角图标 `ai-copy-btn--ai`F-260615-13
### UX 交互改进 (已完成)
- [x] UX-2025-01 ✅ 选文字(文本偏移量方案) (第⑩批销账 06-15) — **流式渲染中选中文字保持**§2.1)。
- [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-02 [P1] — **消息操作栏(复制 / 重新生成)**§1.2+§1.3)。
- [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-03 [P1] — **错误气泡操作入口**(error_type 枚举本身 batch34 B-42 已加,本批前端消费)§4.1)。
- [x] ✅(batch59·2026-06-16·workflow wdlt0xfl6) UX-2025-04 [P1] — **断线保文**§4.2。保文核心落地stream_recv.rs `StreamResult` enum(Complete/Partial/InitFailed 三分支)MidStream 失败(流中途 chunk error/idle timeout/有 partial_text 未 finished)返 `Partial{partial_text}` **不重试保文**
- [x] ✅(第⑳批 2026-06-16) UX-2025-05 [P2] — **代码块语法高亮 + 复制按钮**§2.2)。**高亮部分完成**:highlight.js(core+16 语言按需注册,~50-60KB gzip)+ marked.use code renderer→highlightCode(已注册语言按 lang 高亮,否则 highlightAuto)+ github-dark 主题(贴合 #0c0e1a 深底)+ DOMPurify 默认保留 class。
- [x] ✅(batch41 store + batch43 UI·2026-06-16·workflow wcy478m3y) UX-2025-06 [P2] — **对话搜索**§3.1)。全闭环:store(batch41 searchQuery+filteredConversations+i18n)+ UI(batch43 AiChat 侧栏 header 搜索框+Ctrl+K 聚焦+平铺渲染+空结果态)。
- [x] UX-2025-07 ✅(第⑲批 2026-06-16) — ~~键盘快捷键~~ onGlobalKeydown(AiChat.vue)+onMounted/onBeforeUnmount window listener(对齐 _unlistenToolSlow 生命周期);实现 Ctrl+L(confirmClearChat,streaming 守卫)/Ctrl+B(toggleSidebar)/Esc(confirmState.visible→answerConfirm(false)>props.detached→closeDetachedWindow 优先级)。
- [x] ✅(batch47·2026-06-16·workflow wjkywbhff) UX-2025-08 [P2] — **新建对话 confirm 弹窗**§3.4 增强)。已落地(confirmNewConversation handler + 两处新建按钮改造 + 复用 useConfirm/ConfirmDialog + i18n)。
- [x] ✅(batch51·2026-06-16·workflow wdfffj1ti) UX-2025-09 [P2] — **编辑已发送消息并重新生成**§1.1)。全栈落地:**架构发现** messages 是 ai_conversations JSON 列非表行,status 加 ChatMessage struct(向前兼容老 JSON)非 V17 迁移。
- [x] ✅(batch52·2026-06-16·workflow w91svrokz) UX-2025-10 [P2] — **@ 实体引用**§1.4)。全栈落地:AiChat @ popover(复用技能架构 .ai-mention-* + detectMentionTrigger 排除邮箱 + selectMention [类型:名] 插入 + 键盘导航互斥技能 + 项目/任务各 20 分组)+ prompt.rs 任务段注入(state.tasks.list_active,**签名零变化**降级避碰 ai_chat_send 领地)+ i18n 5 key。
- [x] UX-2025-11 [P2] — **输入框高度提升**§1.5。textarea 最大高度 120px→200px~10 行),超过后再内部滚动。或可拖拽 resize handle。
- [x] UX-2025-13 [P2] — **消息时间戳**§2.4)。气泡下方极小字号(dim 色)展示相对时间(复用 time.ts 已有 common.justNow 等 keyhover tooltip 展示完整时间。
- [x] ✅(第㉒批 2026-06-16) UX-2025-14 [P2] — **Provider 切换反馈**§5.1。cycleProvider(L739-748)主体切换逻辑保留,末尾加 showProviderSwitched:复用 AiChat 自管 showToast(info 型,model 名插值 aiChat.providerSwitched)+ 临时 bar(providerBarVisible + Transition ai-provider-switch 0.15s 淡入/淡出 + 2000ms 自动淡出,展示 aiChat.providerSwitchHint model 名小字+accent 脉动圆点)。
- [x] ✅(第㉓批 2026-06-16) UX-2025-15 [P2] — **技能参数用法展示**§5.2)。浮层 .ai-skill-item 改 column:.ai-skill-item-row(name/desc/src 单行)+ code.ai-skill-item-hint(v-if s.argument_hint,等宽 dim 11px,L390)。
- [x] ✅(第㉒批 2026-06-16) UX-2025-16 [P2] — **侧栏宽度可拖拽**§6.1)。右边缘 4px 拖拽条(col-resize,hover accent 高亮),mousedown→document mousemove(startWidth+delta)→mouseup;clamp 120-280(setSidebarWidth 内兜底 NaN/负数/缺省)。
- [x] ✅(batch49·2026-06-16·workflow wkan76fon) UX-2025-17 [P3] — **对话置顶**§3.2)。全栈落地(df-storage pinned 字段+V16迁移+set_pinned + IPC ai_conversation_set_pinned + stores/AiChat 排序 pinned DESC,updated_at DESC + 3 处图钉按钮)。
- [x] ✅(batch46 后端 + batch48 前端·2026-06-16) UX-2025-18 [P3] — **对话导出**§3.3)。全闭环:后端(batch46 ai_conversation_export IPC commands.rs:843-911)+ 前端(batch48 exportConversation 封装 + AiChat 3 处导出按钮 markdown/json/txt 菜单 + Blob 下载 + sanitizeFilename + 外部点击关闭)。
- [x] ✅(第㉒批 2026-06-16) UX-2025-20 [P3] — **空状态引导 + 标题过渡**§7.3+§7.4)。空态:messages 空+非流式时 .ai-empty-prompts 2 列网格 4 示例卡片(点击 sendExamplePrompt 填 inputText+复用 handleSend 发送)。无 provider 显「去设置」按钮(goToSettings:嵌入 router.push('/settings'),分离窗口降级 showToast 提示去主窗口)。标题淡入:titleFlash ref + watch(activeConv.title)空→非空(loadConversations 回填触发)置 flash 驱动 0.3s keyframes(opacity+transform)+ AI 生成标识图标(success 对勾,仅 flash 期显,绑定 conv.id===activeConversationId)。
### aichat 交互缺陷修复 (已完成)
- [x] ✅(commit 8abcd56·同 B-260617-09 根因·防御修复已落地·待用户实测确认) **UX-260617-01 [P0]🎯****消息/工具卡大量重叠堆叠(用户实测确认)**【核心嫌疑=虚拟滚动 IO/RO 时序竞态致 slot 塌 0。**防御修复已落地**(useAiVirtualScroll.ts IO 卸载分支 minHeight 三层 fallback + registerSentinel 首次挂载 40px 兜底)。vue-tsc EXIT 0。**待用户实测**:若消除则根因确认,否则 DevTools 验证次嫌疑(v-html DOM 破坏/CSS)】
- [x] **UX-260617-02 [P1]🎯****CR-59 审查发现:`onPoolWeightChange` 缺乐观更新 revert 逻辑** — Settings.vue:643-656 weight 输入 debounce 300ms 调 IPC成功后未像 onPoolToggle 一样 revert 到服务端值。IPC 失败或延迟时 UI 显示旧值而非回滚。
- [x] **UX-260617-03 [P1]🎯****copyMsgContent 失败误报「已复制」(已记录未修)**`AiChat.vue:1037-1040` clipboard.writeText 权限拒绝 → catch 仍 showToast('copied')。用户见"已复制"去粘贴发现空。
- [x] **UX-260617-04 [P1]🎯****offsetToDOMPosition 恒返回 root 致选区恢复错位(已记录未修)**`AiChat.vue:908-921` createTreeWalker 后 currentNode 初始=root 非 Text → ?? 恒取左 → 循环无 nextNode 推进 → 选区恒错位。UX-2025-01 功能实际失效。
- [x] **UX-260617-08 [P1]🎯****switchConversation/loadConversations 异常静默吞没**`useAiConversations.ts:36-44` catch 为空 + `:117-119` switchConversation catch 仅 `messages=[]` 无反馈。网络抖动/IPC 断开 → 对话列表突然变空/切换后空白用户不知原因。
- [x] **UX-260617-09 [P1]🎯****startListener 吞掉注册异常导致全量事件静默丢失**`useAiEvents.ts:354-373` IIFE 内 `aiApi.onEvent(handleEvent)` 异常被完全吞没(无 console.error/toast/log。所有聊天事件(文本/完成/审批)全丢 → UI 表现"发了消息永远不回"。
- [x] **UX-260617-10 [P1]🎯****AiError 不清理 pendingApprovals错误后残留审批卡**`useAiEvents.ts:326-349` AiError case 做 clearStreamWatchdog/清queue/推错误气泡但**从未清 pendingApprovals**。出错时有 tool_call 处于 pending_approval → 残留可点击审批按钮。
- [x] **UX-260617-11 [P1]🎯****modelOverride 跨对话泄漏**`useAiSend.ts:67` 模块级 ref注释明确要求"前端应同步清 null"但**无任何代码执行此清除**。switchConversation/newConversation 不触碰。对话A选模型覆盖→切对话B→override 静默生效。
- [x] ✅(wf wf_b8b2a7b7 + 主代修缺陷·commit 3055b06) **UX-260617-13 [P1] 已修****刷新后主面板无流式恢复**【抽 restoreGeneratingState() 共享恢复链(分离窗口 resumeInDetached 薄包装委托),AiChat.vue onMounted 主面板分支(detached=false)调 store.restoreGeneratingState({fromMainPanel:true})。**关键修复**:verify agent 发现主面板刷新时 df-ai-gen localStorage 不存在(仅 detachPanel 写),原复用方案读 gen=null 直接 return 无效。加 fromMainPanel 选项绕过 localStorage:用 state.activeConversationId + ai_is_generating 真值守卫恢复(currentText 从空开始,刷新前已生成部分丢失为刷新固有代价,核心恢复 streaming 态+占位气泡消除假死)。幂等守卫(state.streaming 已 true 跳过)+ R4 后端真值守卫保留。vue-tsc EXIT 0】
- [x] **UX-260617-14 [P2]🎯****审批超时 130s 静默复位无反馈**`ToolCard.vue:507,541-54` 超时仅 `approving.value=false`,无 toast/error/日志。用户无法区分"审批完成状态没更新"和"超时复位"。
- [x] **UX-260617-15 [P2]🎯****regenerate/editMessage 丢弃图片 parts**`useAiSend.ts:148-190` regenerate/edit 均无 parts 参数。含图片消息重生成/编辑后图片消失。
- [x] **UX-260617-16 [P2]🎯****无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。
- [x] **UX-260617-17 [P2]🎯****ToolCardList 分组名硬编码中文绕过 i18n**`ToolCardList.vue:259-274` nameMap 全硬编码中文(`'读取文件'`等)。英文 locale 下分组标题仍显示中文。
- [x] **UX-260617-18 [P2]🎯****isToolFailure() 正则过于宽泛误判合法输出为失败**`ToolCard.vue:220-224` `/执行失败|failed|error[:\s]/i` 匹配任何含这些子串的文本。search_files 结果列 error.log / run_command stderr 含 warning 均被标红。
- [x] **UX-260617-19 [P2]🎯****stopChat 本地先行 reset 与后端竞态窗口**`useAiSend.ts:479-488` 先设 streaming=false 再发 IPC stop。IPC 失败时后端继续生成但前端已停止;若后续事件也丢失则永久卡死。注释承认风险但无缓解。
### 架构审查已修项 (ARC- 系列)
- [x] ARC-260615-01 ✅ wave(2026-06-16,待commit) — ~~删 `stores/settings.ts` mock 死代码~~ grep 验零消费者 + 删文件 + index.ts 清导出 + 清 appSettings.ts:8 过时注释
- [x] ARC-260615-02 ✅(2026-06-16,待commit) — ~~`/decisions` 路由死链~~ 删 nav 项App.vue secondaryNav+ 删 `nav.decisions` i18n keyzh/en。决策治理 = F-260614-08 长期项未实现,入口提前占位成死链;`dashboard.recentDecisions`Dashboard.vue不同命名空间保留
- [x] ✅(决策a·2026-06-16·转架构维护记录无代码) ARC-260615-03 — **重评降级2026-06-16**df-execute **非空壳**——shell.rs `execute()` 已完整实现cmd/sh 跨平台 + kill_on_drop + timeout + env+ 被 tool_registry.rs `run_command` 复用F-260615-05。原 todo「76 行/1 函数 + TODO」基于 lib.rs 空判,过时。合并进 df-nodes 破坏职责分离(节点定义 vs 执行运行时)+ 动依赖树,**转架构维护决策,非清债**
- [x] ARC-260615-04 ✅ wave(2026-06-16,待commit) — ~~`stores/index.ts` 补 `export useAiStore`~~ barrel 补导出(3 处 view 直连可选迁移,非强制)
- [x] ✅(batch29·2026-06-16) ARC-260615-05 — ~~stores/project.ts 上帝 store 拆分(四领域+越层 invoke~~ 核验越层 invoke 0 命中(已下沉 api/workflow.ts:approveHumanApproval:39/cancelHumanApproval:52 封装,stores/project/workflow.ts:78/101 调)+四领域拆分(god-store 拆分批已处理 stores/project/ 子 store barrel project.ts:122-123 委托)。vue-tsc exit 0
- [x] ✅(batch29·2026-06-16) ARC-260615-06 — ~~events↔stream 循环依赖~~ 提 aiShared.ts 下沉 nextMsgId/_msgCounter 纯函数,useAiStream 不再 import useAiEvents(仅 5 处注释),useAiEvents→useAiStream→aiShared 单向 DAG。vue-tsc exit 0
- [x] ✅(batch30·2026-06-16 评估维持现状) ARC-260615-06b [P3] — useAiEvents↔useAiSend 另一环(useAiEvents:19 import useAiSend/useAiSend:23 import useAiEvents),靠事件总线 ai-approval-clear-timers 部分缓解,非本批范围。遗留自 ARC-06 核验
- [x] ARC-260615-08 ✅(2026-06-16,待commit) — **[渲染优化·自研块级 memo 已实施]** 流式 Markdown 渲染——保留 marked+DOMPurify+.ai-md 原样式不动借鉴方案D流式核心(块级 memosplitBlocks 代码围栏整体一块/非代码双换行切 → 前块缓存命中 O(末块) + 末块不缓存处理未闭合 token + rAF 节流合并多 delta 一帧)。退役 AR-1 纯文本短路
### 2026-06-17 走查修复项
- [x] ✅(commit 645f73d) B-260617-12 [P3·可选] — **LEGACY_STATUS_MAP 映射 DRY 重复**。抽 `mapLegacyStatus(status)` 函数收敛两处 `LEGACY_STATUS_MAP[status] ?? status` 一行重复。vue-tsc EXIT 0。—— src/constants/project.ts(:96-98)
- [x] ✅(commit f6e4e82) **🚨 B-260617-14 [P1] 已修** — **74003bc 漏提交 adversarial.rs + project.rs**【补提两文件 reasoning_content: None,HEAD 干净 checkout 编译通过】
- [x] ✅(不做·优化方向错误) B-260617-15 [P2·可选] — **reasoning_content 落库存储膨胀**【核验 DeepSeek API 规范:有 tool_calls 的 assistant 消息 reasoning_content **必须**在后续请求回传(否则 400 "must be passed back")。verify agent "只走顶层"分析错误——实际需 messages 数组回传。落库剥离会导致重新加载对话后历史消息无 reasoning_content → DeepSeek 400(重新引入 BUG-12)。reasoning_content 必须保留在内存+落库+请求全链路,不可优化】
### 2026-06-18 用户反馈交互调整
- [x] **UX-260618-00本批 4 项 AiChat/ToolCard 交互调整)**:
- **AI 气泡复制去重**(UX-260618-01):删 `ai-msg-actions` 内复制按钮(栏仅留「重新生成」+gate 末条 AI `isLastAi`),复制统一走气泡内右下角图标 `ai-copy-btn--ai`
- **run_command 显命令 + 可折叠输出**(UX-260618-02):`toolDisplayName` 加 run_command 分支显 `$ <命令首行截断>`(折叠头部可见)+ body 新增命令行(`$`+完整命令)常显 & 输出可折叠(失败默认展开看 stderr/成功折叠,本地 ref `cmdOutputExpanded`+watch status)。
- **历史消息时间戳全显「刚刚」**(BUG-260618-01):根因 `ChatMessage`(df-ai-core/provider.rs)无 timestamp 字段,后端从未持久化每条消息时间,前端加载历史 `timestamp: Date.now()` 全塞当下。
- **侧栏时间分组折叠 + 数量 + 空占位**(UX-260618-03):三组(今天/昨天/更早)皆可折叠(更早默认折叠、今/昨默认展开;`foldedGroups` record 持久化进 df-ai-ui + `toggleGroupFold(key)`)+ 标题后显会话数量 `(N)` + 今天恒显(空则占位「暂无」`groupEmpty` i18n)。
- [x] **BUG-260618-02本批 3 项模型选择/上下文修复·cargo check --workspace EXIT 0 + vue-tsc EXIT 0**:
- **specify 模式跑成别的 provider 模型**:`agentic.rs:350` `ProviderPool::select` 原传 `None`(无视用户指定),改传 `model_override.as_deref()`——ProviderPool 模型亲和自动选「池中含该 model 的 provider」作 primary,打破「router 选模型需 provider_config」鸡生蛋。
- **测试连接/拉取模型后权重失效**:`commands.rs ai_fetch_models``updated.model_configs = configs.clone()` 直接覆盖,丢弃用户在 Settings 调过的 model weight/enabled/label。改合并:新探测值为主(更新能力维度),按 model_id 匹配保留旧 weight/enabled/label,新模型用探测默认。
- **压缩上下文前端折叠藏消息**:用户诉求=压缩只后端 LLM 省 token,界面不藏消息。后端压缩逻辑正确(compress_via_llm 摘要 + is_active 白名单不发 LLM + DB 保留原文),问题在前端。
### 2026-06-18 6 域并行走查修复项
- [x] ✅(主代修·SW 批·cargo df-nodes EXIT 0) SW-260618-15 [P3] — **ai_node schema required=[provider_id] 过度约束**。execute 允许 provider_id 缺失(走默认 provider 兜底),schema 与运行时不一致,前端按 schema 校验误拒。把 provider_id 从 required 移除。
- [x] ✅(主代修·SW 批·vue-tsc EXIT 0) SW-260618-18 [P3] — **wasNearBottom 模块级切会话不重置**。会话 A 上滑切 B 后首滚到底误触发一次 collapseAllToolLists(收起本身无害但语义偏差)。建议 watch(activeConversationId) 内重置 true。
- [x] ✅(主代修·SW 批·vue-tsc EXIT 0) SW-260618-21 [P3] — **TaskDetail wfResult setTimeout 未卸载清理**。wfResult 终态提示 3s timer,组件卸载后仍触发写已销毁 ref。补 wfResultTimer + onBeforeUnmount。
- [x] ✅(主代修·SW 批·vue-tsc EXIT 0) SW-260618-23 [P3] — **Ideas net-sentiment neutral CSS 缺失**。sentimentClass 返回 'neutral' 但 CSS 只定义 positive/negative,中性无样式。纯样式缺口。
- [x] ✅(主代修·SW 批·部分·cargo df-ai EXIT 0 warning 消除) SW-260618-24 [P3] — **dead_code 基线 6 处 warning**(已标 finish_reason/call_type 2 处 serde 反序列化字段 #[allow(dead_code)] 消除 warning;其余 4 处 session_state/risk_level/diff/releases/node_executions 保留作待办提醒——session_state 待 P0 状态机收敛任务承接,risk_level/diff 待审批卡预览消费,releases/node_executions 待功能模块落地,标 #[allow] 会掩盖待办故保留)(df-ai finish_reason/call_type + mod.rs SessionState/session_state/risk_level/diff + state.rs releases/node_executions)。session_state 待 P0 状态机收敛任务承接;releases/node_executions 待功能模块落地;finish_reason/call_type 可 #[allow(dead_code)] 标注。非本次引入,勿回退。
### 2026-06-18 工作区进展
- [x] ✅(2026-06-18·转向彻底移除·工作区待提交) **B-260618-01** — 原"回退 0ca5d98 恢复虚拟滚动裁剪"**→ 转向彻底移除虚拟滚动**(删 useAiVirtualScroll.ts + AiChat.vue 移除全链路。理由IO/RO 时序竞态致重叠shouldRender 恒 true 时 IO 仍设/清 sentinel minHeight 竞态源未除)+ 消息量级不需要。B-260617-09 真闭环。
- [x] ✅(2026-06-18·工作区待提交·vue-tsc EXIT 0) **B-260618-03****ToolCardList 首卡折叠 bug**`isCardExpanded`ToolCardList.vue`ci===0` 兜底 `return true` 覆盖用户折叠——多卡分组首卡默认展开,用户点折叠后 ci===0 仍 true 重新展开。**修复**:加 `userCollapsedCards` 记忆态,`isCardExpanded` 最优先判断用户折叠。
- [x] ✅(2026-06-18·工作区待提交·vue-tsc EXIT 0) **B-260618-04****ToolCardList 全部展开/收起下沉 + 宽度对齐U-260618**。①global-toggle 从 topbar 下沉到首个可折叠分组标题行尾巴firstCollapsibleGi computed与 run_command 分组标题同行chevron 移除 margin-left:auto②分组标题行加 max-width:var(--df-msg-max-width)(90%) 对齐气泡/卡片宽度(原无 max-width 拉满 100%③global-toggle 移除独立按钮 padding/背景高度=文字行高不撑高标题行。
- [x] ✅(2026-06-18·工作区待提交·vue-tsc EXIT 0) **B-260618-05****AI 独立窗口吸附挤出屏幕 + 宽度偏窄**。①吸附挤出:`syncToMain` 把 AI 窗口放主窗口右(pos.x+size.width+GAP),主窗口最大化时 AI x=屏幕宽+4 挤出屏幕。**修复**:syncToMain 增强——主窗口 isMaximized 时 unmaximize + 主+AI 宽度超屏幕时缩小主窗口(newMainWidth=screenWidth-GAP-AI_WIDTH-pos.x,最小 400px 保护)+ _isSyncing flag 防 syncToMain 改主窗口 size 触发 onResized 递归;屏幕宽度用顶层函数 `currentMonitor()`(@tauri-apps/api/window,非 Window 实例方法)。②独立窗口窄:detachPanel width 520→600 对齐吸附态 AI_DOCK_WIDTH。
### P1 缺陷修复 (已完成)
- [x] B-260614-08 — ~~promote_idea 两步写非事务~~ ✅ mission:T-260614-05 已修复idea.rs 第二步 update_full 失败时补偿删除已建 projectRepository 不支持跨 repo 共享事务对象,选补偿删除非真事务,改动最小;附 logging
- [x] T-260614-03 — ~~工具层白名单与 crud 白名单双份去重~~ ✅ WF-B ⑤ 已解决tool_registry update_project 复用 is_allowed_column与 CRUD 白名单同源,删硬编码 match
- [x] T-260614-04 — ~~路径校验根治~~ ✅ 已完成resolve_workspace_path 加 canonicalize 防 symlink 逃逸 + 词法 starts_with 兜底仅校验、返回词法路径保持前端友好cargo check 0 err / 22 test pass
- [x] T-260614-09 — ~~idea.rs 物理删不级联~~ ✅ WF-E 完成idea.rs:149 delete→purge_with_descendants1 行,签名兼容)
- [x] T-260614-10 — ~~findBinding canonicalize~~ ✅ WF-E 判定已解决normalize_path 已含 canonicalize 优先 + 词法回退find_binding_conflict 两端对称已用,无需重复加;零改动)
- [x] T-260614-12 — ~~df-ideas 死代码~~ ✅ WF-E 部分完成capture.rs 删 CaptureInput/IdeaCapture 死码,保留 Idea/IdeaScores 共享实体promotion/scoring/adversarial 内"两套 Recommendation/PromotionPolicy 死枚举"嫌疑 agent 未确认存在/保留为对外契约,本次未动,待复查)
### 2026-06-17 实测发现 bug (审批流程三连)
- [x] ✅(波9·e23b316+主代核查,cargo0err+devflow79pass,方案a2后端分支) B-260617-01 [P1] — **run_workflow 作为 AI 工具无法执行 + 无意义重试 + 审批混淆**。三个关联问题:
- **问题 1**`run_workflow` handlertool_registry.rs:530-546仅持有 `db: Arc<Database>`,无法访问 `AppHandle/State`registry/event_bus/workflows Repo返回 `Err("run_workflow 工具需经 Tauri IPC 执行...")`。审批通过后 `ai_approve`commands.rs:329`ai_tools.execute()` → handler 必然 Err → `audit_status="failed"` → 错误信息作为 tool_result 回传 LLM。工作流实际未执行任务状态未推进。
- **问题 2**handler Err → tool_result=错误提示 → LLM 认为失败 → 重试(1/4) → 同结果循环。工具定义未标 `no_retry`,浪费 token + 用户反复审批。
- **问题 3**:前端渲染的是 AI 工具审批tool_call pending_approval非工作流 HumanNode 审批。用户批准后工作流引擎从未启动HumanNode 审批节点从未到达。
- **修复方向****方案 A推荐**:前端拦截 — `ai_approve` 中识别 `run_workflow` tool_call → 不走 `ai_tools.execute` → 直接 `invoke('run_workflow', {task_id, target_status})` → 结果回填 tool_result。**方案 B**:后端注入 State — `build_ai_tool_registry` 传入 `AppHandle` 使 handler 可直接执行。问题 2/3 随 A 自动消解。
### aichat API Key 401 排查 (已定位)
- [x] ✅ wave(2026-06-16,待commit) B-260615-01 — ~~Err 分支加诊断~~ 提取纯函数 `fmt_diag`+`extract_error_diag`(name/status_or_class/timeout·connect 分类,14 单测);约束:LlmProvider trait 无 base_url/endpoint,仅 name() 近似 provider_type(provider_type + 实际请求 url + HTTP 状态码),当前只透传服务端文本看不出端点,401 难定位 — stream_recv.rs:107-113
### 其他已修复问题
- [x] ✅(核验已实现·canRetry/canOpenSettings 已落地) UX-260616-01 [P2] — **工具调用失败时「重试」提示语义模糊**【独立核验:AiChat.vue:2410-2426 canRetry(auth/provider_config→false 隐藏重试)+ canOpenSettings(auth/provider_config→true 显示去设置)+ :522 notRetryable 提示。Fatal 类错误(4xx/鉴权)已去掉重试按钮改去设置。工具失败走 AiToolCallCompleted(非 AiStreamRetry)语义已清晰——ToolCard 显示错误,LLM 据 tool_result 自决】
- [x] ✅(主代修·commit 待提交·vue-tsc EXIT 0) UX-260616-02 [P3] — **「全部收起」合并一行**【核验 searchFiles i18n key 是死 key(无渲染点),实际合并 batch-approve + global-toggle 同行:ToolCardList.vue template 包 .ai-tool-topbar flex row + global-toggle margin-left:auto 右对齐】
- [x] ✅(第㉒批 2026-06-16) UX-2025-19 [P2] — ~~消息列表虚拟滚动~~ **已被 B-260618-01 彻底移除虚拟滚动取代**。原 e38474b 自研 IntersectionObserver+sentinel+末条保活, 因 IO/RO 时序竞态致消息重叠反复(shouldRender 恒 true 仍重叠), 最终删除 useAiVirtualScroll.ts + AiChat.vue 移除全链路。
### 2026-06-16 走查修复项
- [x] ✅(第㉖批 2026-06-16·主代理直修) B-260616-09 [P1] — **任务详情点击打开空白(路由漏注册)**。**router/index.ts 补 /tasks/:id → TaskDetail**(L50-54 新增块,6 行,参照 /projects/:id L39-43 + /ideas/:id L27-31 模式)。name 'TaskDetail' 无冲突(现有 name 集无此项),复用 icon-thunder(对齐 /ideas/:id 复用 icon-lightbulb、/projects/:id 复用 icon-apps 一致模式)。TaskDetail.vue 现挂载,Tasks.vue:57 router.push(`/tasks/${task.id}`) 不再跳未注册路由,B-18 联动刷新等功能激活(死代码转活)。改动极简确定(6 行路由表项),主代理直修省 workflow 单 agent 编排开销。vue-tsc exit 0。
- [x] ✅(第㉖批 2026-06-16) B-260616-10 [P1] — **provider 配置后 AI 面板仍提示「未配置」**。**Settings.vue loadProviders 同步刷全局 store**。L376 import useAiStore + L383 const aiStore = useAiStore()(跟随 L382 appSettings 风格)+ L420-424 loadProviders 内本地 aiProviders.value 刷新成功后 L424 await aiStore.loadProviders() 刷全局 state.providers(模块级单例 ai.ts:45,L20/L44 注释明确全应用共享同一份,AiChat 共享)。三处调用点(saveProvider L464/deleteProvider L479/setDefaultProvider L490)+ onMounted L731 均经 loadProviders 自动同步全局。无循环(useAiPanel.ts:91 loadProviders 只刷 state.providers 不回调 Settings)。无 i18n 改动(纯数据同步),try/catch + showToast 保留。vue-tsc exit 0。
- [x] ✅(ca9b318·wrapNakedDiff落地+第㉖批diff语言注册) B-260616-11 [P2] — **AI 消息 `-`/`+` 列表/diff 无视觉效果**。两项子项均已落地:①**裸 diff 预处理** `wrapNakedDiff`(ca9b318 useMarkdown.ts:+96 守卫4:纯`-`列表不触发/≥2行连续±/已在围栏不动/段落级处理+短路优化+缓存key用wrapped) ②**diff 围栏高亮**(第㉖批 useMarkdown.ts import diff+registerLanguage('diff')+highlightCode getLanguage('diff')→hljs addition/deletion token+github-dark.css 红绿)。
- [x] ✅(第㉗批 2026-06-16·workflow) B-260616-19 [P2] — **知识库详情窄屏标题挤压成竖线(一行一字)**。三因子叠加Knowledge.vue`.kn-layout` grid `grid-template-columns: 360px 1fr`L567——左列表死钉 360px、右详情 `1fr` 拿剩余,**无响应式断点**;窗口窄(如总宽 500px减 padding 40+gap详情列塌缩到 ~80px 甚至更小。②`.detail-title`L625`font-size:18px` + `word-break:break-word` —— 窄列里中文每字宽度 > 列宽,逐字换行 → 竖线化。③`.detail-actions`L626`flex-shrink:0` —— 编辑/发布/拒绝 3 按钮不缩,优先占位,把 `.detail-title-row`(`flex:1`) 挤到零头。`.kn-detail-panel`(L570-576) 无 `min-width` 兜底。改动待定①grid 列宽改 `minmax(240px,360px) 1fr` + `@media (max-width:760px)` 切单列(`grid-template-columns:1fr`,列表详情上下堆叠);②详情面板加 `min-width:0` 配合标题行 `min-width` 防塌缩③可选标题字号响应式降级18px→15px。— src/views/Knowledge.vue。**低风险纯 CSS**,无逻辑/i18n/数据变动。
---
> 归档自 todo.md 2026-06-18 三文档巡检

File diff suppressed because it is too large Load Diff

View File

@@ -324,3 +324,30 @@
#### ~~S-260614-01 「显示多开」需求澄清~~ #### ~~S-260614-01 「显示多开」需求澄清~~
- **状态**:✅ 已解决(2026-06-16)— 用户澄清:需求本质是「会话来回切并行处理」(A 对话回复中切到 B 发消息,A 后台继续跑),非设置页开关。已被 F-260616-09(决策 a:d1 侧栏切换+后台并行)完整覆盖收编。 - **状态**:✅ 已解决(2026-06-16)— 用户澄清:需求本质是「会话来回切并行处理」(A 对话回复中切到 B 发消息,A 后台继续跑),非设置页开关。已被 F-260616-09(决策 a:d1 侧栏切换+后台并行)完整覆盖收编。
---
## 当前队列已决项迁入2026-06-18
#### B-260617-01 run_workflow 作为 AI 工具无法执行 + 无意义重试 + 审批混淆
- **背景**2026-06-17 实测发现AI 对话中调用 `run_workflow(task_id, target_status)` 推进任务状态,审批通过后 handler 返回 Err 提示字符串(`tool_registry.rs:530-546``ai_approve``commands.rs:329-334`)将其包成 `"failed"` tool_result 回传 LLM → LLM 认为失败 → 重试(1/4) → 又进审批 → 循环。三个关联问题:
- **问题 1**run_workflow handler 仅持有 `db: Arc<Database>`,无法访问 `AppHandle/State`registry/event_bus/workflows Repo返回 Err 提示前端走 IPC。但 `ai_approve` 审批通过后直接 `ai_tools.execute()` 调 handler → 必然 Err。
- **问题 2**handler Err → tool_result = 错误提示 → LLM 重试 → 同结果循环(工具定义未标 `no_retry`)。
- **问题 3**:前端渲染的是 AI 工具审批tool_call pending非工作流 HumanNode 审批。用户批准后工作流引擎从未启动。
- **决策点**run_workflow 审批通过后如何真正执行工作流?
- **选项**
- a: **前端拦截**`ai_approve` 中识别 `run_workflow` → 不走 `ai_tools.execute` → 直接 `invoke('run_workflow', {task_id, target_status})` → 结果回填 tool_result
- b: **后端注入 State**`build_ai_tool_registry` 传入 `AppHandle` 使 handler 可直接执行,改 state.rs + tool_registry 签名)
- c: **从 AI 工具移除 run_workflow**(仅保留 advance_task 推进状态,工作流由用户手动在前端触发)
- **推荐****a 前端拦截**(改动最小,`ai_approve` 一处分支即可;方案 b 需改 tool_registry 签名链路较长;方案 c 退回手动不符合 AI 驱动目标)。问题 2/3 随 a 自动消解。
- **关联**todo B-260617-01 / tool_registry.rs:530-546 / commands.rs:329-334 / audit.rs:590-630
- **状态**:✅ 已决(2026-06-17·波9 e23b316)— **决策:a 细化为 a2(后端分支)**。ai_approve 已持 app+state,后端直接调 run_workflow_inner 最干净(无需新 IPC/前端分支/工作流知识泄漏),符合 a 大方向(绕开 ai_tools.execute 必 Err)。问题 2 随 a2 消解(成功 tool_result + High 去重缓存双层断循环);问题 3 渲染区分记观察项(tool_display_hint 已含「触发工作流」语义)。详单见待审查 CR-52。
### ~~S-260614-01 多会话来回切(A/B 路线)~~ — 已并入 F-260616-09
- **状态**:✅ 已并入 F-260616-09(2026-06-16 已决 a:A 隔离修复 + B 立项高优先实施)。A/B 路线决策统一收口于 F-260616-09,本条不再独立维护。A 路线实施待 T-260614-02 实测确认是否升 B。
#### B-260617-11 tauri.conf.json 打包目标收窄确认
- **背景**:工作区改动 `bundle.targets: "all" → ["nsis"]`(src-tauri/tauri.conf.json:28),收窄到仅 Windows NSIS。若随其他改动一并提交,macOS(dmg/app)/Linux(deb/appimage)构建将不可用,影响其他开发者/CI。
- **决策点**:临时本地构建 vs 有意入库?
- **选项**:A 临时本地构建(提交前 revert 此行) / B 有意入库(改按平台条件配置而非硬编码单 target)
- **状态**:✅ 已决(2026-06-18) — **决策:A 临时本地构建,要求高速**`["nsis"]` 已是 Windows 单一最快安装包目标。提交前须 revert 为 `"all"`。若进一步提速可用 `["app"]`(裸 .exe 无安装包)或 `tauri build --no-bundle`

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -24,35 +24,6 @@
--- ---
## 当前队列 ## 当前队列
### ③类 — 产品取舍(有 a/b/c 决策点,核心待拍板)
#### B-260617-01 run_workflow 作为 AI 工具无法执行 + 无意义重试 + 审批混淆
- **背景**2026-06-17 实测发现AI 对话中调用 `run_workflow(task_id, target_status)` 推进任务状态,审批通过后 handler 返回 Err 提示字符串(`tool_registry.rs:530-546``ai_approve``commands.rs:329-334`)将其包成 `"failed"` tool_result 回传 LLM → LLM 认为失败 → 重试(1/4) → 又进审批 → 循环。三个关联问题:
- **问题 1**run_workflow handler 仅持有 `db: Arc<Database>`,无法访问 `AppHandle/State`registry/event_bus/workflows Repo返回 Err 提示前端走 IPC。但 `ai_approve` 审批通过后直接 `ai_tools.execute()` 调 handler → 必然 Err。
- **问题 2**handler Err → tool_result = 错误提示 → LLM 重试 → 同结果循环(工具定义未标 `no_retry`)。
- **问题 3**:前端渲染的是 AI 工具审批tool_call pending非工作流 HumanNode 审批。用户批准后工作流引擎从未启动。
- **决策点**run_workflow 审批通过后如何真正执行工作流?
- **选项**
- a: **前端拦截**`ai_approve` 中识别 `run_workflow` → 不走 `ai_tools.execute` → 直接 `invoke('run_workflow', {task_id, target_status})` → 结果回填 tool_result
- b: **后端注入 State**`build_ai_tool_registry` 传入 `AppHandle` 使 handler 可直接执行,改 state.rs + tool_registry 签名)
- c: **从 AI 工具移除 run_workflow**(仅保留 advance_task 推进状态,工作流由用户手动在前端触发)
- **推荐****a 前端拦截**(改动最小,`ai_approve` 一处分支即可;方案 b 需改 tool_registry 签名链路较长;方案 c 退回手动不符合 AI 驱动目标)。问题 2/3 随 a 自动消解。
- **关联**todo B-260617-01 / tool_registry.rs:530-546 / commands.rs:329-334 / audit.rs:590-630
- **状态**:✅ 已决(2026-06-17·波9 e23b316)— **决策:a 细化为 a2(后端分支)**。ai_approve 已持 app+state,后端直接调 run_workflow_inner 最干净(无需新 IPC/前端分支/工作流知识泄漏),符合 a 大方向(绕开 ai_tools.execute 必 Err)。问题 2 随 a2 消解(成功 tool_result + High 去重缓存双层断循环);问题 3 渲染区分记观察项(tool_display_hint 已含「触发工作流」语义)。详单见待审查 CR-52。
### 🟡 S-260614-01 多会话来回切对话(A/B 路线决策)
- **背景**:用户澄清(2026-06-17)「显示多开」原意 = 多个会话来回切都可对话(非 AI Chat 窗口多开)。关联 T-260614-02(切对话不中断实测)+ F-09 多会话架构。
- **决策点**:A 路线(单例软隔离)实测够 vs 需 B 路线(真多会话并行)架构升级?
- **选项**:
- a: **A 路线实测确认**(T-260614-02 跑 tauri dev 验证切会话不中断、来回切可对话)。够 → 销账;不够 → 升 b。
- b: **B 路线架构升级**(AiSession 单例 → 多实例,每会话独立 generating/AiSession,切走不打断)。memory aichat-arch-extensibility 记录单例未动。大改(触 ai/mod.rs AiSession + state.rs AppState + agentic.rs loop)。
- **推荐**:**a 先实测**(T-260614-02 验证 A 路线是否满足"来回切都可对话")。A 路线设计目标即切会话不丢上下文 + active 切换,理论支持。若实测发现切走 generating 被打断(单例限制)→ 升 b。避免盲目大改。
- **关联**:todo S-260614-01 / T-260614-02 / F-09 / memory aichat-arch-extensibility
- **状态**:🟡 待用户实测(T-260614-02)确认 A 路线是否够
### ⏸️ 暂缓(待条件触发)
#### T-260614-11 条件表达式引擎升级 #### T-260614-11 条件表达式引擎升级
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。 - **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景) - **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
@@ -79,6 +50,9 @@
- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。 - **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
- **状态**:🟡 待用户实测 - **状态**:🟡 待用户实测
#### ~~B-260617-11 tauri.conf.json 打包目标收窄确认~~
- **状态**:✅ 已决(2026-06-18) — 已迁入归档。决策:A 临时本地构建,要求高速。提交前 revert 为 `"all"`
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划) ### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
#### ARC-260615-07 架构重构批(排期/优先级决策) #### ARC-260615-07 架构重构批(排期/优先级决策)
@@ -115,6 +89,60 @@
- **关联**:todo UX-260617-28 [INFO] - **关联**:todo UX-260617-28 [INFO]
- **状态**:⏸️ 长期(INFO,当前够用) - **状态**:⏸️ 长期(INFO,当前够用)
#### B-260618-03 路由解耦 cost_tier/intelligence(用户已全局决策去掉,工程大需专项)
- **背景**:用户 2026-06-18 决策「API 无判别依据的 cost_tier/intelligence 去掉,不写死不瞎填」。当前 router.rs 用 cost/intel 做硬过滤(步骤4 min_intelligence / 步骤5 max_cost)+ 排序(步骤7 Reverse(cost_tier)),但这两维度 100% 由 model_probe 瞎猜(预设表+启发式),零客观依据。GLM-5.2 被猜成 standard/medium(名不沾词素走兜底),title/compress 按 max_cost 过滤可能选不到合适模型。
- **决策点**:用户已全局决策去掉。本项是落地(自主可推进)。记待决策因:工程大(~30 Edit)+ 主链路由行为变更需 cargo test router 全验证 + title/compress 选模型退化需在场核对。
- **选项**:
- a: 自主推进(用户已决策,专项会话充分资源 + cargo test router 全验证 + 实测 title/compress 选模型)
- b: 等用户在场专项(核对路由行为退化 + 实测)
- **实施清单(勘察完成 2026-06-18)**:
- router.rs:TaskRequirements 删 min_intelligence(:29)/max_cost(:31)字段 + 删步骤4 filter(:62)/步骤5 filter(:63) + 步骤7 max_by_key 改纯 weight 删 Reverse(cost_tier)(:65) + 注释更新(:28/:31/:48-51/:55)
- 9 调用点删 min_intelligence/max_cost 传参:project.rs:535-536/631-632 · compress.rs:62-63 · knowledge_inject.rs:61-62/346-347 · agentic.rs:414-415 · title.rs:88-89 · ai_node.rs:203-204 · adversarial.rs:161-162
- import 清理:调用点 CostTier/IntelligenceTier 不再用则删 import(title.rs/compress.rs 用 CostTier;多处用 IntelligenceTier)
- test:router.rs 多 test 改/删(max_cost_filters_expensive / max_cost_none_allows_any / same_weight_picks_cheaper_cost_tier / intelligence 相关 + TaskRequirements 构造删字段)
- ModelConfig.cost_tier 字段保留(B-04 model_probe 去瞎填处理数据源,不删字段)
- df-ai-core/model.rs 注释更新(:47/:60 提及 router max_cost/min_intelligence)
- **推荐**:**a 自主推进**(用户已全局决策,方向明确),建议专项会话充分资源;title/compress 失去 max_cost 约束后纯 weight 选模型,需核对 weight 配置合理(用户在 Settings 配)
- **关联**:todo B-260618-03/04/05 · UX-260618-04 前端 cost/intel 标签(依赖本项)
- **状态**:✅ **已实施**(2026-06-18 workflow wexu1isx1,主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 109 passed + vue-tsc EXIT 0;SW-02 借用 E0502 主代修)。title/compress 纯 weight 选模型退化点:用户核对 Settings weight 配置(同 weight 并列 max_by_key 返回最后一个)
#### ARC-260618-01 God 文件拆分批(架构重投入·需设计)
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍)
- **选项**:
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中)
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口)
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险)
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做,b 待 AiChat 改动沉淀后)
- **关联**:todo SMELL-P0-2/P0-3/P1-9
- **状态**:🟡 待排期决策
#### SMELL-P1-6 String→newtype 强类型(需设计)
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。
- **决策点**:做不做 + newtype 边界(仅 Rust 内部 vs 跨 IPC 到前端)
- **选项**:
- a: 全栈 newtype(Rust+IPC+前端,类型安全最强,波及大)
- b: 仅 Rust 内部 newtype(IPC 边界仍 String,折中)
- c: 不做(String 够用,status 已有 enum 部分覆盖)
- **推荐**:**⏸️ 缓做**(全栈波及大 ROI 中等;倾向 b 折中或 c 现状 enum 已部分覆盖。若做需专项设计 newtype 边界)
- **关联**:todo SMELL-P1-6
- **状态**:🟡 待设计决策
#### SW-260618-21 死代码预留功能清理批(清理 vs 保留)
- **背景**:2026-06-18 深层 sweep 核验出 4 类「0 外部消费者但设计预留」符号。清理减负 vs 保留未来功能取舍。注:`PendingApproval.diff` 经 IPC 活跃(useAiEvents:252 `event.diff→tc.diff` + ToolCard:44/754/772 渲染·UX-260618-06 审批 diff)**非死代码不删**;`transition_status` 已删(批次7·0 消费者+TODO 未实现)。
- **核验(独立 grep 2026-06-18)**:
- `SessionState` enum + `session_state()`(mod.rs:172/393):0 外部调用·注释「读状态统一走 session_state() 收敛」设计预留(待重构 SW-02 类终态化复用)
- `AppState.releases`/`node_executions`(state.rs:206/210):`\.releases\b|\.node_executions\b` 0 字段访问·df-storage ReleaseRepo/NodeExecutionRepo 预留(未来 release mgmt/node exec log)
- ~~`PendingApproval.risk_level`(mod.rs:410)~~:**✅ 已删(2026-06-18·前端 types.ts 0 字段坐实真死·零波及·CR-22)**
- **决策点**:清理 0 消费者预留 vs 保留未来功能
- **选项**:
- a: 全清理(删 SessionState/releases/node_executions/risk_level·连带 df-storage repo 定义·减 dead_code warning·但失去预留扩展点)
- b: 全保留+标 `#[allow(dead_code)]` 注释预留意图(消 warning·保留未来·零波及)
- c: 部分(SessionState/releases 近期无计划清·risk_level 核前端类型后定)
- **推荐**:**⏸️ b 保留+标 allow**(预留设计意图明确·清理失去未来扩展点 ROI 低;标 allow 消 warning 即可·零波及)
- **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处
- **状态**:✅ 已实施 b(2026-06-18·主代自主决策·risk_level 删 + 预留/diff 标 allow·cargo 0 warning)
--- ---
## 已决归档 ## 已决归档

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,12 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。 // 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
use df_ai::retry; use df_ai::retry;
// F-01 阶段5: 智能路由 helper + TaskRequirements + 维度枚举。 // F-01 阶段5: 智能路由 helper + TaskRequirements + 维度枚举。
// 调用点构造 TaskRequirements(主对话:needs_tool_use=true,min_intelligence=Standard, // 调用点构造 TaskRequirements(主对话:needs_tool_use=true,
// 当前仅 Text 模态;后续多模态接入时检测消息内 Part/Image 追加 Vision), // 当前仅 Text 模态;后续多模态接入时检测消息内 Part/Image 追加 Vision),
// 经 select_model_id 在 provider.model_configs 池中选最优;池空/无匹配兜底 default_model。 // 经 select_model_id 在 provider.model_configs 池中选最优;池空/无匹配兜底 default_model。
// 注:路由已解耦(B-260618-03),cost_tier/intelligence 不再参与硬路由。
use df_ai::router::{ use df_ai::router::{
select_model_id, IntelligenceTier, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::db::Database; use df_storage::db::Database;
@@ -344,10 +345,20 @@ pub(crate) async fn run_agentic_loop(
// AiProviderRepo::new 仅 clone Arc<Database>(廉价),不复用 AppState.ai_providers // AiProviderRepo::new 仅 clone Arc<Database>(廉价),不复用 AppState.ai_providers
// (run_agentic_loop 签名只传 Arc<Database>,改签名会牵动 3 调用点 + try_continue)。 // (run_agentic_loop 签名只传 Arc<Database>,改签名会牵动 3 调用点 + try_continue)。
let provider_repo = df_storage::crud::AiProviderRepo::new(&db); let provider_repo = df_storage::crud::AiProviderRepo::new(&db);
let pool_providers: Vec<AiProviderRecord> = provider_repo.list_all().await.unwrap_or_default(); let pool_providers: Vec<AiProviderRecord> = match provider_repo.list_all().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[ai] list_all providers 失败,负载均衡池退化为入参默认 provider(空池兜底)");
Vec::new()
}
};
let ranked_candidates: Vec<AiProviderRecord> = super::provider_pool::ProviderPool::select( let ranked_candidates: Vec<AiProviderRecord> = super::provider_pool::ProviderPool::select(
&pool_providers, &pool_providers,
None, // 模型亲和此时尚未确定(router 选模型需 provider_config,见下方) // specify 模式(用户指定 model):传 override 作亲和键,ProviderPool 优先选
// 「池中含该 model 的 provider」作 primary,打破下方「router 选模型需 provider_config」
// 的鸡生蛋——override 此时已知(入参 ← session.model_override),无须等 router。
// auto 模式(override=None)→ 全亲和纯权重排序,行为不变(向后兼容)。
model_override.as_deref(),
); );
let (primary_provider, candidates): (AiProviderRecord, Vec<AiProviderRecord>) = let (primary_provider, candidates): (AiProviderRecord, Vec<AiProviderRecord>) =
match ranked_candidates.split_first() { match ranked_candidates.split_first() {
@@ -401,14 +412,12 @@ pub(crate) async fn run_agentic_loop(
"[ai] 发起 LLM 请求" "[ai] 发起 LLM 请求"
); );
// F-01 阶段5: 主对话路由 — TaskRequirements(needs_tool_use=true,min_intelligence=Standard)。 // F-01 阶段5: 主对话路由 — TaskRequirements(needs_tool_use=true)。
// 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。 // 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。
// select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。 // select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。
let agentic_req = TaskRequirements { let agentic_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: true, needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs) let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs)

View File

@@ -21,6 +21,13 @@ use crate::commands::{err_str, now_millis};
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
use super::tool_registry::generate_diff; use super::tool_registry::generate_diff;
/// Med/High 工具挂起审批时回填的占位 tool_result 内容。
///
/// 单一常量避免「写入处」(process_tool_calls) 与「去重排查处」
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
/// RiskLevel → 审计记录字符串low/medium/high /// RiskLevel → 审计记录字符串low/medium/high
pub(crate) fn risk_str(r: RiskLevel) -> &'static str { pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
match r { match r {
@@ -265,14 +272,16 @@ pub async fn restore_pending_approvals(state: &AppState) {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
for rec in pending { for rec in pending {
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default(); let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
let Some(risk) = risk_from_str(&rec.risk_level) else { continue }; // 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
if risk_from_str(&rec.risk_level).is_none() {
continue;
}
session.pending_approvals.insert( session.pending_approvals.insert(
rec.tool_call_id.clone(), rec.tool_call_id.clone(),
PendingApproval { PendingApproval {
tool_call_id: rec.tool_call_id, tool_call_id: rec.tool_call_id,
tool_name: rec.tool_name, tool_name: rec.tool_name,
arguments: args, arguments: args,
risk_level: risk,
conversation_id: rec.conversation_id, conversation_id: rec.conversation_id,
recovered: true, recovered: true,
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启, // AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
@@ -325,14 +334,18 @@ pub(crate) async fn audit_tool_call(
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉, /// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
/// 导致审批后审计记录永久卡 pending。 /// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) { pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
let Some(mut rec) = state // 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
.ai_tool_executions // DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
.find_by_tool_call_id(tool_call_id) let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
.await Ok(Some(rec)) => rec,
.unwrap_or_default() Ok(None) => {
else { tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id);
tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id); return;
return; }
Err(e) => {
tracing::error!("audit_finalize: 查询 tool_call_id={} 审计记录失败(DB 故障,状态未回填): {}", tool_call_id, e);
return;
}
}; };
rec.status = status.to_string(); rec.status = status.to_string();
rec.decided_by = Some("human".to_string()); rec.decided_by = Some("human".to_string());
@@ -340,7 +353,9 @@ pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status:
if let Some(r) = result { if let Some(r) = result {
rec.result = Some(r); rec.result = Some(r);
} }
let _ = state.ai_tool_executions.update_full(&rec).await; if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
}
} }
/// AR-11方案A工具名 → (entity, action) 映射,用于数据变更联动刷新。 /// AR-11方案A工具名 → (entity, action) 映射,用于数据变更联动刷新。
@@ -386,6 +401,10 @@ pub(crate) fn emit_data_changed(app_handle: &AppHandle, tool_name: &str) {
/// F-260616-05高危工具去重根治 run_command 超时→重试→重新审批循环)。 /// F-260616-05高危工具去重根治 run_command 超时→重试→重新审批循环)。
/// ///
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
///
/// **根因链**F-04 batch42 已做超时标注LLM 调 run_command 超时 → F-04 把超时标注成 /// **根因链**F-04 batch42 已做超时标注LLM 调 run_command 超时 → F-04 把超时标注成
/// tool_result 回传 LLM → LLM不可靠仍重试同命令 → 新 tool_call_idprovider 每轮新 id /// tool_result 回传 LLM → LLM不可靠仍重试同命令 → 新 tool_call_idprovider 每轮新 id
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。 /// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
@@ -403,11 +422,12 @@ pub(crate) fn emit_data_changed(app_handle: &AppHandle, tool_name: &str) {
/// - 返回 None 表示无缓存命中(走原审批流程)。 /// - 返回 None 表示无缓存命中(走原审批流程)。
/// ///
/// `session` 只读扫描 messages不写调用方据返回值决定是否跳过 insert pending。 /// `session` 只读扫描 messages不写调用方据返回值决定是否跳过 insert pending。
fn find_cached_high_risk_result( async fn find_cached_high_risk_result(
session: &AiSession, session: &AiSession,
audit_repo: &AiToolExecutionRepo,
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
) -> Option<String> { ) -> Option<(String, String)> {
use df_ai::provider::MessageRole; use df_ai::provider::MessageRole;
// 规范化新调用的 args 为可比字符串(排序键,键序无关) // 规范化新调用的 args 为可比字符串(排序键,键序无关)
@@ -454,11 +474,20 @@ fn find_cached_high_risk_result(
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
continue; continue;
} }
// 命中旧 tool_result排除 pending 占位(内容固定为「需要用户审批,等待确认」 // 命中旧 tool_result排除 pending 占位(内容固定为 PENDING_APPROVAL_PLACEHOLDER
if msg.content == "需要用户审批,等待确认" { if msg.content == PENDING_APPROVAL_PLACEHOLDER {
return None; return None;
} }
return Some(msg.content.clone()); // SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
let status = audit_repo
.find_by_tool_call_id(old_id.as_str())
.await
.ok()
.and_then(|opt| opt.map(|rec| rec.status))
.unwrap_or_else(|| "completed".to_string());
return Some((msg.content.clone(), status));
} }
None None
} }
@@ -609,7 +638,7 @@ pub(crate) async fn process_tool_calls(
} }
// F-05仅 High 查去重缓存Med 保持原审批流程 // F-05仅 High 查去重缓存Med 保持原审批流程
if matches!(risk_level, RiskLevel::High) { if matches!(risk_level, RiskLevel::High) {
if let Some(cached) = find_cached_high_risk_result(session, &draft.name, &args) { if let Some((cached, status)) = find_cached_high_risk_result(session, &audit_repo, &draft.name, &args).await {
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
tracing::info!( tracing::info!(
tool = %draft.name, tool = %draft.name,
@@ -622,8 +651,8 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(cached.clone()), result: serde_json::Value::String(cached.clone()),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
// 审计:去重命中记一条 completed(decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, Some(cached), Some("auto_dedup")).await; audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup")).await;
continue; continue;
} }
} }
@@ -641,12 +670,11 @@ pub(crate) async fn process_tool_calls(
tool_call_id: draft.id.clone(), tool_call_id: draft.id.clone(),
tool_name: draft.name.clone(), tool_name: draft.name.clone(),
arguments: args.clone(), arguments: args.clone(),
risk_level,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
recovered: false, recovered: false,
diff: approval_diff.clone(), diff: approval_diff.clone(),
}); });
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认")); session.messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
id: draft.id.clone(), id: draft.id.clone(),

View File

@@ -23,6 +23,33 @@ use super::skills::{read_skill_content, SkillInfo, skills_cached};
use super::AiChatEvent; use super::AiChatEvent;
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批,等待确认")就地替换为终态文本。
///
/// 背景(SW-260618-02):审批占位文本在 stop/clear/create/delete/force_send 等
/// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。
/// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。
///
/// 调用时机:在 `pending_approvals.clear()` / `retain` 之前调用(遍历即将被丢弃的条目)。
/// 在 messages 已 clear 的命令(如 ai_chat_clear)里该方法为 no-op
/// (replace_tool_result_content 找不到 tool_result 返回 false),不报错、无副作用。
///
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
fn finalize_pending_placeholders(session: &mut super::AiSession, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
let ids: Vec<String> = session
.pending_approvals
.values()
.map(|a| a.tool_call_id.clone())
.collect();
for id in ids {
// replace_tool_result_content 反向遍历,找不到对应 tool_call_id 时返回 false,无副作用
session.messages.replace_tool_result_content(&id, final_text);
}
}
// ============================================================ // ============================================================
// 发送 / 审批 / 控制 // 发送 / 审批 / 控制
// ============================================================ // ============================================================
@@ -51,6 +78,12 @@ pub async fn ai_regenerate(
if session.generating { if session.generating {
return Err("AI 正在生成中,请等待完成".to_string()); return Err("AI 正在生成中,请等待完成".to_string());
} }
// 一致性:regenerate 限定当前活跃对话(避免历史快照陈旧时弹错对话的消息)。
// 必须在 pop_last_assistant_round 之前校验——否则对话不匹配时已弹出 active 对话
// 的 AI 回复却报错退出,内存消息被破坏无法恢复(对齐 ai_chat_edit:637-640 先校验后改)。
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法重新生成".to_string());
}
session.generating = true; session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst); session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone(); session.agent_language = language.clone();
@@ -64,11 +97,6 @@ pub async fn ai_regenerate(
session.generating = false; session.generating = false;
return Err("没有可重新生成的回复".to_string()); return Err("没有可重新生成的回复".to_string());
} }
// 一致性:regenerate 限定当前活跃对话(避免历史快照陈旧时弹错对话的消息)
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
session.generating = false;
return Err("对话已切换,无法重新生成".to_string());
}
} }
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
@@ -258,11 +286,18 @@ pub async fn ai_approve(
Some(a) => a, Some(a) => a,
None => { None => {
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错) // F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
if let Some(rec) = state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await // BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
.unwrap_or_default() // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
{ match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" { Ok(Some(rec)) => {
return Ok(format!("已处理({})", rec.status)); if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" {
return Ok(format!("已处理({})", rec.status));
}
}
Ok(None) => { /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
Err(e) => {
tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e);
return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e));
} }
} }
return Err(format!("未找到挂起的审批: {}", tool_call_id)); return Err(format!("未找到挂起的审批: {}", tool_call_id));
@@ -410,6 +445,9 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB) // 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
let active_id = session.active_conversation_id.clone(); let active_id = session.active_conversation_id.clone();
// SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear,
// 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。
finalize_pending_placeholders(&mut *session, "会话已清除");
session.messages.clear(); session.messages.clear();
session.pending_approvals.clear(); session.pending_approvals.clear();
drop(session); drop(session);
@@ -758,6 +796,9 @@ pub async fn ai_chat_force_send(
// ① 软停止复位(照旧实现,与 ai_chat_stop 审批分支一致) // ① 软停止复位(照旧实现,与 ai_chat_stop 审批分支一致)
let old = session.active_conversation_id.clone(); let old = session.active_conversation_id.clone();
session.generating = false; session.generating = false;
// SW-260618-02:强制发送丢弃旧审批,先终态化占位 tool_result,防占位随 messages 残留
// 下次发送喂给 LLM(此处 messages 不 clear,是真实残留路径)。
finalize_pending_placeholders(&mut *session, "已取消");
session.pending_approvals.clear(); session.pending_approvals.clear();
session.stop_flag.store(true, Ordering::SeqCst); session.stop_flag.store(true, Ordering::SeqCst);
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send:160-194,无 generating 拦截—— // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send:160-194,无 generating 拦截——
@@ -859,6 +900,9 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
} }
if !session.pending_approvals.is_empty() { if !session.pending_approvals.is_empty() {
// 审批等待态loop 已退出,直接清理让会话立即可用 // 审批等待态loop 已退出,直接清理让会话立即可用
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
// 清 pending 前先终态化为"会话已停止"。
finalize_pending_placeholders(&mut *session, "会话已停止");
session.pending_approvals.clear(); session.pending_approvals.clear();
session.generating = false; session.generating = false;
session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
@@ -1020,7 +1064,10 @@ pub async fn ai_save_provider(
api_key: String, api_key: String,
default_model: String, default_model: String,
provider_type: String, provider_type: String,
model_configs: Vec<ModelConfig>,
) -> Result<String, String> { ) -> Result<String, String> {
// F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at避免被覆盖 // 编辑已有提供商时保留原 created_at避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
// 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。
@@ -1087,7 +1134,7 @@ pub async fn ai_save_provider(
base_url, base_url,
default_model, default_model,
models: None, models: None,
model_configs: Vec::new(), model_configs,
is_default, is_default,
config: None, config: None,
created_at, created_at,
@@ -1244,13 +1291,31 @@ pub async fn ai_fetch_models(
let api_key = df_storage::secret::resolve_provider_secret(&provider); let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
let configs = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key) let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
// 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。
// 新模型(旧池无同 model_id)用探测默认值。
let merged: Vec<ModelConfig> = probed
.iter()
.map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) {
Some(old) => ModelConfig {
weight: old.weight,
enabled: old.enabled,
label: old.label.clone(),
..c.clone()
},
None => c.clone(),
})
.collect();
// 写回 model_configs(更新 updated_at) // 写回 model_configs(更新 updated_at)
let mut updated = provider.clone(); let mut updated = provider.clone();
updated.model_configs = configs.clone(); updated.model_configs = merged.clone();
updated.updated_at = now_millis(); updated.updated_at = now_millis();
state state
.ai_providers .ai_providers
@@ -1258,7 +1323,7 @@ pub async fn ai_fetch_models(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
Ok(configs) Ok(merged)
} }
/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 /// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
@@ -1295,6 +1360,9 @@ pub async fn ai_conversation_create(
if session.generating { if session.generating {
let old_conv = session.active_conversation_id.clone(); let old_conv = session.active_conversation_id.clone();
session.generating = false; session.generating = false;
// SW-260618-02:清 pending 前终态化旧对话占位 tool_result(下方 messages.clear 会清旧消息,
// 此处调用为 no-op,但保持统一口径防清理顺序变更)。
finalize_pending_placeholders(&mut *session, "已取消");
session.pending_approvals.clear(); session.pending_approvals.clear();
session.stop_flag.store(true, Ordering::SeqCst); session.stop_flag.store(true, Ordering::SeqCst);
drop(session); drop(session);
@@ -1309,8 +1377,25 @@ pub async fn ai_conversation_create(
} }
session = state.ai_session.lock().await; session = state.ai_session.lock().await;
} }
// 修复 B-260618-06: 清内存前先持久化当前活跃会话,防「新建会话致旧对话消息丢失」。
// 丢失路径1(高频稳定复现):生成中新建 → 本函数 messages.clear() 抹掉旧会话内存消息,
// 旧 loop 经 B-260615-11 一致性校验(active_conversation_id≠conv_id)return 不 save → 旧会话整段永久丢失。
// 丢失路径2(低概率):loop 正常完成 spawn 后台 save(agentic.rs:928)与 clear 竞态,读空内存覆盖旧会话为空。
// 此处 save 在 clear 前 await 完成,内存仍是旧会话完整消息;幂等(已落库则 update 路径不累加 token/不改 model)。
let old_conv = session.active_conversation_id.clone();
let old_has_msgs = !session.messages.is_empty();
drop(session);
if let Some(ref oc) = old_conv {
if old_has_msgs {
save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await;
}
}
let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone()); session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now); session.active_conv_created_at = Some(now);
// SW-260618-02:清 pending 前终态化占位 tool_result(此处 messages 紧即将 clear,调用为 no-op,
// 保持清理点统一口径;非生成分支无上方 messages.clear 之外的清场,防占位随新会话残留)。
finalize_pending_placeholders(&mut *session, "会话已清除");
session.messages.clear(); session.messages.clear();
session.pending_approvals.clear(); session.pending_approvals.clear();
// F-260616-09(A 路线):补漏清字段维持单例软隔离,解「新建会话上下文残留」。 // F-260616-09(A 路线):补漏清字段维持单例软隔离,解「新建会话上下文残留」。
@@ -1368,6 +1453,7 @@ pub async fn ai_conversation_list(
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_switch( pub async fn ai_conversation_switch(
state: State<'_, AppState>, state: State<'_, AppState>,
app_handle: AppHandle,
conversation_id: String, conversation_id: String,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await let record = state.ai_conversations.get_by_id(&conversation_id).await
@@ -1379,6 +1465,8 @@ pub async fn ai_conversation_switch(
let messages_json = record.messages.clone(); let messages_json = record.messages.clone();
let title = record.title.clone(); let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
let need_title_regen = record.title.is_none();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 生成中允许只读切换:返回目标对话的 messages 供前端展示,但不修改 session 状态 // 生成中允许只读切换:返回目标对话的 messages 供前端展示,但不修改 session 状态
@@ -1402,6 +1490,29 @@ pub async fn ai_conversation_switch(
session.model_override = None; session.model_override = None;
// AE-2025-04切换对话清会话信任,新会话重审(信任上下文相关,不跨对话)。 // AE-2025-04切换对话清会话信任,新会话重审(信任上下文相关,不跨对话)。
session.session_trust.clear(); session.session_trust.clear();
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
drop(session);
// 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
if need_title_regen {
match super::prompt::get_active_provider(&state).await {
Ok(provider_config) => {
super::title::spawn_ensure_title(
&provider_config,
&state.db,
&conversation_id,
&app_handle,
&state.ai_session,
&state.llm_concurrency,
);
}
Err(e) => tracing::warn!(
"切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}",
conversation_id, e
),
}
}
Ok(serde_json::json!({ Ok(serde_json::json!({
"id": record.id, "id": record.id,
@@ -1419,10 +1530,13 @@ pub async fn ai_conversation_delete(
state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?; state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?;
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 删除任意对话(含非活跃)都应清理其积压审批:pending_approvals 是单例 HashMap,
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
if session.active_conversation_id.as_deref() == Some(&conversation_id) { if session.active_conversation_id.as_deref() == Some(&conversation_id) {
session.active_conversation_id = None; session.active_conversation_id = None;
session.messages.clear(); session.messages.clear();
session.pending_approvals.clear();
} }
Ok(()) Ok(())
} }

View File

@@ -8,12 +8,12 @@
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`, //! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。 //! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化 // F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{ use df_ai::router::{
select_model_id, CostTier, IntelligenceTier, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
@@ -31,7 +31,7 @@ use super::prompt::compress_prompt;
/// - 输出:`Result<String, String>` — `Ok` 为 LLM 生成的摘要文本(已 trim); /// - 输出:`Result<String, String>` — `Ok` 为 LLM 生成的摘要文本(已 trim);
/// `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。 /// `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。
/// ///
/// 模型选择:`select_model_id(TaskRequirements{Standard 智力, Medium 成本上限, 无 tool_use})`, /// 模型选择:`select_model_id(TaskRequirements{无 tool_use})`,
/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。 /// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。
/// ///
/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流, /// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流,
@@ -54,13 +54,11 @@ pub(crate) async fn compress_via_llm(
return Err("无可压缩消息(active 消息为空)".to_string()); return Err("无可压缩消息(active 消息为空)".to_string());
} }
// 路由选模型:Standard 智力够摘要,无 tool_use,Medium 成本上限 // 路由选模型:无 tool_use(纯文本摘要)
// None(池空/无匹配)→ 兜底 default_model(行为不变)。 // None(池空/无匹配)→ 兜底 default_model(行为不变)。
let compress_req = TaskRequirements { let compress_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Standard,
max_cost: Some(CostTier::Medium),
estimated_context: 0, estimated_context: 0,
}; };
let model = select_model_id(&compress_req, &provider_config.model_configs) let model = select_model_id(&compress_req, &provider_config.model_configs)

View File

@@ -10,7 +10,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// - 嵌入: TaskRequirements(Capability::Embedding,无工具)— 在 embedding provider 的池中选, // - 嵌入: TaskRequirements(Capability::Embedding,无工具)— 在 embedding provider 的池中选,
// 池空兜底 config.embedding_model(行为不变,现有 KnowledgeConfig 配置即生效)。 // 池空兜底 config.embedding_model(行为不变,现有 KnowledgeConfig 配置即生效)。
use df_ai::router::{ use df_ai::router::{
select_model_id, IntelligenceTier, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::crud::{AiConversationRepo, KnowledgeRepo}; use df_storage::crud::{AiConversationRepo, KnowledgeRepo};
use df_storage::db::Database; use df_storage::db::Database;
@@ -58,8 +58,6 @@ async fn resolve_embed_provider(
let embed_req = TaskRequirements { let embed_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let model = select_model_id(&embed_req, &rec.model_configs).unwrap_or(fallback_model); let model = select_model_id(&embed_req, &rec.model_configs).unwrap_or(fallback_model);
@@ -129,7 +127,13 @@ async fn hybrid_search(
limit: usize, limit: usize,
config: &crate::state::KnowledgeConfig, config: &crate::state::KnowledgeConfig,
) -> Vec<df_storage::models::KnowledgeRecord> { ) -> Vec<df_storage::models::KnowledgeRecord> {
let keyword_results = state.knowledge.search(query, None, limit).await.unwrap_or_default(); let keyword_results = match state.knowledge.search(query, None, limit).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[ai] 知识关键词检索失败,降级空结果");
Vec::new()
}
};
if !config.vector_enabled { if !config.vector_enabled {
return keyword_results; return keyword_results;
@@ -138,7 +142,13 @@ async fn hybrid_search(
Some(v) => v, Some(v) => v,
None => return keyword_results, // embed 失败降级 None => return keyword_results, // embed 失败降级
}; };
let vector_results = state.knowledge.search_vector(&query_vec, limit).await.unwrap_or_default(); let vector_results = match state.knowledge.search_vector(&query_vec, limit).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[ai] 知识向量检索失败,降级空结果");
Vec::new()
}
};
merge_hybrid_results(keyword_results, vector_results, limit) merge_hybrid_results(keyword_results, vector_results, limit)
} }
@@ -311,7 +321,13 @@ async fn extract_knowledge_from_conversation(
.filter(|t| !t.trim().is_empty()) .filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string()); .unwrap_or_else(|| "未命名对话".to_string());
let messages: Vec<ChatMessage> = serde_json::from_str(&conv.messages).unwrap_or_default(); let messages: Vec<ChatMessage> = match serde_json::from_str(&conv.messages) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[ai] 对话消息 JSON 解析失败,降级空消息(知识提炼跳过)");
Vec::new()
}
};
// 过滤 user/assistant,取最后 6 条 // 过滤 user/assistant,取最后 6 条
let recent: Vec<&ChatMessage> = messages let recent: Vec<&ChatMessage> = messages
.iter() .iter()
@@ -343,8 +359,6 @@ async fn extract_knowledge_from_conversation(
let extract_req = TaskRequirements { let extract_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let extract_model = select_model_id(&extract_req, &provider_config.model_configs) let extract_model = select_model_id(&extract_req, &provider_config.model_configs)

View File

@@ -42,7 +42,7 @@ use std::sync::atomic::AtomicBool;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use df_ai::ai_tools::RiskLevel; // RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
use df_ai::context::ContextManager; use df_ai::context::ContextManager;
use df_ai::context::ContextConfig; use df_ai::context::ContextConfig;
@@ -169,6 +169,7 @@ pub enum AiChatEvent {
/// ///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval /// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval
/// 即使同时在流式)> `generating`Streaming> 都不满足Idle /// 即使同时在流式)> `generating`Streaming> 都不满足Idle
#[allow(dead_code)] // SW-260618-21 b:预留读视图(SW-02 类终态化复用·当前 0 消费者·保留扩展点)
pub enum SessionState { pub enum SessionState {
/// 空闲:既未生成、也无挂起审批 /// 空闲:既未生成、也无挂起审批
Idle, Idle,
@@ -390,6 +391,7 @@ impl AiSession {
/// - `commands.rs:451` — 审批提交/会话复位路径的状态判断 /// - `commands.rs:451` — 审批提交/会话复位路径的状态判断
/// ///
/// 上述三处替换由 P0 任务承接,本方法先就位供其切换。 /// 上述三处替换由 P0 任务承接,本方法先就位供其切换。
#[allow(dead_code)] // SW-260618-21 b:预留(P0 终态化任务承接·当前 0 调用·保留扩展点)
pub fn session_state(&self) -> SessionState { pub fn session_state(&self) -> SessionState {
if !self.pending_approvals.is_empty() { if !self.pending_approvals.is_empty() {
SessionState::AwaitingApproval SessionState::AwaitingApproval
@@ -407,13 +409,13 @@ pub struct PendingApproval {
pub tool_call_id: String, pub tool_call_id: String,
pub tool_name: String, pub tool_name: String,
pub arguments: serde_json::Value, pub arguments: serde_json::Value,
pub risk_level: RiskLevel,
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑 /// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool, pub recovered: bool,
/// AE-2025-03路径 Bwrite_file 的行级 unified diff旧文件 vs 新内容)。 /// AE-2025-03路径 Bwrite_file 的行级 unified diff旧文件 vs 新内容)。
/// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None。 /// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None。
/// recovered 审批(启动重建)无 diff文件可能已变重读无意义 /// recovered 审批(启动重建)无 diff文件可能已变重读无意义
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
pub diff: Option<String>, pub diff: Option<String>,
} }

View File

@@ -6,10 +6,11 @@ use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// F-01 阶段5: 标题生成路由 — TaskRequirements(Lite + Low 成本,无需工具)。 // F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。
// 标题是短文本摘要任务,选便宜 Lite 模型;池空/无匹配兜底 default_model(行为不变)。 // 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。
// 注:路由已解耦(B-260618-03),原 Lite/Low 智力成本约束已删除,纯 weight 选模型。
use df_ai::router::{ use df_ai::router::{
select_model_id, CostTier, IntelligenceTier, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::crud::AiConversationRepo; use df_storage::crud::AiConversationRepo;
use df_storage::db::Database; use df_storage::db::Database;
@@ -56,6 +57,7 @@ pub(crate) async fn ensure_conversation_title(
model: None, model: None,
status: None, status: None,
reasoning_content: m.reasoning_content.clone(), reasoning_content: m.reasoning_content.clone(),
timestamp: m.timestamp,
}) })
.collect(); .collect();
(summary, session.messages.all_messages_clone()) (summary, session.messages.all_messages_clone())
@@ -64,35 +66,55 @@ pub(crate) async fn ensure_conversation_title(
return; return;
} }
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
if let Err(e) = conv_repo.update_field(conv_id, "title", &fallback_title).await {
tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e);
}
let _ = app_handle.emit("ai-conversation-changed", ());
// 标题生成是独立一次 LLM 调用,自建 provider便于后台 spawn不借主 loop 的 &dyn LlmProvider // 标题生成是独立一次 LLM 调用,自建 provider便于后台 spawn不借主 loop 的 &dyn LlmProvider
// build_provider_for 含空 key 早失败:Err(如 keyring 读不到)→ 跳过 LLM,extract_title 兜底
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(provider_config) { let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(provider_config) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
tracing::warn!("标题生成跳过(provider 密钥不可用,走 extract_title 兜底): {}", e); // 兜底已落库 + emit,LLM 跳过(密钥不可用等)
let title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); tracing::warn!("标题 LLM 生成跳过(provider 密钥不可用,保留 extract_title 兜底): {}", e);
let _ = conv_repo.update_field(conv_id, "title", &title).await;
let _ = app_handle.emit("ai-conversation-changed", ());
return; return;
} }
}; };
// F-01 阶段5: 标题生成路由 — 选便宜 Lite 模型(Low 成本上限,无工具) // F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model
// select_model_id None(池空/无匹配)→ 兜底 default_model。
let title_req = TaskRequirements { let title_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: Some(CostTier::Low),
estimated_context: 0, estimated_context: 0,
}; };
let title_model = select_model_id(&title_req, &provider_config.model_configs) let title_model = select_model_id(&title_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone()); .unwrap_or_else(|| provider_config.default_model.clone());
let title = match generate_title_via_llm(&*provider, &title_model, summary_msgs, &llm_concurrency).await { // B-260617-17: LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积;
Some(t) => t, // 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。
None => extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()), let title = match tokio::time::timeout(
std::time::Duration::from_secs(20),
generate_title_via_llm(&*provider, &title_model, summary_msgs, &llm_concurrency),
).await {
Ok(Some(t)) => t,
Ok(None) => {
tracing::warn!("标题 LLM 生成返回空,保留 extract_title 兜底(conv_id={})", conv_id);
return;
}
Err(_) => {
tracing::warn!("标题 LLM 生成超时(20s),保留 extract_title 兜底(conv_id={})", conv_id);
return;
}
}; };
let _ = conv_repo.update_field(conv_id, "title", &title).await; // LLM 生成成功 → 覆盖 extract 兜底
if let Err(e) = conv_repo.update_field(conv_id, "title", &title).await {
tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e);
}
let _ = app_handle.emit("ai-conversation-changed", ()); let _ = app_handle.emit("ai-conversation-changed", ());
} }

View File

@@ -107,8 +107,8 @@ pub async fn promote_idea(
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("灵感不存在: {id}"))?; .ok_or_else(|| format!("灵感不存在: {id}"))?;
if record.promoted_to.is_some() { if let Some(promoted_to) = &record.promoted_to {
return Err(format!("灵感已立项: {}", record.promoted_to.unwrap())); return Err(format!("灵感已立项: {}", promoted_to));
} }
// 复用 df-project 领域逻辑构造项目实体create_from_idea // 复用 df-project 领域逻辑构造项目实体create_from_idea
@@ -289,11 +289,16 @@ async fn build_default_provider(
/// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分) /// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分)
fn record_to_idea(record: &IdeaRecord) -> Idea { fn record_to_idea(record: &IdeaRecord) -> Idea {
let tags: Vec<String> = record let tags: Vec<String> = match record.tags.as_deref() {
.tags Some(t) => match serde_json::from_str::<Vec<String>>(t) {
.as_deref() Ok(v) => v,
.and_then(|t| serde_json::from_str(t).ok()) Err(e) => {
.unwrap_or_default(); tracing::warn!(error = %e, idea_id = %record.id, "[ideas] tags JSON 解析失败,降级空 tags 继续评估");
Vec::new()
}
},
None => Vec::new(),
};
Idea { Idea {
id: record.id.clone(), id: record.id.clone(),
title: record.title.clone(), title: record.title.clone(),

View File

@@ -6,14 +6,15 @@ use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use df_ai::provider::{ChatMessage, CompletionRequest}; use df_ai::provider::{ChatMessage, CompletionRequest};
// F-01 阶段5: 项目扫描描述路由 — TaskRequirements(Standard;采样含图时追加 Vision, // F-01 阶段5: 项目扫描描述路由 — TaskRequirements(采样含图时追加 Vision,
// 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。 // 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。
// 注:路由已解耦(B-260618-03),原 Standard 智力约束已删除,纯 weight 选模型。
use df_ai::router::{ use df_ai::router::{
select_model_id, IntelligenceTier, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_types::types::new_id; use df_types::types::new_id;
use df_project::scan::{ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, is_monorepo, collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject, normalize_path, DiscoveredProject,
}; };
use df_storage::models::ProjectRecord; use df_storage::models::ProjectRecord;
@@ -527,13 +528,11 @@ async fn extract_description_via_llm(
.map_err(err_str)? .map_err(err_str)?
.map_err(err_str)?; .map_err(err_str)?;
// F-01 阶段5: 项目扫描描述路由 — Standard,无工具;采样未含图故仅 Text。 // F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
// select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。 // select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
let scan_req = TaskRequirements { let scan_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let scan_model = select_model_id(&scan_req, &pc.model_configs) let scan_model = select_model_id(&scan_req, &pc.model_configs)
@@ -623,13 +622,11 @@ pub async fn scan_project_with_ai(
}); });
} }
}; };
// F-01 阶段5: 项目扫描描述路由 — Standard,无工具;采样未含图故仅 Text。 // F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
// select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。 // select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
let scan_req = TaskRequirements { let scan_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0, estimated_context: 0,
}; };
let scan_model = select_model_id(&scan_req, &pc.model_configs) let scan_model = select_model_id(&scan_req, &pc.model_configs)

View File

@@ -202,11 +202,13 @@ pub struct AppState {
pub projects: ProjectRepo, pub projects: ProjectRepo,
/// 任务表 Repo /// 任务表 Repo
pub tasks: TaskRepo, pub tasks: TaskRepo,
/// 发布表 Repo /// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub releases: ReleaseRepo, pub releases: ReleaseRepo,
/// 工作流执行表 Repo /// 工作流执行表 Repo
pub workflows: WorkflowRepo, pub workflows: WorkflowRepo,
/// 节点执行表 Repo /// 节点执行表 Repo(预留:NodeExecutionRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub node_executions: NodeExecutionRepo, pub node_executions: NodeExecutionRepo,
/// 工作流事件总线tokio broadcast /// 工作流事件总线tokio broadcast
pub event_bus: EventBus, pub event_bus: EventBus,

View File

@@ -25,7 +25,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": ["nsis"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",

View File

@@ -113,6 +113,7 @@ import { useProjectStore } from './stores/project'
import { useAppSettingsStore } from './stores/appSettings' import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n' import i18n from './i18n'
import AiChat from './components/AiChat.vue' import AiChat from './components/AiChat.vue'
import { aiApi } from '@/api'
const route = useRoute() const route = useRoute()
const isDetached = computed(() => route.path === '/ai-detached') const isDetached = computed(() => route.path === '/ai-detached')
@@ -207,6 +208,20 @@ onMounted(async () => {
await migrateLegacyLocalStorage() await migrateLegacyLocalStorage()
// 3. 缓存已就绪,初始化主题/locale // 3. 缓存已就绪,初始化主题/locale
initTheme() initTheme()
// F-260618-23: 根 onMounted 恢复 Agentic 轮次/重试到后端(AtomicUsize 纯内存,重启回默认)。
// GeneralPanel.vue onMounted 仅 Settings 页挂载,用户直接进 AI Chat 不同步 → 后端默认 10 与前端显示持久值不一致。
// clamp 与 GeneralPanel.syncAgentMaxIterations/syncAgentMaxRetries 逐字对齐(双保险,IPC 幂等值相同无害)
// 包 try/catch 防 IPC 失败 reject onMounted(对齐 dataChangedListener 模式)
try {
const iter = appSettings.get<number>('df-ai-agent-max-iterations', 10)
const iterClamped = Math.min(50, Math.max(1, iter || 10))
const retry = appSettings.get<number>('df-ai-agent-max-retries', 3)
const retryClamped = Math.min(10, Math.max(0, retry ?? 3))
await aiApi.setAgentMaxIterations(iterClamped)
await aiApi.setAgentMaxRetries(retryClamped)
} catch (e) {
console.error('恢复 Agentic 轮次/重试配置失败:', e)
}
const savedLang = appSettings.get<string | null>('df-language', null) const savedLang = appSettings.get<string | null>('df-language', null)
if (savedLang) { if (savedLang) {
i18n.global.locale.value = savedLang as 'zh-CN' | 'en' i18n.global.locale.value = savedLang as 'zh-CN' | 'en'

View File

@@ -128,6 +128,7 @@ export const aiApi = {
baseUrl: string baseUrl: string
apiKey: string apiKey: string
defaultModel: string defaultModel: string
modelConfigs: ModelConfig[]
}): Promise<string> { }): Promise<string> {
return invoke('ai_save_provider', { return invoke('ai_save_provider', {
id: input.id, id: input.id,
@@ -136,6 +137,7 @@ export const aiApi = {
baseUrl: input.baseUrl, baseUrl: input.baseUrl,
apiKey: input.apiKey, apiKey: input.apiKey,
defaultModel: input.defaultModel, defaultModel: input.defaultModel,
modelConfigs: input.modelConfigs,
}) })
}, },

View File

@@ -203,10 +203,10 @@ export type Modality = 'text' | 'vision' | 'audio' | 'video'
/** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */ /** 维度 2 — 能力(模型能做什么)。serde snake_case 字面量。 */
export type Capability = 'tool_use' | 'embedding' | 'code_gen' export type Capability = 'tool_use' | 'embedding' | 'code_gen'
/** 维度 3 — 价格分级(成本)。serde snake_case 字面量。 */ /** 维度 3 — 价格分级(成本)。serde snake_case 字面量。'free' 已删(B-260618-05 死档,预设/启发式从不赋值)。 */
export type CostTier = 'free' | 'low' | 'medium' | 'high' export type CostTier = 'low' | 'medium' | 'high'
/** 维度 4 — 智力分级。serde snake_case 字面量。 */ /** 维度 4 — 智力分级。serde snake_case 字面量。CR-260618-11#3:与 CostTier 同,cost/intel 已从路由硬过滤(B-260618-03)及前端标签(UX-260618-04)去除,字段保留仅 DB 兼容,不再参与路由选模型。 */
export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra' export type IntelligenceTier = 'lite' | 'standard' | 'plus' | 'ultra'
/** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */ /** 探测来源(只读,由后端探测器填充)。serde snake_case 字面量。 */
@@ -300,7 +300,7 @@ export type ContentPart =
/** AI 消息前端渲染用isError 标记错误消息用于差异化样式) */ /** AI 消息前端渲染用isError 标记错误消息用于差异化样式) */
export interface AiMessage { export interface AiMessage {
id: string id: string
role: 'user' | 'assistant' | 'tool' role: 'user' | 'assistant' | 'tool' | 'system'
content: string content: string
isError?: boolean isError?: boolean
toolCalls?: AiToolCallInfo[] toolCalls?: AiToolCallInfo[]

View File

@@ -80,9 +80,22 @@
<template v-else> <template v-else>
<!-- 活跃对话: 今天/昨天/更早 分组 --> <!-- 活跃对话: 今天/昨天/更早 分组 -->
<template v-for="group in groupedActive" :key="'g-' + group.key"> <template v-for="group in groupedActive" :key="'g-' + group.key">
<div class="ai-conv-group-title">{{ group.label }}</div> <div
class="ai-conv-group-title ai-conv-group-title--fold"
@click="store.toggleGroupFold(group.key)"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }"></span>
{{ group.label }}
<span class="ai-conv-group-count">({{ group.items.length }})</span>
</div>
<div
v-if="group.key === 'today' && !group.items.length"
v-show="!store.state.foldedGroups[group.key]"
class="ai-conv-group-empty"
>{{ $t('aiChat.groupEmpty') }}</div>
<div <div
v-for="conv in group.items" v-for="conv in group.items"
v-show="!store.state.foldedGroups[group.key]"
:key="conv.id" :key="conv.id"
class="ai-conv-item" class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
@@ -91,7 +104,7 @@
<div class="ai-conv-item-main"> <div class="ai-conv-item-main">
<input <input
v-if="editingConvId === conv.id" v-if="editingConvId === conv.id"
ref="editEl" :ref="setEditEl"
v-model="editText" v-model="editText"
class="ai-conv-item-edit" class="ai-conv-item-edit"
@keyup.enter="commitRename(conv.id)" @keyup.enter="commitRename(conv.id)"
@@ -364,14 +377,17 @@
<!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' , <!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' ,
正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed 正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文) 合并一条分隔条,点击展开/收起看原文)
UX-2025-19: 每项外层包 sentinel div(虚拟滚动观测点 + 高度占位), UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
不可见窗口外的 msg 内容卸载sentinel 留存占位高度(scrollHeight 不塌) --> slot 仅作 flex 子占位 + 消息分组的语义容器,gap .ai-messages 承担) -->
<template v-for="item in renderItems" :key="item.key"> <template v-for="item in renderItems" :key="item.key">
<!-- B-260618-19: 纯空 assistant( content/error/toolCalls 且非末条流式) slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染; toolCalls 的空 assistant
仍渲染(工具卡可见修复 agentic 工具轮 LLM 无文本时工具卡消失) -->
<div <div
v-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot" class="ai-msg-slot"
:ref="(el) => registerMsgSentinel(item.key, el as Element)"
> >
<!-- 折叠分隔条(归档段 / 压缩段) 轻量,始终渲染不过虚拟化 --> <!-- 折叠分隔条(归档段) 轻量,始终渲染 -->
<div <div
v-if="item.kind === 'sep'" v-if="item.kind === 'sep'"
class="ai-msg-segment" class="ai-msg-segment"
@@ -395,26 +411,29 @@
</span> </span>
</div> </div>
<!-- 消息(正常段 + 展开的折叠段内消息) 不可见时卸载(sentinel 占位高度), <!-- 消息(正常段 + 展开的折叠段内消息) 虚拟滚动已移除(UX-260617-01:IO/RO 时序致重叠),消息恒渲染 -->
流式末条经 pinnedKeys 保活(shouldRenderMsg true) --> <!-- B-260618-19: 纯空 assistant 过滤已上提到外层 .ai-msg-slot v-if(shouldRenderMsg),
<!-- UX-260617-22:跳过已完成且无内容且非错误的 AI 消息 此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg) -->
原模板 ai-md 气泡 v-if content 非空才渲染,空内容只剩 avatar+空白,渲染无意义;
仅当是末条流式(可能 text 刚开始)时不过滤,保留光标气泡占位 -->
<div <div
v-else-if="shouldRenderMsg(item.key) && !(item.msg.role === 'assistant' && !item.msg.content && !item.msg.isError && !(isLastAi(item.msg) && store.state.streaming))" v-else
class="ai-msg" class="ai-msg"
:class="'ai-msg--' + item.msg.role" :class="'ai-msg--' + item.msg.role"
:data-msg-id="item.msg.id" :data-msg-id="item.msg.id"
> >
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠, messageSegments) -->
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
</div>
<!-- 用户消息 --> <!-- 用户消息 -->
<div v-if="item.msg.role === 'user'" class="ai-msg-user"> <div v-else-if="item.msg.role === 'user'" 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 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">
{{ item.msg.content }} {{ item.msg.content }}
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载,虚拟滚动裁剪的是 <!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
整条消息,气泡内 <img> sentinel 卸载无冲突) --> B-260618-01 虚拟滚动已移除,气泡内 <img> 消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length"> <template v-if="msgImageParts(item.msg).length">
<div class="ai-msg-images"> <div class="ai-msg-images">
<img <img
@@ -483,23 +502,14 @@
<span class="ai-cursor"></span> <span class="ai-cursor"></span>
</div> </div>
<!-- UX-02:AI 消息操作栏(hover 显示:复制 / 重新生成) <!-- UX-02:AI 消息操作栏(hover 显示:重新生成)
仅非错误有内容且非流式生成中的 AI 消息显示 复制走气泡内右下角图标(ai-copy-btn--ai),本栏仅留重新生成,故只在
重新生成仅最后一 AI 消息显示(语义:重生成末尾回复) --> AI 消息(重生成语义)非错误有内容非流式中显示 -->
<div <div
v-if="item.msg.content && !item.msg.isError && !store.state.streaming" v-if="isLastAi(item.msg) && item.msg.content && !item.msg.isError && !store.state.streaming"
class="ai-msg-actions" class="ai-msg-actions"
> >
<button <button
class="ai-msg-act"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="11" height="11" 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>
<span>{{ $t('aiChat.copyMsg') }}</span>
</button>
<button
v-if="isLastAi(item.msg)"
class="ai-msg-act" class="ai-msg-act"
:disabled="store.state.streaming" :disabled="store.state.streaming"
:title="$t('aiChat.regenerate')" :title="$t('aiChat.regenerate')"
@@ -567,7 +577,7 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /.ai-msg-slot(sentinel,UX-2025-19 虚拟滚动观测点) --> </div><!-- /.ai-msg-slot(B-260618-01:虚拟滚动已移除,恒渲染容器) -->
</template> </template>
</div> </div>
@@ -758,7 +768,12 @@
</div> </div>
</div> </div>
<!-- 确认弹层(删对话等不可逆操作) --> <!-- 确认弹层(删对话等不可逆操作) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" /> <ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) --> <!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
<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>
@@ -773,7 +788,6 @@ import { useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { useAiStore } from '../stores/ai' import { useAiStore } from '../stores/ai'
import { pendingMaxRounds } from '../composables/ai/useAiEvents' import { pendingMaxRounds } from '../composables/ai/useAiEvents'
import { useAiVirtualScroll } from '../composables/ai/useAiVirtualScroll'
import { aiApi } from '../api' import { aiApi } from '../api'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
@@ -911,7 +925,13 @@ function scheduleStreamParse(text: string) {
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区 // UX-2025-01: 末块 innerHTML 每帧替换,保存选区
saveSelection() saveSelection()
streamingBlocks.value = renderStreamingBlocks(lastStreamText) streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => restoreSelection()) nextTick(() => {
restoreSelection()
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (isFollowingBottom) scrollToBottom()
})
}) })
} }
@@ -1115,13 +1135,6 @@ function imgAlt(img: ImageContentPart): string {
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。 // UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
const editingMsgId = ref<string | null>(null) const editingMsgId = ref<string | null>(null)
const messagesContainer = ref<HTMLDivElement>() const messagesContainer = ref<HTMLDivElement>()
// UX-2025-19: 虚拟滚动(可见窗口懒渲染 + 流式末条保活)。渲染层裁剪,不触碰既有滚动/收起/流式逻辑。
const {
registerSentinel: registerMsgSentinel,
setupOnMount: setupVirtualScroll,
shouldRender: shouldRenderMsg,
setPinned: setPinnedMsgs,
} = useAiVirtualScroll({ root: messagesContainer, rootMarginPx: 600 })
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡) // 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>() const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
@@ -1256,11 +1269,16 @@ function detectMentionTrigger() {
} }
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效) // UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// F-01 阶段6: 同步清 modelOverride(后端 switchConversation/new 已清 session.model_override, // 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 前端 ref 不清则会与后端不一致——override 是单对话级 UI 选择,不跨对话持久化) // 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit() if (editingMsgId.value) cancelEdit()
store.modelOverride.value = null store.modelOverride.value = enabledModels.value[0]?.model_id || null
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
}) })
function selectSkill(s: SkillInfo) { function selectSkill(s: SkillInfo) {
@@ -1387,7 +1405,12 @@ function selectMention(item: MentionItem) {
// 对话项 inline 重命名 // 对话项 inline 重命名
const editingConvId = ref<string | null>(null) const editingConvId = ref<string | null>(null)
const editText = ref('') const editText = ref('')
const editEl = ref<HTMLInputElement>() // 重命名 input 元素引用。该 input 在 v-for 内且由 v-if(editingConvId===conv.id) 单实例化,
// 字符串 ref 在 v-for 中会被收集成数组(.focus/.select 失效),改用函数 ref 直接持有单个元素。
let editEl: HTMLInputElement | null = null
function setEditEl(el: Element | { $el?: Element } | null): void {
editEl = (el as HTMLInputElement) || null
}
// UX-06 §3.1: 对话搜索框引用(Ctrl+K 聚焦用) // UX-06 §3.1: 对话搜索框引用(Ctrl+K 聚焦用)
const searchInputRef = ref<HTMLInputElement>() const searchInputRef = ref<HTMLInputElement>()
@@ -1396,8 +1419,8 @@ function startRename(conv: { id: string; title: string | null }) {
editingConvId.value = conv.id editingConvId.value = conv.id
editText.value = conv.title || '' editText.value = conv.title || ''
nextTick(() => { nextTick(() => {
editEl.value?.focus() editEl?.focus()
editEl.value?.select() editEl?.select()
}) })
} }
@@ -1489,7 +1512,12 @@ const activeProviderRecord = computed(() =>
const enabledModels = computed(() => { const enabledModels = computed(() => {
const p = activeProviderRecord.value const p = activeProviderRecord.value
if (!p?.model_configs) return [] if (!p?.model_configs) return []
return p.model_configs.filter(m => m.enabled) // weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
}) })
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。 // 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null) const isSpecifyMode = computed(() => store.modelOverride.value !== null)
@@ -1501,12 +1529,9 @@ const modelPickerHint = computed(() => {
? t('aiChat.specifyModeHint') ? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint') : t('aiChat.autoModeHint')
}) })
/** 下拉选项 label:model_id + 4 维度简标(智力/价格),复用 settings i18n 标签字面 */ /** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: import('../api/types').ModelConfig): string { function modelOptionLabel(m: import('../api/types').ModelConfig): string {
const name = m.label || m.model_id return m.label || m.model_id
const intel = t('settings.tagIntel.' + m.intelligence)
const cost = t('settings.tagCost.' + m.cost_tier)
return `${name} · ${intel} · ${cost}`
} }
function setAutoMode() { function setAutoMode() {
store.modelOverride.value = null store.modelOverride.value = null
@@ -1563,6 +1588,31 @@ function isLastAi(msg: AiMessage): boolean {
return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id
} }
/**
* B-260618-19: 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
*
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if(UX-260617-22)
* 漏判 toolCalls 把它整条过滤 → 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div,
* flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移。
*
* 现仅过滤「纯空」assistant(无 content / 无 error / 无 toolCalls / 非末条流式):
* - 有 toolCalls → 渲染(工具卡可见,即便本轮 LLM 无文本)
* - 末条 + 流式 → 渲染(光标气泡占位,接收后续 delta)
* - 其余(user/system/有内容 AI)→ 渲染
* - 纯空中间条 → 不渲染(连 slot 一起省,消除空白)
*/
function shouldRenderMsg(msg: AiMessage): boolean {
if (msg.role === 'assistant'
&& !msg.content
&& !msg.isError
&& !(msg.toolCalls && msg.toolCalls.length)
&& !(isLastAi(msg) && store.state.streaming)) {
return false
}
return true
}
/** UX-09:msg 是否为 messages 中最后一条 user 消息(可编辑判定)。 */ /** UX-09:msg 是否为 messages 中最后一条 user 消息(可编辑判定)。 */
function isLastUser(msg: AiMessage): boolean { function isLastUser(msg: AiMessage): boolean {
if (msg.role !== 'user') return false if (msg.role !== 'user') return false
@@ -1648,8 +1698,9 @@ const groupedActive = computed(() => {
} }
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = [] const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
for (const key of ['today', 'yesterday', 'earlier']) { for (const key of ['today', 'yesterday', 'earlier']) {
const items = map.get(key) const items = map.get(key) || []
if (items && items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items }) // 「今天」恒显(空则占位「暂无」);昨天/更早仅非空时出现
if (key === 'today' || items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items })
} }
return groups return groups
}) })
@@ -1989,6 +2040,10 @@ const showBackToBottom = ref(false)
// 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发: // 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发:
// 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。 // 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。
let wasNearBottom = true let wasNearBottom = true
// B-260618-24: 跟随意图锁存。原 onContentChange 仅靠 isNearBottom() 瞬时测量,流式 rAF 高频
// 渲染下单帧增量 >80px 即判不在底 → 跟随意图一帧丢失(点回到底部后又失跟)。锁存解耦
// "意图"与"瞬时测量":点回到底部/滚到底/发新消息→true;用户上滑→false。
let isFollowingBottom = true
function refreshBackToBottom() { function refreshBackToBottom() {
const el = messagesContainer.value const el = messagesContainer.value
if (!el) { showBackToBottom.value = false; return } if (!el) { showBackToBottom.value = false; return }
@@ -2003,10 +2058,13 @@ function onMessagesScroll() {
collapseAllToolLists(buildActiveToolIds(store.state.messages)) collapseAllToolLists(buildActiveToolIds(store.state.messages))
} }
wasNearBottom = near wasNearBottom = near
// B-260618-24: 用户主动滚离底部 = 放弃跟随;滚回底部 = 恢复跟随
isFollowingBottom = near
} }
function scrollToBottom(smooth = false) { function scrollToBottom(smooth = false) {
if (messagesContainer.value) { if (messagesContainer.value) {
isFollowingBottom = true // 显式回底 = 跟随意图
messagesContainer.value.scrollTo({ messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight, top: messagesContainer.value.scrollHeight,
behavior: smooth ? 'smooth' : 'instant', behavior: smooth ? 'smooth' : 'instant',
@@ -2015,24 +2073,31 @@ function scrollToBottom(smooth = false) {
} }
} }
// 消息/流式变化时:在底部则自动滚;上滑看历史则只刷新回底按钮(不强制打断) // 消息/流式变化时:跟随意图则自动滚;则只刷新回底按钮(不强制打断)
// 用 isFollowingBottom 锁存而非 isNearBottom 瞬时测量,避免流式高频下单帧突破阈值误判失跟
function onContentChange() { function onContentChange() {
if (isNearBottom()) { if (isFollowingBottom) {
nextTick(scrollToBottom) nextTick(scrollToBottom)
} else { } else {
refreshBackToBottom() refreshBackToBottom()
} }
} }
watch(() => store.state.messages.length, onContentChange) watch(() => store.state.messages.length, onContentChange)
watch(() => store.state.currentText, onContentChange) // SW-260618-07: currentTextonContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行两 handler)
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo): // 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块 // - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存) // - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存)
// - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback // - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback
// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化 // 纯文本;marked 就绪后主动重算一次,使纯文本转格式化
// SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
watch(() => store.state.currentText, (text) => { watch(() => store.state.currentText, (text) => {
// B-260618-24: 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚),
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
if (store.state.streaming && text) scheduleStreamParse(text) if (store.state.streaming && text) scheduleStreamParse(text)
else onContentChange()
}) })
watch(mdReady, (ready) => { watch(mdReady, (ready) => {
if (ready && store.state.streaming && store.state.currentText) { if (ready && store.state.streaming && store.state.currentText) {
@@ -2155,8 +2220,6 @@ onMounted(async () => {
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器 // pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。 // 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
window.addEventListener('beforeunload', onBeforeUnloadGuard) window.addEventListener('beforeunload', onBeforeUnloadGuard)
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
setupVirtualScroll()
// UX-18: 点击外部关闭导出格式菜单 // UX-18: 点击外部关闭导出格式菜单
document.addEventListener('click', onExportOutsideClick) document.addEventListener('click', onExportOutsideClick)
const t0 = (window as any).__APP_T0 ?? 0 const t0 = (window as any).__APP_T0 ?? 0
@@ -2497,12 +2560,6 @@ interface AiMessageWithStatus extends AiMessage {
status?: string | null status?: string | null
} }
/** 判断消息是否"折叠态"(归档段 / 压缩段) */
function isFoldedStatus(msg: AiMessage): boolean {
const s = (msg as AiMessageWithStatus).status
return s === 'archived_segment' || s === 'compressed'
}
/** /**
* 消息列表分段预处理 — 连续同 status 合并成一条"段"。 * 消息列表分段预处理 — 连续同 status 合并成一条"段"。
* *
@@ -2530,17 +2587,18 @@ const messageSegments = computed<MessageSegment[]>(() => {
while (i < msgs.length) { while (i < msgs.length) {
const m = msgs[i] const m = msgs[i]
const status = (m as AiMessageWithStatus).status const status = (m as AiMessageWithStatus).status
if (status === 'archived_segment' || status === 'compressed') { // F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩
// 连续同 status 合并 // 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。
// 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。
if (status === 'archived_segment') {
// 连续 archived_segment 合并
const group: AiMessage[] = [] const group: AiMessage[] = []
const key = `seg-${m.id}` const key = `seg-${m.id}`
while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === status) { while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === 'archived_segment') {
group.push(msgs[i]) group.push(msgs[i])
i++ i++
} }
segments.push(status === 'archived_segment' segments.push({ kind: 'archived', key, msgs: group })
? { kind: 'archived', key, msgs: group }
: { kind: 'compressed', key, msgs: group })
} else { } else {
segments.push({ kind: 'normal', msg: m }) segments.push({ kind: 'normal', msg: m })
i++ i++
@@ -2592,39 +2650,30 @@ const renderItems = computed<RenderItem[]>(() => {
}) })
/** /**
* 是否存在"活跃消息"(非折叠态)— 控制清空/压缩按钮启用。 * 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。
* 空会话 / 全部已归档/压缩 → 无活跃消息 → 按钮禁用(不发起无意义 IPC) * archived(已清空归档) + compressed(已压缩,后端不再发给 LLM)都算已脱离上下文,不计活跃
* 折叠段内的消息不计(它们已脱离当前上下文)。 * 空会话 / 全归档 / 全压缩 → 无活跃 → 按钮禁用(不发起无意义 IPC)。
*/ */
const hasActiveMessages = computed(() => const hasActiveMessages = computed(() =>
store.state.messages.some(m => !isFoldedStatus(m)), store.state.messages.some(m => {
const s = (m as AiMessageWithStatus).status
return s !== 'archived_segment' && s !== 'compressed'
}),
) )
// UX-2025-19: 流式末条保活 — streaming 时把末条 AI 消息的 renderItem key 钉进虚拟滚动
// pinned 集合,使其无论是否在可见窗口都渲染内容,保证 ARC-08 块级 memo + streamingBlocks
// v-for + 选区保存/恢复不因虚拟化卸载丢帧。非 streaming 时清空(回归正常虚拟化裁剪)。
const lastStreamingRenderKey = computed<string | null>(() => {
if (!store.state.streaming) return null
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') return null
// 末条 streaming AI 消息必为 normal 段(renderItems 里 key='m-'+id)
return 'm-' + last.id
})
watch(lastStreamingRenderKey, (key) => {
setPinnedMsgs(key ? new Set([key]) : new Set())
}, { immediate: true })
/** /**
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。 * 清空当前对话上下文(二次确认 → aiApi.clearContext)。
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。 * 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
* 二次确认:不可逆操作,危险按钮显式「清空」(dangerLabel)。
*/ */
async function handleClearContext(): Promise<void> { async function handleClearContext(): Promise<void> {
const convId = store.state.activeConversationId const convId = store.state.activeConversationId
if (!convId) return if (!convId) return
if (store.state.streaming) return // 生成中禁操作,避免与流式事件竞争 if (store.state.streaming) return // 生成中禁操作,避免与流式事件竞争
if (!hasActiveMessages.value) return // 无活跃消息(空/全归档)禁操作 if (!hasActiveMessages.value) return // 无活跃消息(空/全归档)禁操作
if (!await confirmDialog(t('aiChat.clearContextConfirm'))) return // 二次确认:不可逆操作,危险按钮显式「清空」(不回退通用「确认」)
if (!await confirmDialog(t('aiChat.clearContextConfirm'), t('aiChat.clearContextDangerLabel'))) return
try { try {
await store.clearContext() await store.clearContext()
} catch (e) { } catch (e) {
@@ -2805,6 +2854,17 @@ body.ai-sidebar-resizing * {
.ai-conv-group-arrow:not(.is-folded) { .ai-conv-group-arrow:not(.is-folded) {
transform: rotate(90deg); transform: rotate(90deg);
} }
.ai-conv-group-count {
margin-left: 2px;
font-weight: 400;
opacity: 0.7;
}
.ai-conv-group-empty {
padding: 2px 8px 4px 20px;
font-size: 10px;
color: var(--df-text-dim);
font-style: italic;
}
.ai-conv-item { .ai-conv-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3179,8 +3239,8 @@ body.ai-sidebar-resizing * {
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
/* UX-2025-19: 虚拟滚动 sentinel 容器 — 取代 msg/sep 成为 .ai-messages 的直接 flex 子 /* UX-2025-19: .ai-messages 的直接 flex 子(B-260618-01:虚拟滚动 sentinel 已移除,
(gap:14px 由它承担,内部消息间距零变化)。占位时 minHeight 锁住上次实测高度防塌。 */ 现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
.ai-msg-slot { display: flex; flex-direction: column; } .ai-msg-slot { display: flex; flex-direction: column; }
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area ── */ /* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area ── */
@@ -3346,14 +3406,16 @@ body.ai-sidebar-resizing * {
color: var(--df-danger); color: var(--df-danger);
border-color: rgba(240,101,101,0.3); border-color: rgba(240,101,101,0.3);
} }
/* UX-260617-21:Markdown 表格横向溢出处理。全局 ai-md.css 的 .ai-md table 用 width:100%, /* B-260618-06:修正 UX-260617-21 的 display:block 方案——display:block 让 table 失去表格
宽表格被压窄单元格换行而非横向滚动;此处覆盖为 max-width:100% + display:block + overflow-x:auto, 布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
使宽表格在气泡内横向滚动不撑破气泡容器,窄表格仍自适应不出现多余滚动条。 正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡
深度选择器 :deep() 因 table 经 v-html 注入非 scoped 受控节点。 */ 容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下
overflow 被浏览器忽略,滚动须由 block-layout 父容器承担)。:deep() 因 table 经 v-html
注入非 scoped 受控节点。 */
.ai-msg-bubble.ai-md :deep(table) { .ai-msg-bubble.ai-md :deep(table) {
display: block;
max-width: 100%; max-width: 100%;
width: max-content; }
.ai-msg-bubble--ai.ai-md {
overflow-x: auto; overflow-x: auto;
} }
@@ -3913,6 +3975,27 @@ body.ai-sidebar-resizing * {
opacity: 0.6; opacity: 0.6;
} }
/* ═══ F-15: 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要"。 */
.ai-msg-system {
margin: 6px 0;
padding: 8px 12px;
border-left: 3px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
border-radius: 4px;
font-size: 12px;
}
.ai-msg-system-label {
display: block;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 4px;
font-size: 11px;
}
.ai-msg-system-summary { color: var(--df-text-secondary); line-height: 1.5; }
.ai-msg-system-summary :is(h1, h2, h3) { font-size: 12px; margin: 4px 0 2px; }
/* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══ /* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══
连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。 连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。
风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */ 风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */

View File

@@ -0,0 +1,75 @@
<template>
<!-- 全局错误兜底:子树渲染/生命周期抛错时显示降级 UI,防止白屏
用法: <ErrorBoundary> 包裹可能出错的子组件树(slot 透传) -->
<template v-if="error">
<div class="error-boundary">
<div class="error-boundary-icon"></div>
<div class="error-boundary-title">{{ $t('error.title') }}</div>
<div class="error-boundary-desc">{{ $t('error.desc') }}</div>
<button class="error-boundary-retry" @click="reset">{{ $t('error.retry') }}</button>
</div>
</template>
<slot v-else />
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
// errorCaptured 捕获子组件树抛出的错误(渲染/生命周期),返回 false 阻止继续向上冒泡
const error = ref<unknown>(null)
onErrorCaptured((err) => {
error.value = err
console.error('[ErrorBoundary] 子树渲染错误:', err)
return false
})
// 重试:重置 errorState,slot 重新挂载子树
function reset() {
error.value = null
}
</script>
<style scoped>
.error-boundary {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 16px;
color: var(--df-text-secondary, #888);
text-align: center;
}
.error-boundary-icon {
font-size: 28px;
color: var(--df-danger, #e5484d);
line-height: 1;
}
.error-boundary-title {
font-size: 14px;
font-weight: 500;
color: var(--df-text, #333);
}
.error-boundary-desc {
font-size: 12px;
line-height: 1.5;
max-width: 360px;
}
.error-boundary-retry {
margin-top: 8px;
padding: 5px 16px;
font-size: 12px;
font-weight: 500;
font-family: var(--df-font-sans);
background: var(--df-accent, #7b6ff0);
color: #fff;
border: none;
border-radius: var(--df-radius-sm, 4px);
cursor: pointer;
transition: filter 0.15s;
}
.error-boundary-retry:hover {
filter: brightness(1.1);
}
</style>

View File

@@ -3,7 +3,7 @@
class="ai-tool-card" class="ai-tool-card"
:class="[ :class="[
'ai-tool-card--' + tc.status, 'ai-tool-card--' + tc.status,
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isToolFailure(tc) }, { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed },
]" ]"
> >
<!-- 卡片头部图标 + 名称 + 状态 --> <!-- 卡片头部图标 + 名称 + 状态 -->
@@ -14,10 +14,10 @@
<div class="ai-tool-header-text"> <div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span> <span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span> <span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isToolFailure(tc)" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span> <span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span> <span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
</div> </div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isToolFailure(tc) ? 'failed' : tc.status)"></span> <span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }"></span> <span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }"></span>
</div> </div>
@@ -73,9 +73,11 @@
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span> <span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.lines || 0 }} {{ $t('aiTool.lines') }} · {{ formatBytes(parsed?.size) }}</span> <span class="ai-tool-file-meta">{{ parsed?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)"> <button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expandAll') }} {{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
</button> </button>
</div> </div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre> <pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
@@ -87,6 +89,7 @@
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span> <span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span> <span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: 1000 }) }}</span>
</div> </div>
<div class="ai-tool-dir-entries"> <div class="ai-tool-dir-entries">
<div <div
@@ -103,20 +106,72 @@
</div> </div>
</div> </div>
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染, results 被丢弃致 body 仅显找到 N ) -->
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">{{ parsed?.pattern }}</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.path }}</span>
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- 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 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">{{ parsed?.path }}</span> <span class="ai-tool-write-path">{{ 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?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
UX-260618-06:后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"> {{ parsed.warning }}</div>
<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>
</div>
</div>
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
<div
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
class="ai-tool-cmd-result"
>
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
<span class="ai-tool-cmd-prompt">$</span>
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
<button
v-if="cmdOutput"
class="ai-tool-cmd-toggle"
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="cmdOutputExpanded = !cmdOutputExpanded"
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div> </div>
<!-- 通用 CRUD 结果 --> <!-- 通用 CRUD 结果 -->
<div <div
v-else-if="tc.result && tc.status === 'completed'" v-else-if="tc.result && tc.status === 'completed'"
class="ai-tool-result" class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isToolFailure(tc) }" :class="{ 'ai-tool-result--failed': isFailed }"
> >
<div v-if="isToolFailure(tc)" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div> <div v-if="isFailed" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div>
<code>{{ formatToolResult(tc) }}</code> <code>{{ formatToolResult(tc) }}</code>
</div> </div>
</div><!-- /.ai-tool-body --> </div><!-- /.ai-tool-body -->
@@ -146,6 +201,7 @@ const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) =>
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */ /** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
interface ToolResult { interface ToolResult {
path?: string path?: string
pattern?: string
from?: string from?: string
to?: string to?: string
content?: string content?: string
@@ -184,6 +240,20 @@ interface ToolResult {
has_more?: boolean has_more?: boolean
warning?: string warning?: string
review_rounds?: number review_rounds?: number
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string
execution_id?: string
target_status?: string
note?: string
diff?: string
matches_found?: number
backup_path?: string
returned_lines?: number
items?: Array<Record<string, unknown>>
modified?: number | null
old_size?: number | null
encoding?: string
cross_volume?: boolean
} }
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */ /** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
@@ -239,6 +309,12 @@ function formatToolName(name: string): string {
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
} }
/** run_command 命令首行截断(头部展示;多行命令取首行,超长省略) */
function shortCmd(cmd: string, max = 48): string {
const firstLine = cmd.split('\n')[0].replace(/\s+/g, ' ').trim()
return firstLine.length > max ? firstLine.slice(0, max) + '…' : firstLine
}
function formatJson(data: unknown): string { function formatJson(data: unknown): string {
try { try {
return JSON.stringify(data, null, 2) return JSON.stringify(data, null, 2)
@@ -365,6 +441,30 @@ function formatToolResult(tc: AiToolCallInfo): string {
return raw return raw
} }
switch (tc.name) { switch (tc.name) {
case 'list_projects':
case 'list_tasks':
case 'list_ideas':
case 'list_trash': {
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
const total = typeof r.total === 'number' ? r.total : items.length
const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total })
: tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
: tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total })
: t('aiTool.trashCount', { n: total })
const lines = items.slice(0, 10).map((it: Record<string, unknown>) => {
const label = typeof it.name === 'string' ? it.name
: typeof it.title === 'string' ? it.title
: typeof it.id === 'string' ? it.id
: ''
const status = typeof it.status === 'string' ? ` [${it.status}]` : ''
const stack = typeof it.stack === 'string' ? ` (${it.stack})` : ''
return label ? `- ${label}${status}${stack}` : ''
}).filter(Boolean)
const parts = [head, ...lines]
if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length }))
return parts.join('\n')
}
case 'write_file': case 'write_file':
return `${r.path} (${formatBytes(r.bytes_written)})` return `${r.path} (${formatBytes(r.bytes_written)})`
case 'update_task': case 'update_task':
@@ -372,13 +472,16 @@ function formatToolResult(tc: AiToolCallInfo): string {
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated') : t('aiTool.updated')
case 'advance_task': case 'advance_task':
return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' }) // UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_task': case 'delete_task':
return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file': case 'delete_file':
return r.permanent return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' }) ? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' }) : t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
case 'run_command': case 'run_command':
return formatCommandResult(r) return formatCommandResult(r)
case 'patch_file': case 'patch_file':
@@ -389,7 +492,9 @@ function formatToolResult(tc: AiToolCallInfo): string {
return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file': case 'rename_file':
return r.renamed return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' }) ? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: formatJson(r) : formatJson(r)
case 'search_files': { case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
@@ -401,6 +506,14 @@ function formatToolResult(tc: AiToolCallInfo): string {
return r.bound return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: formatJson(r) : formatJson(r)
case 'run_workflow': {
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
}
default: default:
return formatJson(r) return formatJson(r)
} }
@@ -435,13 +548,18 @@ function formatCommandResult(r: ToolResult): string {
return parts.join('\n') return parts.join('\n')
} }
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数 */ /** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } { function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
const parts: string[] = [] const parts: string[] = []
if (stdout) parts.push(stdout) if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr) if (stderr) parts.push(stderr)
if (!parts.length) return { text: '', truncated: false, total: 0 } return parts.join('\n')
const combined = parts.join('\n') }
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数(合并复用 combineOutputs) */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const combined = combineOutputs(stdout, stderr)
if (!combined) return { text: '', truncated: false, total: 0 }
const allLines = combined.split('\n') const allLines = combined.split('\n')
const total = allLines.length const total = allLines.length
if (total <= maxLines) { if (total <= maxLines) {
@@ -458,6 +576,10 @@ function formatFileInfoResult(r: ToolResult): string {
} }
let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
const extras: string[] = [] const extras: string[] = []
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
if (typeof r.modified === 'number' && r.modified > 0) {
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
}
if (r.is_dir) { if (r.is_dir) {
extras.push(t('aiTool.fileInfoDir')) extras.push(t('aiTool.fileInfoDir'))
} else { } else {
@@ -505,6 +627,12 @@ const emit = defineEmits<{
approve: [{ id: string; approved: boolean }] approve: [{ id: string; approved: boolean }]
}>() }>()
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
// 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。
const cmdOutputExpanded = ref(false)
// SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch
// (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。
/** /**
* 审批按钮 loading 态(B-260616-08)。 * 审批按钮 loading 态(B-260616-08)。
* 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status * 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status
@@ -570,13 +698,16 @@ async function onApprove(approved: boolean) {
}, APPROVE_LOADING_TIMEOUT_MS) }, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: props.tc.id, approved }) emit('approve', { id: props.tc.id, approved })
} }
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器 // SW-260618-06: 合并双 watch(原 cmdOutputExpanded watch + approving 复位 watch,同源 props.tc.status)。
// immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用);
// approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。
watch(() => props.tc.status, (s) => { watch(() => props.tc.status, (s) => {
if (s === 'completed') cmdOutputExpanded.value = isToolFailure(props.tc)
if (s !== 'pending_approval') { if (s !== 'pending_approval') {
approving.value = false approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null } if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
} }
}) }, { immediate: true })
// 组件卸载清计时器防泄漏 // 组件卸载清计时器防泄漏
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer) if (approvingTimer) clearTimeout(approvingTimer)
@@ -586,6 +717,34 @@ onBeforeUnmount(() => {
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */ /** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result)) const parsed = computed(() => parseResult(props.tc.result))
/**
* 是否工具失败(completed 语义)。模板多处复用(.ai-tool-card--failed / 状态点 / 失败横幅等,
* 6+ 引用)。原每处直调 isToolFailure(tc) → 每次 patch 重新 parseResult + 正则,
* 现下沉为 computed 复用 parsed,patch 间不重算。逻辑与模块级 isToolFailure 同源。
*/
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true
}
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
})
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
const cmdOutput = computed(() => {
const r = parsed.value
if (!r) return ''
return combineOutputs(r.stdout, r.stderr)
})
/** /**
* AE-2025-03: write_file 审批 diff 行解析。 * AE-2025-03: write_file 审批 diff 行解析。
* 后端 generate_diff 输出 '+新行/-旧行/ 上下文行',逐行拆 → {kind, text} 供模板按 kind 着色。 * 后端 generate_diff 输出 '+新行/-旧行/ 上下文行',逐行拆 → {kind, text} 供模板按 kind 着色。
@@ -603,6 +762,29 @@ const diffLines = computed(() => {
}).filter((x): x is { kind: string; text: string } => x !== null) }).filter((x): x is { kind: string; text: string } => x !== null)
}) })
/**
* UX-260618-06: patch_file 结果 diff 行拆解(复用 diffLines 同款 +add/-del/ ctx 着色)。
* 数据源不同:diffLines 取 props.tc.diff(审批事件注入,仅 write_file);此 computed 取
* parsed.diff(patch_file 工具执行结果字段)。两条路径互不干扰。
* 长 diff 截断到前 120 行防撑爆卡片(unified diff 含上下文行,大改可能很长)。
*/
const resultDiffLines = computed(() => {
const d = parsed.value?.diff
if (!d) return []
const MAX = 120
const lines = d.split('\n').map(line => {
if (line === '') return null
if (line.startsWith('+')) return { kind: 'add', text: line }
if (line.startsWith('-')) return { kind: 'del', text: line }
return { kind: 'ctx', text: line }
}).filter((x): x is { kind: string; text: string } => x !== null)
if (lines.length > MAX) {
lines.splice(MAX)
lines.push({ kind: 'ctx', text: '…' })
}
return lines
})
/** /**
* 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目, * 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。 * 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
@@ -665,6 +847,11 @@ function toolDisplayName(tc: AiToolCallInfo): string {
} }
case 'create_task': return t('aiTool.createTask') case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject') case 'create_project': return t('aiTool.createProject')
case 'run_command': {
// 折叠态头部也显命令(取首行+截断),无 command 回退通用名
const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
}
default: return formatToolName(tc.name) default: return formatToolName(tc.name)
} }
} }
@@ -679,9 +866,21 @@ function toolResultSummary(tc: AiToolCallInfo): string {
const r = parseResult(tc.result) const r = parseResult(tc.result)
if (!r) return '' if (!r) return ''
switch (tc.name) { switch (tc.name) {
case 'list_tasks': return Array.isArray(r) ? t('aiTool.taskCount', { n: r.length }) : '' case 'list_tasks':
case 'list_projects': return Array.isArray(r) ? t('aiTool.projectCount', { n: r.length }) : '' case 'list_projects':
case 'list_ideas': return Array.isArray(r) ? t('aiTool.ideaCount', { n: r.length }) : '' case 'list_ideas':
case 'list_trash': {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0))
if (!n) return ''
const key = tc.name === 'list_projects' ? 'projectCount'
: tc.name === 'list_tasks' ? 'taskCount'
: tc.name === 'list_ideas' ? 'ideaCount'
: 'trashCount'
return t(`aiTool.${key}`, { n })
}
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created') case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
case 'create_idea': case 'create_idea':
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created') case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
@@ -689,12 +888,14 @@ function toolResultSummary(tc: AiToolCallInfo): string {
case 'update_task': return r.updated !== false case 'update_task': return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated') : t('aiTool.updated')
case 'advance_task': return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' }) case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_project': case 'delete_project':
case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file': return r.permanent case 'delete_file': return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' }) ? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' }) : t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective') case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective')
case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective') case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective')
case 'run_command': { case 'run_command': {
@@ -707,7 +908,9 @@ function toolResultSummary(tc: AiToolCallInfo): string {
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file': return r.renamed case 'rename_file': return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' }) ? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: '' : ''
case 'search_files': { case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
@@ -719,7 +922,13 @@ function toolResultSummary(tc: AiToolCallInfo): string {
case 'bind_directory': return r.bound case 'bind_directory': return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: '' : ''
case 'run_workflow': return t('aiTool.workflowHint') case 'run_workflow': {
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
}
default: return '' default: return ''
} }
} }
@@ -1204,6 +1413,64 @@ function toolResultSummary(tc: AiToolCallInfo): string {
word-break: break-all; word-break: break-all;
} }
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
.ai-tool-cmd-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-cmd-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04);
}
.ai-tool-cmd-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-cmd-prompt {
flex-shrink: 0;
font-weight: 600;
color: var(--df-success);
}
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
color: var(--df-danger);
}
.ai-tool-cmd-text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text);
}
.ai-tool-cmd-toggle {
flex-shrink: 0;
padding: 2px 4px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10.5px;
cursor: pointer;
}
.ai-tool-cmd-toggle:hover {
color: var(--df-text);
background: var(--df-bg-hover, rgba(255,255,255,0.06));
}
.ai-tool-cmd-output {
margin: 0;
padding: 8px 10px;
max-height: 160px;
overflow-y: auto;
background: rgba(0,0,0,0.18);
color: var(--df-text-dim);
white-space: pre-wrap;
word-break: break-all;
border-top: 0.5px solid var(--df-border);
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */ /* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast { .ai-tool-toast {
position: absolute; position: absolute;

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="ai-tool-calls" ref="rootEl"> <div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏批量审批 + 全局收起合并同(UX-260616-02,减少垂直空间占用) --> <!-- 顶部操作栏批量审批(全局收起已下沉到首个可折叠分组标题,U-260618) -->
<div v-if="pendingCount > 0 || collapsibleGroupCount > 0" class="ai-tool-topbar"> <div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏存在待审批项时显示 --> <!-- 批量操作栏存在待审批项时显示 -->
<div v-if="pendingCount > 0" class="ai-batch-approve"> <div class="ai-batch-approve">
<button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')"> <button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')">
{{ $t('aiTool.approveAll', { n: pendingCount }) }} {{ $t('aiTool.approveAll', { n: pendingCount }) }}
</button> </button>
@@ -11,12 +11,6 @@
{{ $t('aiTool.rejectAll') }} {{ $t('aiTool.rejectAll') }}
</button> </button>
</div> </div>
<!-- 全部展开 / 全部收起仅当存在可折叠分组时显示推到行尾右对齐 -->
<div v-if="collapsibleGroupCount > 0" class="ai-tool-global-toggle" @click="toggleAllGroups">
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div> </div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name"> <template v-for="(group, gi) in groupedToolCalls" :key="group.name">
@@ -26,6 +20,15 @@
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span> <span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-count">{{ group.calls.length }}</span> <span class="ai-tool-group-count">{{ group.calls.length }}</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>
<!-- 全部展开/收起(U-260618: topbar 下沉到首个可折叠分组标题行尾巴, run command 同行) -->
<div
v-if="gi === firstCollapsibleGi"
class="ai-tool-global-toggle"
@click.stop="toggleAllGroups"
>
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div> </div>
<ToolCard <ToolCard
v-for="(tc, ci) in group.calls" v-for="(tc, ci) in group.calls"
@@ -33,7 +36,7 @@
:tc="tc" :tc="tc"
:isExpanded="isCardExpanded(tc, gi, ci)" :isExpanded="isCardExpanded(tc, gi, ci)"
:isContentExpanded="expandedTools.has(tc.id)" :isContentExpanded="expandedTools.has(tc.id)"
@toggle="toggleCardExpand" @toggle="() => toggleCardExpand(tc, gi, ci)"
@expand-content="toggleExpand" @expand-content="toggleExpand"
@approve="(e) => emit('approve', e)" @approve="(e) => emit('approve', e)"
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }" :class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }"
@@ -72,6 +75,9 @@ const expandedCards = ref(new Set<string>())
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。 // userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。 // 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>()) const userExpandedCards = ref(new Set<string>())
// UX-260617-23:用户主动折叠记忆(修复首卡 ci===0 兜底 return true 导致无法折叠)。
// isCardExpanded 最优先判断,覆盖默认首卡展开 + userExpandedCards 记忆。
const userCollapsedCards = ref(new Set<string>())
// 内容级展开read_file "展开全部" // 内容级展开read_file "展开全部"
const expandedTools = ref(new Set<string>()) const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起 // 分组折叠状态(按分组索引);多卡片分组默认收起
@@ -108,6 +114,14 @@ const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length groupedToolCalls.value.filter(g => g.calls.length >= 2).length
) )
/** 首个可折叠分组索引(U-260618:全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i
}
return -1
})
/** 待审批工具数status === 'pending_approval'),控制批量操作栏显隐 */ /** 待审批工具数status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() => const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length props.toolCalls.filter(tc => tc.status === 'pending_approval').length
@@ -207,6 +221,8 @@ function groupDisplayName(group: ToolCallGroup): string {
*/ */
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean { function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false if (isGroupCollapsed(gi)) return false
// UX-260617-23:用户主动折叠最优先(覆盖默认首卡 ci===0 兜底展开 + 用户展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false
// UX-260616-03:用户主动展开的卡片记忆态优先(不被自动收起影响) // UX-260616-03:用户主动展开的卡片记忆态优先(不被自动收起影响)
if (userExpandedCards.value.has(tc.id)) return true if (userExpandedCards.value.has(tc.id)) return true
// 用户手动切换过该卡(当前态) // 用户手动切换过该卡(当前态)
@@ -217,15 +233,21 @@ function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
return true return true
} }
function toggleCardExpand(id: string) { function toggleCardExpand(tc: AiToolCallInfo, gi: number, ci: number) {
if (expandedCards.value.has(id)) { const id = tc.id
// 用 isCardExpanded 判当前真实态(含默认首卡展开),而非 expandedCards(默认首卡不在其中,
// 否则首卡点 toggle 会误判"当前折叠"反向展开)
const currentlyExpanded = isCardExpanded(tc, gi, ci)
if (currentlyExpanded) {
// 展开 → 折叠
expandedCards.value.delete(id) expandedCards.value.delete(id)
// 收起时同步清记忆(下次自动收起可作用于它)
userExpandedCards.value.delete(id) userExpandedCards.value.delete(id)
userCollapsedCards.value.add(id)
} else { } else {
// 折叠 → 展开
expandedCards.value.add(id) expandedCards.value.add(id)
// UX-260616-03:展开即记忆(用户主动展开,后续不被自动收起)
userExpandedCards.value.add(id) userExpandedCards.value.add(id)
userCollapsedCards.value.delete(id)
} }
} }
@@ -313,24 +335,21 @@ function groupIcon(name: string): string {
font-size: 11px; font-size: 11px;
} }
/* -- 全部展开/收起按钮 -- */ /* -- 全部展开/收起(U-260618:从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
.ai-tool-global-toggle { .ai-tool-global-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 4px 10px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
font-size: 11px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
border-radius: var(--df-radius-sm); transition: color 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease); /* 推到分组标题行尾巴右对齐 */
width: fit-content;
/* UX-260616-02: 合并同行时推到行尾右对齐(batch-approve 在左,收起在右) */
margin-left: auto; margin-left: auto;
} }
.ai-tool-global-toggle:hover { .ai-tool-global-toggle:hover {
background: rgba(255,255,255,0.03);
color: var(--df-text-secondary); color: var(--df-text-secondary);
} }
.ai-tool-global-toggle-icon { .ai-tool-global-toggle-icon {
@@ -344,6 +363,8 @@ function groupIcon(name: string): string {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 10px; padding: 6px 10px;
/* U-260618: max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */
max-width: var(--df-msg-max-width);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
@@ -378,7 +399,7 @@ function groupIcon(name: string): string {
color: var(--df-accent); color: var(--df-accent);
} }
.ai-tool-group-chevron { .ai-tool-group-chevron {
margin-left: auto; /* U-260618: 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
font-size: 10px; font-size: 10px;
color: var(--df-text-dim); color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease); transition: transform 0.15s var(--df-ease);

View File

@@ -264,8 +264,11 @@ onMounted(() => {
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值) // 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig() syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值) // F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
// F-260618-23: App.vue 根 onMounted 已在应用启动时恢复(覆盖用户没先进 Settings 直接用 AI Chat 的场景),
// 此处保留双保险IPC 幂等值相同无害)
syncAgentMaxIterations() syncAgentMaxIterations()
// F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值) // F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值)
// F-260618-23: 同上App.vue 根 onMounted 已恢复,此处双保险保留
syncAgentMaxRetries() syncAgentMaxRetries()
}) })

View File

@@ -111,8 +111,6 @@
<div class="model-tags"> <div class="model-tags">
<span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span> <span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span>
<span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span> <span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span>
<span class="tag tag-cost" :class="'tag-cost--' + m.cost_tier">{{ $t('settings.tagCost.' + m.cost_tier) }}</span>
<span class="tag tag-intel" :class="'tag-intel--' + m.intelligence">{{ $t('settings.tagIntel.' + m.intelligence) }}</span>
<span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span> <span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
</div> </div>
</div> </div>
@@ -250,6 +248,7 @@ async function saveProvider() {
baseUrl: providerForm.baseUrl, baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey, apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel, defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
}) })
providerForm.visible = false providerForm.visible = false
await loadProviders() await loadProviders()

View File

@@ -22,6 +22,26 @@ const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>
async function loadConversations() { async function loadConversations() {
try { try {
state.conversations = await aiApi.listConversations() state.conversations = await aiApi.listConversations()
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
// 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。
if (state.activeConversationId
&& !state.conversations.some(c => c.id === state.activeConversationId)) {
const now = String(Date.now())
state.conversations.unshift({
id: state.activeConversationId,
title: null,
provider_id: null,
model: null,
archived: false,
pinned: false,
prompt_tokens: 0,
completion_tokens: 0,
created_at: now,
updated_at: now,
})
}
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时) // 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
const pending = appSettings.get<string | null>('df-ai-active-conv', null) const pending = appSettings.get<string | null>('df-ai-active-conv', null)
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) { if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
@@ -100,7 +120,8 @@ export async function switchConversation(id: string) {
role: m.role, role: m.role,
content: m.content || '', content: m.content || '',
model: m.model, model: m.model,
timestamp: Date.now(), // 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[] // F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段, // 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片, // 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
@@ -230,6 +251,12 @@ function toggleArchivedFold() {
persistUiState() persistUiState()
} }
/** 折叠/展开时间分组(today/yesterday/earlier) */
function toggleGroupFold(key: string) {
state.foldedGroups[key] = !state.foldedGroups[key]
persistUiState()
}
/** 展开/收起侧栏(会话列表) */ /** 展开/收起侧栏(会话列表) */
function toggleSidebar() { function toggleSidebar() {
state.sidebarOpen = !state.sidebarOpen state.sidebarOpen = !state.sidebarOpen
@@ -246,6 +273,7 @@ export function useAiConversations() {
archiveConversation, archiveConversation,
setPinnedConversation, setPinnedConversation,
toggleArchivedFold, toggleArchivedFold,
toggleGroupFold,
toggleSidebar, toggleSidebar,
} }
} }

View File

@@ -38,6 +38,9 @@ function applyUiState(s: any) {
if (typeof s.maximized === 'boolean') state.maximized = s.maximized if (typeof s.maximized === 'boolean') state.maximized = s.maximized
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
if (s.foldedGroups && typeof s.foldedGroups === 'object') {
state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups }
}
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底) // 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth) state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
} }
@@ -63,6 +66,7 @@ export function persistUiState() {
maximized: state.maximized, maximized: state.maximized,
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
archivedCollapsed: state.archivedCollapsed, archivedCollapsed: state.archivedCollapsed,
foldedGroups: state.foldedGroups,
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘 // 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
sidebarWidth: state.sidebarWidth, sidebarWidth: state.sidebarWidth,
}) })

View File

@@ -1,175 +0,0 @@
/**
* useAiVirtualScroll — AiChat 消息列表虚拟滚动(IntersectionObserver 可见窗口懒渲染)。
*
* 设计取舍(UX-2025-19):选自研 IntersectionObserver 而非 vue-virtual-scroller。
* - 零新依赖(项目 package.json 无 vue-virtual-scroller)。
* - vue-virtual-scroller DynamicScroller 会接管滚动容器 + 重排子节点,
* 破坏 .ai-messages 现有 flex/gap 布局、onMessagesScroll 边沿收起(wasNearBottom)、
* isNearBottom/scrollToBottom 计算等"渲染层"以外的既有逻辑。
* - 自研方案只做"渲染层裁剪":可见窗口外的 item 内容卸载、保留占位高度,
* scrollHeight/scrollTop 计算与既有滚动逻辑零感知。
*
* 工作机制:
* 1. 每个列表项包一个 sentinel div(始终挂载,只承担高度占位 + IO 观测)。
* 2. IntersectionObserver root=滚动容器,rootMargin 上下各预加载一段(默认 600px),
* 保证滚动时内容提前就位,不出现空白闪烁。
* 3. sentinel 进入预加载区→key 进 renderedKeys→内容渲染;离开→移除→内容卸载,
* 但 sentinel 自身保留(其已测得的高度经 inline style 占位,scrollHeight 不塌)。
* 4. 高度测量:ResizeObserver 观测 sentinel(内容挂载时其高度=内容高度),
* 卸载前把最后一次实测高度写进 inline style,卸载后 sentinel 仍占该高度。
* 5. 流式末条保活:pinnedKeys 中的 key 永远视为"可见",不虚拟化(见 AiChat.vue 调用点
* 把 streaming 末条 AI 消息 key 传入),保证流式渲染(ARC-08 块级 memo +
* streamingBlocks v-for + 选区保存/恢复)不因虚拟化丢帧。
*
* 不处理(留观察,见诚实拆波):
* - ToolCard 嵌套内部的虚拟化(单条消息内多工具卡,当前不裁剪,首屏内条目少)。
* - 编辑光标在 user 消息上的保持(UX-09 走 input 框回填,与列表虚拟化无耦合)。
*/
import { ref, onBeforeUnmount, type Ref } from 'vue'
export interface AiVirtualScrollOptions {
/** 滚动容器(IntersectionObserver root) */
root: Ref<HTMLElement | null | undefined>
/** 上下预加载像素数(可见区外提前渲染的高度,防滚出空白)。 */
rootMarginPx?: number
}
export function useAiVirtualScroll(options: AiVirtualScrollOptions) {
const rootMargin = options.rootMarginPx ?? 600
// 当前"应当渲染内容"的 key 集合(IO 判定可见 + pinned)。模板读这个判断渲染与否。
const renderedKeys = ref<Set<string>>(new Set())
// 永远渲染的 key(流式末条等),由调用方维护传入。
const pinnedKeys = ref<Set<string>>(new Set())
// 每个观测项的元数据:DOM 元素 + 最近实测高度(卸载后占位用)。
const itemMeta = new Map<string, { el: HTMLElement; height: number }>()
let io: IntersectionObserver | null = null
let ro: ResizeObserver | null = null
function ensureObserver(): void {
if (io) return
const rootEl = options.root.value
if (!rootEl) return // root 未就绪;setupOnMount(root ref 就绪后)会再调一次建观察器
io = new IntersectionObserver(
(entries) => {
const next = new Set(renderedKeys.value)
let changed = false
for (const e of entries) {
const key = (e.target as HTMLElement).dataset.vscrollKey
if (!key) continue
const visible = e.isIntersecting
const has = next.has(key)
if (visible && !has) {
// 重新可见:清掉卸载时锁的占位 minHeight,让内容自然撑高(RO 再回填真实高度)
const meta = itemMeta.get(key)
if (meta && meta.el.isConnected) meta.el.style.minHeight = ''
next.add(key); changed = true
}
else if (!visible && has && !pinnedKeys.value.has(key)) {
// 卸载前:把实测高度锁到 sentinel 的 minHeight,防内容移除后 sentinel 塌成 0
// → scrollHeight 不变 → isNearBottom/scrollToBottom/回到底部计算零感知。
// 内容重新挂载时 RO 会把真实高度回填,registerSentinel 复挂时清 minHeight 由真实高度接管。
// 防御(P0):RO 首次回调前 item 已被 IO 判不可见时 meta.height===0,直接不设 minHeight
// → sentinel 塌 0 高 → 下条消息上浮重叠。此处兜底:height>0 用实测值,
// height===0 用 el.offsetWidth 量得的实际渲染高度(此时 el 仍在 DOM),仍<=0 则用固定 40px。
const meta = itemMeta.get(key)
if (meta && meta.el.isConnected) {
const measured = meta.height > 0 ? meta.height : (meta.el.offsetHeight || 0)
const placeholder = measured > 0 ? measured : 40
meta.el.style.minHeight = `${placeholder}px`
}
next.delete(key); changed = true
}
}
if (changed) renderedKeys.value = next
},
{ root: rootEl, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px`, threshold: 0 },
)
ro = new ResizeObserver((entries) => {
for (const e of entries) {
const key = (e.target as HTMLElement).dataset.vscrollKey
if (!key) continue
const meta = itemMeta.get(key)
if (meta) meta.height = e.contentRect.height
}
})
}
/**
* 模板 sentinel 注册回调(每个 v-for 项的 ref 调用一次,挂载时 el 非空、卸载时为 null)。
* 调用方在 v-for item 上用 `:ref="(el) => registerSentinel(key, el as HTMLElement)"`。
*/
function registerSentinel(key: string, el: Element | null | undefined): void {
const prev = itemMeta.get(key)
if (el instanceof HTMLElement) {
el.dataset.vscrollKey = key
// 复用上次实测高度作为初始 inline 占位(再次挂载时先占位,内容挂载后 RO 再校准)。
// 防御(P0):首次挂载(无 prev / RO 未回填)h<=0 时若不设占位,sentinel 首帧塌 0 高 →
// 列表首次渲染时下条上浮重叠。此处用保守默认 40px 占位,RO 回调后用真实高度覆盖。
const h = prev?.height ?? 0
const placeholder = h > 0 ? h : 40
el.style.minHeight = `${placeholder}px`
itemMeta.set(key, { el, height: h })
ensureObserver()
io?.observe(el)
ro?.observe(el)
// 新挂载/初次进入的项默认视为应渲染(IO 回调会按真实相交态修正)
if (!renderedKeys.value.has(key)) {
const next = new Set(renderedKeys.value)
next.add(key)
renderedKeys.value = next
}
} else if (prev) {
// 卸载:停止观测,保留 height 元数据供下次复用(itemMeta 不删)
io?.unobserve(prev.el)
ro?.unobserve(prev.el)
// el 已被 Vue 移除,记录保留 height 即可
itemMeta.set(key, { el: prev.el, height: prev.height })
}
}
/** 滚动容器 ref 就绪后调用(通常 onMounted),补建观察器并预渲染首批可见项。 */
function setupOnMount(): void {
ensureObserver()
// 不主动全量 observe——registerSentinel 在模板挂载时已逐项 observe。
}
/** 是否应渲染该项内容(IO 可见 或 pinned)。 */
function shouldRender(key: string): boolean {
// 🔍 诊断(P0 消息重叠):临时禁用虚拟滚动裁剪,所有消息内容恒渲染,
// 隔离"历史消息打开全重叠"根因。若禁用后重叠消失→虚拟滚动时序根因;
// 若仍重叠→CSS/markdown DOM 根因。定位后回退此改动。
void key
return true
// return renderedKeys.value.has(key) || pinnedKeys.value.has(key)
}
/** 更新 pinned 集合(调用方 watch 流式末条后传入新集合)。 */
function setPinned(keys: Set<string>): void {
pinnedKeys.value = keys
// pinned 的 key 强制进渲染集(IO 不可见也渲染)
if (keys.size > 0) {
const next = new Set(renderedKeys.value)
let changed = false
for (const k of keys) if (!next.has(k)) { next.add(k); changed = true }
if (changed) renderedKeys.value = next
}
}
onBeforeUnmount(() => {
io?.disconnect()
ro?.disconnect()
io = null
ro = null
itemMeta.clear()
})
return {
renderedKeys,
pinnedKeys,
registerSentinel,
setupOnMount,
shouldRender,
setPinned,
}
}

View File

@@ -43,7 +43,7 @@ async function detachPanel() {
const win = new WebviewWindow('ai-detached', { const win = new WebviewWindow('ai-detached', {
url, url,
title: 'DevFlow AI', title: 'DevFlow AI',
width: 520, width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
height: 700, height: 700,
minWidth: 360, minWidth: 360,
minHeight: 400, minHeight: 400,
@@ -183,17 +183,56 @@ async function dockDetached() {
} }
} }
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */ /** 吸附态 AI 窗口宽度/间隙常量(U-260618) */
const AI_DOCK_WIDTH = 600
const AI_DOCK_GAP = 4
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
let _isSyncing = false
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
async function syncToMain() { async function syncToMain() {
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') if (_isSyncing) return
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi') _isSyncing = true
const mainWin = await WebviewWindow.getByLabel('main') try {
const aiWin = getCurrentWebviewWindow() const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
if (!mainWin) return const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
const pos = await mainWin.outerPosition() const { currentMonitor } = await import('@tauri-apps/api/window')
const size = await mainWin.innerSize() const mainWin = await WebviewWindow.getByLabel('main')
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y)) const aiWin = getCurrentWebviewWindow()
await aiWin.setSize(new PhysicalSize(600, size.height)) if (!mainWin) return
const monitor = await currentMonitor()
const screenWidth = monitor?.size.width ?? 0
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
if (await mainWin.isMaximized()) {
await mainWin.unmaximize()
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
}
let pos = await mainWin.outerPosition()
let size = await mainWin.innerSize()
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
if (newMainWidth >= 400) {
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
pos = await mainWin.outerPosition()
size = await mainWin.innerSize()
}
}
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
} catch (e) {
console.error('[AI] syncToMain 失败:', e)
} finally {
_isSyncing = false
}
} }
/** 启动主窗口 move/resize 跟随(吸附时调用) */ /** 启动主窗口 move/resize 跟随(吸附时调用) */

View File

@@ -9,18 +9,22 @@
//! 各视图绑定 confirmState 的 visible/msg 并把按钮点击回 answerConfirm 即可。 //! 各视图绑定 confirmState 的 visible/msg 并把按钮点击回 answerConfirm 即可。
//! //!
//! i18n:composable 在 setup 内调用,文案由调用方用各自 useI18n().t 翻译后传入 msg, //! i18n:composable 在 setup 内调用,文案由调用方用各自 useI18n().t 翻译后传入 msg,
//! 这里无需 i18n 依赖。dangerLabel ConfirmDialog 组件兜底为 common.delete。 //! 这里无需 i18n 依赖。dangerLabel 透传给 ConfirmDialog,空串时组件兜底为 common.confirm
//! (语义中性,删除/清空等场景应显式传 common.delete / 「清空」等)。
import { ref } from 'vue' import { ref } from 'vue'
export interface ConfirmState { export interface ConfirmState {
visible: boolean visible: boolean
msg: string msg: string
/** 危险按钮文案;空串回退到 ConfirmDialog 组件兜底(common.confirm) */
dangerLabel: string
resolve: null | ((v: boolean) => void) resolve: null | ((v: boolean) => void)
} }
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消 */ /** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消
export type ConfirmFn = (msg: string) => Promise<boolean> * dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
/** 关闭弹层并回传结果 */ /** 关闭弹层并回传结果 */
export type AnswerFn = (ok: boolean) => void export type AnswerFn = (ok: boolean) => void
@@ -29,15 +33,17 @@ export function useConfirm() {
const confirmState = ref<ConfirmState>({ const confirmState = ref<ConfirmState>({
visible: false, visible: false,
msg: '', msg: '',
dangerLabel: '',
resolve: null, resolve: null,
}) })
function confirmDialog(msg: string): Promise<boolean> { function confirmDialog(msg: string, dangerLabel = ''): Promise<boolean> {
return new Promise(resolve => { return new Promise(resolve => {
// CR-260615-31:并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时, // CR-260615-31:并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时,
// 前一个 Promise 永挂(resolve 被覆盖)→ 先 resolve?.(false) 打断前一个视为取消 // 前一个 Promise 永挂(resolve 被覆盖)→ 先 resolve?.(false) 打断前一个视为取消
confirmState.value.resolve?.(false) confirmState.value.resolve?.(false)
confirmState.value.msg = msg confirmState.value.msg = msg
confirmState.value.dangerLabel = dangerLabel
confirmState.value.visible = true confirmState.value.visible = true
confirmState.value.resolve = resolve confirmState.value.resolve = resolve
}) })

View File

@@ -20,6 +20,7 @@ export default {
today: 'Today', today: 'Today',
yesterday: 'Yesterday', yesterday: 'Yesterday',
earlier: 'Earlier', earlier: 'Earlier',
groupEmpty: 'None',
// ── Header buttons ── // ── Header buttons ──
conversationList: 'Chat list', conversationList: 'Chat list',
@@ -155,7 +156,10 @@ export default {
clearContext: 'Clear context', clearContext: 'Clear context',
compressContext: 'Compress context', compressContext: 'Compress context',
// Confirm dialog (clear is semantically irreversible; history archived, not deleted) // Confirm dialog (clear is semantically irreversible; history archived, not deleted)
clearContextConfirm: 'Clear the current chat context? History is archived and preserved; new chats are not affected.', // Title "Clear context" folded into the lead sentence; stress irreversible + archived segment + AI no longer sees history
clearContextConfirm: 'Clear context? This action is irreversible. The current chat context will be archived as a segment (the AI will no longer see these history messages). New chats are not affected.',
// Danger button label for the confirm dialog (ConfirmDialog dangerLabel); falls back to common.confirm if omitted
clearContextDangerLabel: 'Clear',
// Collapsed separator (consecutive same-status merged into one bar; {n}=merged message count) // Collapsed separator (consecutive same-status merged into one bar; {n}=merged message count)
contextArchived: 'Archived {n} messages', contextArchived: 'Archived {n} messages',
compressed: 'Context compressed', compressed: 'Context compressed',

View File

@@ -11,12 +11,17 @@ export default {
// UX-260616-01: tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success) // UX-260616-01: tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed', executionFailed: 'Failed',
executionFailedHint: 'Tool execution failed, see details below', executionFailedHint: 'Tool execution failed, see details below',
// Singular expand/collapse (ToolCard single-card content, command output toggle; expandAll/collapseAll are for ToolCardList global multi-group)
expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
expandAll: 'Expand all', expandAll: 'Expand all',
collapseAll: 'Collapse all', collapseAll: 'Collapse all',
written: 'written', written: 'written',
items: 'items', items: 'items',
lines: 'lines', lines: 'lines',
// UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap)
linesTruncated: '{shown}/{total} lines, truncated',
dirTruncated: 'Truncated, first {n} items only',
readPrefix: 'Read', readPrefix: 'Read',
readFallback: 'Read File', readFallback: 'Read File',
@@ -39,6 +44,9 @@ export default {
taskCount: '{n} tasks', taskCount: '{n} tasks',
projectCount: '{n} projects', projectCount: '{n} projects',
ideaCount: '{n} ideas', ideaCount: '{n} ideas',
// UX-260618-10/12: list_trash collapsed header count + paged has_more hint (backend returns {items,total,has_more} object)
trashCount: '{n} in trash',
listHasMore: '({n} shown, more available)',
createdWithName: 'Created: {name}', createdWithName: 'Created: {name}',
created: 'Created', created: 'Created',
updatedField: 'Updated {field}', updatedField: 'Updated {field}',
@@ -50,22 +58,38 @@ export default {
purged: 'Purged', purged: 'Purged',
purgeIneffective: 'Purge ineffective', purgeIneffective: 'Purge ineffective',
workflowHint: 'Run it on the workflow page', workflowHint: 'Run it on the workflow page',
// UX-260618-05: run_workflow tool_result shows execution_id (workflowHint misleading — workflow already triggered; old key kept for other refs)
workflowTriggered: 'Task {id} triggered workflow (advancing to {status}, execution {exec})',
workflowTriggeredShort: 'Execution {exec}',
// UX-260616-04: formatToolResult covers all tools with human-readable summaries (missing keys) // UX-260616-04: formatToolResult covers all tools with human-readable summaries (missing keys)
updatedTaskField: 'Task {id} updated field {field}', updatedTaskField: 'Task {id} updated field {field}',
advancedTask: 'Task {id} advanced to {status}', advancedTask: 'Task {id} advanced to {status}',
// UX-260618-09: advance_task return-round accumulation (shown only when >0)
advancedTaskWithRounds: 'Task {id} advanced to {status} (returned {n} times)',
commandOk: 'Command succeeded ({ms} ms)', commandOk: 'Command succeeded ({ms} ms)',
commandFailed: 'Command failed (exit code {code})', commandFailed: 'Command failed (exit code {code})',
commandOutputLines: '{n} lines of output', commandOutputLines: '{n} lines of output',
patchedFile: 'Patched {path} ({diff} bytes)', patchedFile: 'Patched {path} ({diff} bytes)',
// UX-260618-06: patch_file multi-match hint + size_diff unit
patchedMatches: 'matched {n} places',
bytesUnit: 'bytes',
patchedNoChange: '{path} no change', patchedNoChange: '{path} no change',
appendedTo: 'Appended {bytes} to {path}', appendedTo: 'Appended {bytes} to {path}',
renamedFile: 'Renamed {from} → {to}', renamedFile: 'Renamed {from} → {to}',
// UX-260618-12: rename_file bytes/cross-volume + write_file overwrite/encoding hints
renamedWithBytes: 'Renamed {from} → {to} ({bytes})',
renamedCrossVolume: 'Moved {from} → {to} (cross-volume, {bytes})',
writeOverwrite: 'overwrote {old} → {neww}',
encodingLabel: 'encoding: {enc}',
foundN: 'Found {n}', foundN: 'Found {n}',
boundDir: 'Bound directory "{path}" stack: {stack}', boundDir: 'Bound directory "{path}" stack: {stack}',
deleteFilePermanent: 'Permanently deleted {path}', deleteFilePermanent: 'Permanently deleted {path}',
deleteFileSoft: 'Soft-deleted {path} (backed up)', // UX-260618-07: soft-delete adds backup_path (trash filename so user knows where to recover from)
deleteFileSoft: 'Soft-deleted {path} (backed up, recover from: {backup_path})',
fileInfoSize: '{path} {size}', fileInfoSize: '{path} {size}',
// UX-260618-12: file_info modified time (epoch_ms → client toLocaleString)
fileInfoModified: ' · modified {time}',
fileInfoLines: ' · {n} lines', fileInfoLines: ' · {n} lines',
fileInfoBinary: ' · binary', fileInfoBinary: ' · binary',
fileInfoDir: ' · directory', fileInfoDir: ' · directory',

46
src/i18n/en/auditLog.ts Normal file
View File

@@ -0,0 +1,46 @@
export default {
auditLog: {
title: 'Approval History',
refresh: 'Refresh',
desc: 'AI tool call audit records (newest first). Arguments and results show summaries only; full data persists in the local database.',
loading: 'Loading…',
empty: 'No audit records',
col: {
time: 'Requested',
tool: 'Tool',
risk: 'Risk',
status: 'Status',
decided: 'Decided by',
args: 'Args summary',
result: 'Result summary',
},
pager: {
prev: 'Prev',
next: 'Next',
page: 'Page {n}',
last: '(last)',
},
risk: {
low: 'Low',
medium: 'Med',
high: 'High',
},
status: {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
executing: 'Executing',
completed: 'Completed',
failed: 'Failed',
},
decided: {
human: 'Human',
auto: 'Auto',
},
},
}

7
src/i18n/en/error.ts Normal file
View File

@@ -0,0 +1,7 @@
export default {
error: {
title: 'Render Error',
desc: 'This section failed to render due to an internal error. Try reloading.',
retry: 'Retry',
},
}

View File

@@ -51,11 +51,9 @@ export default {
fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}', fetchFailedNotFound: 'Endpoint not found, this vendor may not support model listing: {msg}',
toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch', toastModelEditLocalOnly: 'Enabled / weight are local edits, overwritten on next fetch',
// Model trait tags (4 dimensions + probe source) // Model trait tags (modality + capability + probe source; cost/intel 去除见 UX-260618-04)
tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' }, tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' },
tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' }, tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' },
tagCost: { free: 'Free', low: 'Low', medium: 'Medium', high: 'High' },
tagIntel: { lite: 'Lite', standard: 'Standard', plus: 'Plus', ultra: 'Ultra' },
tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' }, tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' },
// ===== Connection panel ===== // ===== Connection panel =====

View File

@@ -27,6 +27,7 @@ export default {
group: { group: {
taskCount: '{n} tasks', taskCount: '{n} tasks',
unknownProject: 'Unknown Project', unknownProject: 'Unknown Project',
empty: 'No tasks yet',
}, },
// New task modal // New task modal

View File

@@ -20,6 +20,7 @@ export default {
today: '今天', today: '今天',
yesterday: '昨天', yesterday: '昨天',
earlier: '更早', earlier: '更早',
groupEmpty: '暂无',
// ── Header 按钮 ── // ── Header 按钮 ──
conversationList: '对话列表', conversationList: '对话列表',
@@ -156,7 +157,10 @@ export default {
clearContext: '清空上下文', clearContext: '清空上下文',
compressContext: '压缩上下文', compressContext: '压缩上下文',
// 二次确认(清空不可逆语义,防误触;历史归档保留不删) // 二次确认(清空不可逆语义,防误触;历史归档保留不删)
clearContextConfirm: '确定清空当前对话上下文?历史消息归档保留,不影响新对话', // 标题「清空上下文」融入正文首句;强调不可逆 + 归档分段 + AI 不再看到历史
clearContextConfirm: '清空上下文?此操作不可逆,当前对话上下文将被归档为分段(AI 将不再看到这些历史消息)。新对话不受影响。',
// 二次确认弹窗的危险按钮文案(ConfirmDialog dangerLabel);不传则回退 common.confirm
clearContextDangerLabel: '清空',
// 折叠分隔条(连续同 status 合并一条;{n}=合并的消息条数) // 折叠分隔条(连续同 status 合并一条;{n}=合并的消息条数)
contextArchived: '已归档 {n} 条消息', contextArchived: '已归档 {n} 条消息',
compressed: '已压缩上下文', compressed: '已压缩上下文',

View File

@@ -13,10 +13,14 @@ export default {
executionFailedHint: '工具执行失败,详情见下方', executionFailedHint: '工具执行失败,详情见下方',
collapse: '收起', collapse: '收起',
expandAll: '展开全部', expandAll: '展开全部',
expand: '展开',
collapseAll: '全部收起', collapseAll: '全部收起',
written: '已写入', written: '已写入',
items: '项', items: '项',
lines: '行', lines: '行',
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断)
linesTruncated: '{shown}/{total} 行,已截断',
dirTruncated: '已截断,仅前 {n} 项',
// 工具显示名(含路径摘要前缀,fallback 兜底) // 工具显示名(含路径摘要前缀,fallback 兜底)
readPrefix: '读取', readPrefix: '读取',
@@ -41,6 +45,9 @@ export default {
taskCount: '{n} 项任务', taskCount: '{n} 项任务',
projectCount: '{n} 个项目', projectCount: '{n} 个项目',
ideaCount: '{n} 条灵感', ideaCount: '{n} 条灵感',
// UX-260618-10/12:list_trash 折叠态计数 + 分页 has_more 提示(后端返 {items,total,has_more} 对象)
trashCount: '回收站 {n} 项',
listHasMore: '(已显示 {n} 项,还有更多)',
createdWithName: '已创建:{name}', createdWithName: '已创建:{name}',
created: '已创建', created: '已创建',
updatedField: '已更新 {field}', updatedField: '已更新 {field}',
@@ -52,22 +59,38 @@ export default {
purged: '已彻底删除', purged: '已彻底删除',
purgeIneffective: '清除未生效', purgeIneffective: '清除未生效',
workflowHint: '请到工作流页面运行', workflowHint: '请到工作流页面运行',
// UX-260618-05:run_workflow tool_result 显 execution_id(workflowHint 误导——工作流已触发;旧 key 保留防他处引用)
workflowTriggered: '任务 {id} 已触发工作流(推进至 {status},执行实例 {exec})',
workflowTriggeredShort: '执行实例 {exec}',
// UX-260616-04:formatToolResult 覆盖全部工具产出人类可读摘要(补缺失 key) // UX-260616-04:formatToolResult 覆盖全部工具产出人类可读摘要(补缺失 key)
updatedTaskField: '任务 {id} 已更新字段 {field}', updatedTaskField: '任务 {id} 已更新字段 {field}',
advancedTask: '任务 {id} 已推进至 {status}', advancedTask: '任务 {id} 已推进至 {status}',
// UX-260618-09:advance_task 退回累加轮数(>0 才显)
advancedTaskWithRounds: '任务 {id} 已推进至 {status}(已退回 {n} 次)',
commandOk: '命令成功({ms} 毫秒)', commandOk: '命令成功({ms} 毫秒)',
commandFailed: '命令失败(退出码 {code}', commandFailed: '命令失败(退出码 {code}',
commandOutputLines: '{n} 行输出', commandOutputLines: '{n} 行输出',
patchedFile: '已修改 {path}(增减 {diff} 字节)', patchedFile: '已修改 {path}(增减 {diff} 字节)',
// UX-260618-06:patch_file 多匹配提示 + size_diff 单位
patchedMatches: '匹配 {n} 处',
bytesUnit: '字节',
patchedNoChange: '{path} 无更改', patchedNoChange: '{path} 无更改',
appendedTo: '已向 {path} 追加 {bytes}', appendedTo: '已向 {path} 追加 {bytes}',
renamedFile: '已重命名 {from} → {to}', renamedFile: '已重命名 {from} → {to}',
// UX-260618-12:rename_file 字节/跨卷 + write_file 覆盖/编码提示
renamedWithBytes: '已重命名 {from} → {to}({bytes})',
renamedCrossVolume: '已移动 {from} → {to}(跨卷,{bytes})',
writeOverwrite: '覆盖 {old} → {neww}',
encodingLabel: '编码:{enc}',
foundN: '找到 {n} 项', foundN: '找到 {n} 项',
boundDir: '已绑定目录「{path}」技术栈:{stack}', boundDir: '已绑定目录「{path}」技术栈:{stack}',
deleteFilePermanent: '已硬删除 {path}', deleteFilePermanent: '已硬删除 {path}',
deleteFileSoft: '已软删除 {path}(已备份)', // UX-260618-07:软删补 backup_path(回收站文件名,用户知从哪恢复)
deleteFileSoft: '已软删除 {path}(已备份,可从 {backup_path} 恢复)',
fileInfoSize: '{path} {size}', fileInfoSize: '{path} {size}',
// UX-260618-12:file_info 修改时间(epoch_ms → 客户端 toLocaleString)
fileInfoModified: ' · 修改于 {time}',
fileInfoLines: ' · {n} 行', fileInfoLines: ' · {n} 行',
fileInfoBinary: ' · 二进制', fileInfoBinary: ' · 二进制',
fileInfoDir: ' · 目录', fileInfoDir: ' · 目录',

View File

@@ -0,0 +1,46 @@
export default {
auditLog: {
title: '审批历史',
refresh: '刷新',
desc: 'AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。',
loading: '加载中…',
empty: '暂无审计记录',
col: {
time: '请求时间',
tool: '工具',
risk: '风险',
status: '状态',
decided: '决策者',
args: '参数摘要',
result: '结果摘要',
},
pager: {
prev: '上一页',
next: '下一页',
page: '第 {n} 页',
last: '(末页)',
},
risk: {
low: '低',
medium: '中',
high: '高',
},
status: {
pending: '待审批',
approved: '已批准',
rejected: '已拒绝',
executing: '执行中',
completed: '已完成',
failed: '失败',
},
decided: {
human: '人工',
auto: '自动',
},
},
}

7
src/i18n/zh-CN/error.ts Normal file
View File

@@ -0,0 +1,7 @@
export default {
error: {
title: '页面渲染异常',
desc: '当前区域因内部错误无法显示,可尝试重新加载。',
retry: '重试',
},
}

View File

@@ -51,11 +51,9 @@ export default {
fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}', fetchFailedNotFound: '端点不存在,该厂商可能不支持模型列表拉取:{msg}',
toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖', toastModelEditLocalOnly: '已启用 / 权重为本地编辑,下次拉取时覆盖',
// 模型特征标签(4 维度 + 探测来源) // 模型特征标签(模态 + 能力 + 探测来源;cost/intel 去除见 UX-260618-04)
tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' }, tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' },
tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' }, tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' },
tagCost: { free: '免费', low: '低价', medium: '中价', high: '高价' },
tagIntel: { lite: '轻量', standard: '标准', plus: '增强', ultra: '旗舰' },
tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' }, tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' },
// ===== 连接管理面板 ===== // ===== 连接管理面板 =====

View File

@@ -27,6 +27,7 @@ export default {
group: { group: {
taskCount: '{n} 个任务', taskCount: '{n} 个任务',
unknownProject: '未知项目', unknownProject: '未知项目',
empty: '暂无任务',
}, },
// 新建任务模态 // 新建任务模态

View File

@@ -5,4 +5,13 @@ import i18n from "./i18n";
import "./styles/global.css"; import "./styles/global.css";
import "./styles/ai-md.css"; import "./styles/ai-md.css";
createApp(App).use(router).use(i18n).mount("#app"); const app = createApp(App);
// 全局错误兜底:捕获 Vue runtime 未处理错误(组件渲染/生命周期/事件回调抛错),
// 仅记录不中断,防止整页白屏。组件级降级 UI 由 ErrorBoundary.vue 负责。
app.config.errorHandler = (err, instance, info) => {
const tag = instance?.$options?.name || instance?.$options?.__name || "unknown";
console.error(`[Vue error] <${tag}> ${info}:`, err);
};
app.use(router).use(i18n).mount("#app");

View File

@@ -89,6 +89,8 @@ export const state = reactive({
agentRound: 0, agentRound: 0,
// 归档分组折叠态(默认折叠) // 归档分组折叠态(默认折叠)
archivedCollapsed: true, archivedCollapsed: true,
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
foldedGroups: { today: false, yesterday: false, earlier: true } as Record<string, boolean>,
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。 // 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。 // 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
searchQuery: '', searchQuery: '',

View File

@@ -26,9 +26,13 @@ const state = reactive({
// ── 异步竞态保护(请求序号,类似 ai.ts messages 的 switch token 模式) ── // ── 异步竞态保护(请求序号,类似 ai.ts messages 的 switch token 模式) ──
// 快速连点(切换筛选/连续检索/重连)时,旧响应晚到会覆盖新响应。序号自增, // 快速连点(切换筛选/连续检索/重连)时,旧响应晚到会覆盖新响应。序号自增,
// 请求发起时捕获序号,响应回时比对:非最新请求的响应直接丢弃,只取最新。 // 请求发起时捕获序号,响应回时比对:非最新请求的响应直接丢弃,只取最新。
// items 序号覆盖 loadList/loadCandidates/search;candidates 与 items 共用同一队列 // 拆两套序号:loadList/search 都写 state.items 共用 _itemsSeq;loadCandidates 写
// (loadList/search 写 items,loadCandidates 写 candidates),故 items 序号也兜底 candidates // state.candidates 用独立 _candidatesSeq。各自防竞态,互不丢弃
let _itemsReqSeq = 0 // (P1 修复:原先三者共用 _itemsReqSeq,onMounted 并发 loadList+loadCandidates 会
// 让后发起的 loadCandidates ++ 覆盖 loadList 的 seq,导致 loadList 响应被误判为
// 旧响应丢弃,首次进入列表必空。)
let _itemsSeq = 0
let _candidatesSeq = 0
let _configReqSeq = 0 let _configReqSeq = 0
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐) // 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
@@ -59,48 +63,48 @@ export function useKnowledgeStore() {
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。 // 后端 knowledge_list 默认收窄为 published,无需前端传 status。
// 显式 status 仍透传(如 status='archived' 查归档)。 // 显式 status 仍透传(如 status='archived' 查归档)。
async function loadList(status?: string) { async function loadList(status?: string) {
const seq = ++_itemsReqSeq const seq = ++_itemsSeq
state.loading = true state.loading = true
state.error = null state.error = null
try { try {
const result = await knowledgeApi.list(status ?? null) const result = await knowledgeApi.list(status ?? null)
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
state.items = result state.items = result
} catch (e: any) { } catch (e: any) {
if (seq !== _itemsReqSeq) return // 旧请求的失败不污染当前视图 if (seq !== _itemsSeq) return // 旧请求的失败不污染当前视图
state.error = e?.toString() ?? t('knowledge.err.loadFailed') state.error = e?.toString() ?? t('knowledge.err.loadFailed')
} finally { } finally {
if (seq === _itemsReqSeq) state.loading = false if (seq === _itemsSeq) state.loading = false
} }
} }
// ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ── // ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ──
// 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。 // 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。
async function loadCandidates() { async function loadCandidates() {
const seq = ++_itemsReqSeq const seq = ++_candidatesSeq
try { try {
const result = await knowledgeApi.listCandidates() const result = await knowledgeApi.listCandidates()
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 if (seq !== _candidatesSeq) return // 旧响应丢弃,只取最新
state.candidates = result state.candidates = result
} catch (e: any) { } catch (e: any) {
if (seq !== _itemsReqSeq) return if (seq !== _candidatesSeq) return
state.error = e?.toString() ?? t('knowledge.err.loadInboxFailed') state.error = e?.toString() ?? t('knowledge.err.loadInboxFailed')
} }
} }
async function search(query: string, kind?: string) { async function search(query: string, kind?: string) {
const seq = ++_itemsReqSeq const seq = ++_itemsSeq
state.loading = true state.loading = true
state.error = null state.error = null
try { try {
const result = await knowledgeApi.search({ query, kind }) const result = await knowledgeApi.search({ query, kind })
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
state.items = result state.items = result
} catch (e: any) { } catch (e: any) {
if (seq !== _itemsReqSeq) return if (seq !== _itemsSeq) return
state.error = e?.toString() ?? t('knowledge.err.searchFailed') state.error = e?.toString() ?? t('knowledge.err.searchFailed')
} finally { } finally {
if (seq === _itemsReqSeq) state.loading = false if (seq === _itemsSeq) state.loading = false
} }
} }

View File

@@ -56,13 +56,16 @@
border-top: 0.5px solid var(--df-border); border-top: 0.5px solid var(--df-border);
margin: 8px 0; margin: 8px 0;
} }
/* B-260618-06:移除 display:block(破坏 table 布局上下文致 tr/td 塌堆叠丢列对齐,用户截图
反馈测试报告表格错乱根因)+ 移除 overflow-x:auto(display:table 下浏览器忽略无效)。
table 恢复默认 display:table 列对齐恢复;width:100%→max-width:100% 允许窄表格按内容
自适应不强制拉满。宽表格横向滚动交由各页面容器承载(AiChat 气泡 .ai-msg-bubble--ai.ai-md
加 overflow-x:auto,见 AiChat.vue)。原 UX-260617-21 display:block 方案治标破坏本体。 */
.ai-md table { .ai-md table {
width: 100%; max-width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 6px 0; margin: 6px 0;
font-size: 12px; font-size: 12px;
overflow-x: auto;
display: block;
} }
.ai-md th, .ai-md td { .ai-md th, .ai-md td {
padding: 4px 8px; padding: 4px 8px;

34
src/utils/markdown.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* 剥离 Markdown 语法字符,返回纯文本。
*
* 用于列表/卡片摘要等纯文本预览场景(详情页应 v-html 富文本渲染,不用此函数)。
* 仅去除粗体、斜体、删除线、标题、行内代码、代码块围栏、列表、引用、
* 链接、图片等 Markdown 语法标记,保留文字内容。
*
* 动机UX-260618-13列表摘要用纯文本插值显示 AI 生成内容,
* 数据源含 Markdown 语法字符会裸字符外露,列表空间小且配合截断,
* 剥离比富文本渲染更合适。
*/
/** 剥离 Markdown 语法,返回纯文本;空输入返回空串 */
export function stripMd(text: string | null | undefined): string {
if (!text) return ''
return text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
.replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开
.replace(/```/g, '') // 代码块围栏闭
.replace(/`([^`]+)`/g, '$1') // 行内代码
.replace(/^\s{0,3}>\s?/gm, '') // 引用
.replace(/^\s{0,3}[-*+]\s+/gm, '') // 无序列表
.replace(/^\s{0,3}\d+\.\s+/gm, '') // 有序列表
.replace(/^#{1,6}\s+/gm, '') // 标题
.replace(/^\s*[-=]{3,}\s*$/gm, '') // 水平线
.replace(/\*\*([^*]+)\*\*/g, '$1') // 粗体
.replace(/__([^_]+)__/g, '$1') // 粗体下划线
.replace(/\*([^*]+)\*/g, '$1') // 斜体
.replace(/_([^_]+)_/g, '$1') // 斜体下划线
.replace(/~~([^~]+)~~/g, '$1') // 删除线
.replace(/\s+/g, ' ') // 折叠连续空白
.trim()
}

View File

@@ -1,31 +1,31 @@
<template> <template>
<div class="audit-log"> <div class="audit-log">
<header class="page-header"> <header class="page-header">
<h1>审批历史</h1> <h1>{{ t('auditLog.title') }}</h1>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-ghost" @click="reload">刷新</button> <button class="btn btn-ghost" @click="reload">{{ t('auditLog.refresh') }}</button>
</div> </div>
</header> </header>
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)参数与结果仅展示摘要,完整数据留存本地数据库</p> <p class="page-desc">{{ t('auditLog.desc') }}</p>
<!-- 状态条 --> <!-- 状态条 -->
<div v-if="loading" class="empty-state">加载中</div> <div v-if="loading" class="empty-state">{{ t('auditLog.loading') }}</div>
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div> <div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div> <div v-else-if="records.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
<!-- 表格 --> <!-- 表格 -->
<div v-else class="audit-table-wrap"> <div v-else class="audit-table-wrap">
<table class="audit-table"> <table class="audit-table">
<thead> <thead>
<tr> <tr>
<th class="col-time">请求时间</th> <th class="col-time">{{ t('auditLog.col.time') }}</th>
<th class="col-tool">工具</th> <th class="col-tool">{{ t('auditLog.col.tool') }}</th>
<th class="col-risk">风险</th> <th class="col-risk">{{ t('auditLog.col.risk') }}</th>
<th class="col-status">状态</th> <th class="col-status">{{ t('auditLog.col.status') }}</th>
<th class="col-decided">决策者</th> <th class="col-decided">{{ t('auditLog.col.decided') }}</th>
<th class="col-args">参数摘要</th> <th class="col-args">{{ t('auditLog.col.args') }}</th>
<th class="col-result">结果摘要</th> <th class="col-result">{{ t('auditLog.col.result') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -54,18 +54,21 @@
<!-- 分页 --> <!-- 分页 -->
<div v-if="!loading && !errorMsg" class="pager"> <div v-if="!loading && !errorMsg" class="pager">
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button> <button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
<span class="pager-info"> {{ page }} {{ hasMore ? '' : '(末页)' }}</span> <span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}</span>
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button> <button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { formatRelativeZh, formatDate } from '@/utils/time' import { formatRelativeZh, formatDate } from '@/utils/time'
const { t } = useI18n()
// 本地 interface(不进 api/types.ts,避撞 B-42 agent) // 本地 interface(不进 api/types.ts,避撞 B-42 agent)
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应 // 字段与后端 ToolExecutionDto(audit.rs) 一一对应
interface ToolExecutionRecord { interface ToolExecutionRecord {
@@ -128,21 +131,15 @@ function nextPage() {
} }
// ── 标签 / 样式映射 ── // ── 标签 / 样式映射 ──
// 文本走 i18n(auditLog.risk/status/decided);class 映射不国际化
function riskLabel(r: string): string { function riskLabel(r: string): string {
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r return t(`auditLog.risk.${r}`)
} }
function riskClass(r: string): string { function riskClass(r: string): string {
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low' return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
} }
function statusLabel(s: string): string { function statusLabel(s: string): string {
return ({ return t(`auditLog.status.${s}`)
pending: '待审批',
approved: '已批准',
rejected: '已拒绝',
executing: '执行中',
completed: '已完成',
failed: '失败',
} as Record<string, string>)[s] ?? s
} }
function statusClass(s: string): string { function statusClass(s: string): string {
return ({ return ({
@@ -155,7 +152,7 @@ function statusClass(s: string): string {
} as Record<string, string>)[s] ?? 'status-pending' } as Record<string, string>)[s] ?? 'status-pending'
} }
function decidedLabel(d: string): string { function decidedLabel(d: string): string {
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d return t(`auditLog.decided.${d}`)
} }
function decidedClass(d: string): string { function decidedClass(d: string): string {
return d === 'human' ? 'decided-human' : 'decided-auto' return d === 'human' ? 'decided-human' : 'decided-auto'

View File

@@ -44,7 +44,7 @@
<span class="idea-title">{{ idea.title }}</span> <span class="idea-title">{{ idea.title }}</span>
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span> <span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
</div> </div>
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p> <p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p>
<div class="idea-card-footer"> <div class="idea-card-footer">
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span> <span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span> <span class="idea-date">{{ formatDate(idea.created_at) }}</span>
@@ -145,8 +145,8 @@
<!-- 标签 --> <!-- 标签 -->
<div class="detail-section"> <div class="detail-section">
<h3>{{ $t('ideas.tagsTitle') }}</h3> <h3>{{ $t('ideas.tagsTitle') }}</h3>
<div class="tag-list" v-if="parseTags(currentIdea).length > 0"> <div class="tag-list" v-if="parseTags(currentIdea.tags).length > 0">
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span> <span class="tag" v-for="tag in parseTags(currentIdea.tags)" :key="tag">{{ tag }}</span>
</div> </div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div> <div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
</div> </div>
@@ -211,7 +211,9 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { parseTags } from '../stores/knowledge'
import { formatDate } from '../utils/time' import { formatDate } from '../utils/time'
import { stripMd } from '../utils/markdown'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ConfirmDialog from '../components/ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { useRendered } from '../composables/useMarkdown' import { useRendered } from '../composables/useMarkdown'
@@ -274,7 +276,7 @@ const filteredIdeas = computed(() => {
ideas = ideas.filter(i => ideas = ideas.filter(i =>
i.title.toLowerCase().includes(query) || i.title.toLowerCase().includes(query) ||
(i.description && i.description.toLowerCase().includes(query)) || (i.description && i.description.toLowerCase().includes(query)) ||
(i.tags && JSON.parse(i.tags || '[]').some((tag: string) => tag.toLowerCase().includes(query))) parseTags(i.tags).some((tag: string) => tag.toLowerCase().includes(query))
) )
} }
@@ -296,16 +298,6 @@ const currentStatus = computed(() => {
return currentIdea.value?.status || 'draft' return currentIdea.value?.status || 'draft'
}) })
function parseTags(idea: IdeaRecord): string[] {
if (!idea.tags) return []
try {
const parsed = JSON.parse(idea.tags)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
interface ScoreDimension { name: string; score: number } interface ScoreDimension { name: string; score: number }
function parseScores(idea: IdeaRecord): ScoreDimension[] { function parseScores(idea: IdeaRecord): ScoreDimension[] {
@@ -734,6 +726,7 @@ watch(() => route.params.id, (id) => {
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); } .net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); } .net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
.summary { .summary {
font-size: 12px; font-size: 12px;

View File

@@ -68,7 +68,7 @@
<span class="kn-card-title">{{ item.title }}</span> <span class="kn-card-title">{{ item.title }}</span>
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span> <span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
</div> </div>
<div class="kn-card-desc">{{ item.content }}</div> <div class="kn-card-desc">{{ stripMd(item.content) }}</div>
<div class="kn-card-meta"> <div class="kn-card-meta">
<span class="meta-kind">{{ kindLabel(item.kind) }}</span> <span class="meta-kind">{{ kindLabel(item.kind) }}</span>
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span> <span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
@@ -238,10 +238,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge' import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
import { stripMd } from '@/utils/markdown'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types' import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
const { t } = useI18n() const { t } = useI18n()
@@ -277,6 +278,15 @@ function onSearchInput() {
}, 300) }, 300)
} }
// 组件卸载清搜索 debounce timer:防卸载后 300ms 内 timer 触发,向单例 store 写入
// 已离开视图的搜索结果/列表(污染下次进入时的列表态)。对齐 Settings.vue toast timer 清理模式。
onUnmounted(() => {
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
})
function switchTopTab(tab: 'library' | 'inbox') { function switchTopTab(tab: 'library' | 'inbox') {
topTab.value = tab topTab.value = tab
selectedId.value = null selectedId.value = null
@@ -422,7 +432,9 @@ function parseContext(e: KnowledgeEventRecord): Record<string, any> {
} }
function refConvTitle(e: KnowledgeEventRecord): string { function refConvTitle(e: KnowledgeEventRecord): string {
return parseContext(e).conv_title || parseContext(e).conv_id || '' // 解析一次复用:原两次 parseContext 调用会重复 JSON.parse,引用列表项越多浪费越大
const ctx = parseContext(e)
return ctx.conv_title || ctx.conv_id || ''
} }
function refQuery(e: KnowledgeEventRecord): string { function refQuery(e: KnowledgeEventRecord): string {
return parseContext(e).query || '' return parseContext(e).query || ''

View File

@@ -145,7 +145,7 @@
</div> </div>
</div> </div>
<p class="card-desc">{{ project.description }}</p> <p class="card-desc">{{ stripMd(project.description) }}</p>
<!-- 底部信息 --> <!-- 底部信息 -->
<div class="card-footer"> <div class="card-footer">
@@ -180,7 +180,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
@@ -188,6 +188,7 @@ import { useProjectStore } from '@/stores/project'
import { projectApi } from '@/api' import { projectApi } from '@/api'
import { formatDate } from '@/utils/time' import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project' import { parseStack } from '@/utils/project'
import { stripMd } from '@/utils/markdown'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project' import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@@ -302,6 +303,15 @@ onMounted(() => {
store.loadProjects() store.loadProjects()
}) })
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
onUnmounted(() => {
if (_toastTimer) {
clearTimeout(_toastTimer)
_toastTimer = null
}
})
// ── 导入历史项目(F-260614-06 scan 第二步) ── // ── 导入历史项目(F-260614-06 scan 第二步) ──
const showImportModal = ref(false) const showImportModal = ref(false)
const importRootPath = ref('') const importRootPath = ref('')

View File

@@ -194,6 +194,8 @@ const wfTotalNodes = ref(0)
const wfDoneCount = ref(0) const wfDoneCount = ref(0)
const wfRunningNode = ref<string>('') const wfRunningNode = ref<string>('')
const wfResult = ref<'completed' | 'failed' | null>(null) const wfResult = ref<'completed' | 'failed' | null>(null)
// SW-260618-21: 终态提示 timer 引用,卸载时清理防写已销毁 ref(对齐 Projects.vue _toastTimer 模式)
let _wfResultTimer: ReturnType<typeof setTimeout> | null = null
const wfProgressHint = computed(() => wfResult.value !== null) const wfProgressHint = computed(() => wfResult.value !== null)
const wfCompletedHint = computed(() => wfResult.value === 'completed') const wfCompletedHint = computed(() => wfResult.value === 'completed')
@@ -408,8 +410,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
wfRunningNode.value = '' wfRunningNode.value = ''
wfResult.value = 'completed' wfResult.value = 'completed'
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据) // 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
setTimeout(() => { if (_wfResultTimer) clearTimeout(_wfResultTimer)
_wfResultTimer = setTimeout(() => {
if (wfResult.value === 'completed') wfResult.value = null if (wfResult.value === 'completed') wfResult.value = null
_wfResultTimer = null
}, 3000) }, 3000)
break break
} }
@@ -419,8 +423,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
wfAdvancing.value = false wfAdvancing.value = false
wfRunningNode.value = '' wfRunningNode.value = ''
wfResult.value = 'failed' wfResult.value = 'failed'
setTimeout(() => { if (_wfResultTimer) clearTimeout(_wfResultTimer)
_wfResultTimer = setTimeout(() => {
if (wfResult.value === 'failed') wfResult.value = null if (wfResult.value === 'failed') wfResult.value = null
_wfResultTimer = null
}, 3000) }, 3000)
} }
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态 // node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
@@ -492,6 +498,8 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null } if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null } if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
}) })
</script> </script>

View File

@@ -31,7 +31,8 @@
:class="{ active: activeStatus === s.key }" :class="{ active: activeStatus === s.key }"
@click="activeStatus = s.key" @click="activeStatus = s.key"
> >
{{ s.icon }} {{ s.label }} <span class="filter-btn-icon">{{ s.icon }}</span>
<span class="filter-btn-label">{{ s.label }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -41,7 +42,7 @@
<!-- Y-2: loading/error/empty 兜底(参考 Projects.vue 空态写法) --> <!-- Y-2: loading/error/empty 兜底(参考 Projects.vue 空态写法) -->
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div> <div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div> <div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredGroups.length === 0" class="empty-state">暂无任务</div> <div v-else-if="filteredGroups.length === 0" class="empty-state">{{ $t('tasks.group.empty') }}</div>
<template v-else> <template v-else>
<section <section
v-for="group in filteredGroups" v-for="group in filteredGroups"
@@ -287,6 +288,7 @@ onMounted(async () => {
.filter-group { .filter-group {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; /* 窄屏按钮可换行,避免 nowrap 溢出 */
gap: 6px; gap: 6px;
} }
.filter-label { .filter-label {
@@ -295,6 +297,9 @@ onMounted(async () => {
white-space: nowrap; white-space: nowrap;
} }
.filter-btn { .filter-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px; padding: 4px 12px;
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
@@ -304,12 +309,22 @@ onMounted(async () => {
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.filter-btn-icon { line-height: 1; } /* 图标基线对齐,避免行高跳动 */
.filter-btn-label { white-space: nowrap; } /* 文字不内断行,换行只发生在按钮间 */
.filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); } .filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); }
.filter-btn.active { .filter-btn.active {
background: var(--df-accent); background: var(--df-accent);
color: #fff; color: #fff;
border-color: var(--df-accent); border-color: var(--df-accent);
} }
/* 窄屏(手机宽):按钮内图标与文字改纵向排布,避免横向挤压致图标/文字在空格处错位断行 */
@media (max-width: 480px) {
.filter-btn {
flex-direction: column;
gap: 2px;
padding: 4px 8px;
}
}
/* ===== 任务分组 ===== */ /* ===== 任务分组 ===== */
.task-groups { .task-groups {