优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
240
PROGRESS.md
240
PROGRESS.md
@@ -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-other,12.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.rs(parts token 预算,F-05 回归)/ anthropic_compat.rs(Image move)/ openai_compat.rs(2 处 #[allow(dead_code)])
|
||||||
|
- df-project: scan.rs(to_ascii_lowercase 索引对齐 + is_monorepo null 判定)
|
||||||
|
- df-ideas: adversarial.rs(MockProvider 补 reasoning_content)
|
||||||
|
- src-tauri: audit.rs(audit_finalize 吞错分流 + PENDING_APPROVAL_PLACEHOLDER 常量)/ commands.rs(ai_conversation_delete retain)
|
||||||
|
- df-nodes: ai_node.rs(schema required=[] 对齐 execute)
|
||||||
|
- 前端: ToolCard.vue(isFailed/cmdOutput computed)/ AiChat.vue(虚拟滚动死注释清理 + wasNearBottom 重置)/ Projects.vue(_toastTimer onUnmounted)/ Knowledge.vue(parseContext 单次)/ TaskDetail.vue(wfResult timer 清理)/ Ideas.vue(neutral 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 两个 P1(executor Ok 路径取消事件不一致 / 审批占位 tool_result 5 处 .clear() 未终态化):均行为变更需确认设计意图,记 todo 未改。
|
||||||
|
- 3. sweep P2 多为重构/防御/改进(AuditLog i18n / ai_node DRY / reqwest Client DRY / 双 watch 合并 / Anthropic url 防御),记 todo 待攒批。
|
||||||
|
- 4. 待审查 CR-260618-01(17 处)待审查会话独立 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 构建 DRY(Code-Working 会话续推)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- sweep P2 SW-260618-10:openai_compat/anthropic_compat Provider::new reqwest Client 构建(connect_timeout 30s + 回退 Client::new + warn)7 行逐字重复,抽 `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-03(SW-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-16(P3 审计精度):find_cached_high_risk_result 加 audit_repo 参数 + async + 返回 (content,status),命中旧 tool_result 后查 AiToolExecutionRepo::find_by_tool_call_id(**已存在** crud.rs:1256,复用零新增)拿缓存来源真实 status(completed/rejected/failed)透传 audit_tool_call 替代固定 "completed"。审计缺失/查询失败 fallback completed 不阻塞去重。
|
||||||
|
- SW-260618-06(P2 DRY):ToolCard 双 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.rs(find_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-07(P2 DRY):AiChat 双 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-19(combineAndTruncateLines DRY 低价值可做)/ SW-20(parseTags DRY 签名变更中复杂度)/ SW-05/11/12/13/14(防御/确认/权衡)/ SW-08(AuditLog i18n 重构级)/ SW-09(ai_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-19(P3 DRY):ToolCard combineOutputs 抽函数,combineAndTruncateLines + cmdOutput computed 复用。vue-tsc EXIT 0。
|
||||||
|
- SW-260618-20(P3 DRY):Ideas 删本地 parseTags(IdeaRecord),import stores/knowledge.ts parseTags,3 调用点传 .tags。vue-tsc EXIT 0。
|
||||||
|
- sweep 评估销账 6 项:SW-05(占位内容匹配·PENDING_APPROVAL_PLACEHOLDER 集中常量有效暂缓)/ SW-11(OpenAI 图片构造·当前不可达暂缓)/ SW-12(pinned/archived 不对称·设计合理确认)/ SW-13(find_path_conflict 规范化·文档+测试守护够暂缓)/ SW-14(keyring current_dir·权衡已承认不做)/ SW-22(Knowledge 模板重复求值·影响可忽略暂缓)。
|
||||||
|
- SW-260618-03 评估暂缓:url 模式当前不可达(前端 Phase2b 只产 base64)+ anthropic_compat 已 warn;加 commands 层校验需追 parts 全链路(IPC→ContextManager→provider)定校验点有歧义 + 误拒合法 url 风险,待 url→base64 预拉落地后变可达再加。
|
||||||
|
|
||||||
|
**代码变更**:
|
||||||
|
- src/components/ToolCard.vue(combineOutputs 抽函数 :463 + :472/:652 调用)
|
||||||
|
- src/views/Ideas.vue(parseTags import :214 + 3 调用点传 .tags)
|
||||||
|
|
||||||
|
**遗留问题**:
|
||||||
|
- sweep 可自主低风险项已全扫净(SW-19/20 已修 + 6 项评估销账 + SW-03 评估暂缓)
|
||||||
|
- 剩余候选:SW-08(AuditLog i18n 重构级 15+ key 可自主中工程)/ SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_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-08(P2 重构):AuditLog.vue 全硬编码中文 i18n 化。新建 auditLog 命名空间(en/zh-CN:title/refresh/desc/loading/empty + col×7 + pager×4 + risk×3 + status×6 + decided×2),AuditLog.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.vue(useI18n + template t() + label 函数 t())
|
||||||
|
|
||||||
|
**遗留问题**:
|
||||||
|
- sweep 可自主项已全扫净(SW-19/20 DRY + SW-08 i18n 重构均完成 + 7 项评估销账)
|
||||||
|
- 剩余候选均非纯自主:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_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 三件套 DRY(Code-Working 会话续推)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- SW-260618-09(P2 架构级 DRY):AiNode/AiSelfReviewNode execute provider 三件套逐字重复消除。抽 2 helper:resolve_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.rs(2 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-04(knowledge_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-04(P2):前端删 cost/intel 瞎猜档位标签(落地用户「去掉 cost/intel」决策)。自主裁决全删(用户决策明确 + 独立于后端 B-03 路由 + grep 确认仅 2 组件引用 + 回退易)。ProviderPanel 删 tag-cost/tag-intel span;AiChat 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 — 简述
|
||||||
|
|||||||
@@ -43,12 +43,16 @@ pub enum Capability {
|
|||||||
|
|
||||||
/// 维度 3 — 价格分级(成本)
|
/// 维度 3 — 价格分级(成本)
|
||||||
///
|
///
|
||||||
/// 设计文档 §2.2:Free/Low/Medium/High。序列化为 snake_case。
|
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/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);
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 400,warn 留痕但不阻塞(避免静默吞数据)。
|
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 Client(OpenAI/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` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
161
crates/df-execute/tests/shell.rs
Normal file
161
crates/df-execute/tests/shell.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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」。
|
||||||
///
|
///
|
||||||
/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
|
/// model:config["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_id;FR-S1 下经 secret 解析" }
|
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-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(),
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "\n\n\n\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 = "\n\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 = "  \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(".is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 提供。
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.target(line 108 push),而 in_degree 覆盖所有 node_id(line 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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 已就绪(自审闭环地基)
|
||||||
|
|||||||
@@ -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: "同意" }`**,从不等待前端审批响应。这导致:
|
||||||
|
|||||||
@@ -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 跑全场」升级为「多模型池 + 能力感知 + 智能路由」。
|
||||||
|
|||||||
@@ -7,6 +7,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> ## ⚠️ 实施状态(2026-06-18 核对:阶段 1-2 已落地,数据模型形态偏离设计)
|
||||||
|
>
|
||||||
|
> **阶段 1(数据模型)+ 阶段 2(provider 适配)已落地**,阶段 3(前端)+ 阶段 5(F-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-03),vision 路由仍可按 `TaskRequirements.modalities=[Vision]` + `has_image()` 筛选候选模型,设计方向不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 摘要
|
## 0. 摘要
|
||||||
|
|
||||||
将 `ChatMessage.content: String` 升级为 `Vec<ContentPart>{Text/Image}`,打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路,并在 provider 转换层对非 vision 模型做文本降级。Phase 1(F-01 `ModelCapability`)落地后,由 `ModelRouter` 按 `has_image` 自动路由到带 vision 能力的模型;F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06:`scan.rs::ImageRef` 现仅采集 alt+src,Phase 2 后可由 commands 层读 base64 喂 vision 抽 description。
|
将 `ChatMessage.content: String` 升级为 `Vec<ContentPart>{Text/Image}`,打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路,并在 provider 转换层对非 vision 模型做文本降级。Phase 1(F-01 `ModelCapability`)落地后,由 `ModelRouter` 按 `has_image` 自动路由到带 vision 能力的模型;F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06:`scan.rs::ImageRef` 现仅采集 alt+src,Phase 2 后可由 commands 层读 base64 喂 vision 抽 description。
|
||||||
|
|||||||
@@ -7,6 +7,26 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> ## ✅ 实施状态(2026-06-18 核对:方案 B 全量落地)
|
||||||
|
>
|
||||||
|
> **推荐方案 B(df-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_key(base_url/api_key 标注「已废弃过渡」),`:351` required=[](SW-260618-15:prompt/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)
|
||||||
|
|||||||
@@ -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 序列化通信。两侧的类型定义必须保持一致。
|
||||||
|
|||||||
@@ -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 占位保 scrollHeight,IO 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 → 后续上移重叠);多次修 fallback(8abcd56)+ 禁用裁剪(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 条件)
|
||||||
|
|
||||||
## 应用启动 / 数据库配置
|
## 应用启动 / 数据库配置
|
||||||
|
|
||||||
|
|||||||
BIN
docs/07-项目管理/clipboard_20260618_124020.png
Normal file
BIN
docs/07-项目管理/clipboard_20260618_124020.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 KiB |
254
docs/07-项目管理/todo归档/2026-06-18.md
Normal file
254
docs/07-项目管理/todo归档/2026-06-18.md
Normal 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 等 key);hover 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 key(zh/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流式核心(块级 memo:splitBlocks 代码围栏整体一块/非代码双换行切 → 前块缓存命中 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 失败时补偿删除已建 project;Repository 不支持跨 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_descendants,1 行,签名兼容)
|
||||||
|
|
||||||
|
- [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` handler(tool_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 三文档巡检
|
||||||
1080
docs/07-项目管理/todo归档/2026-06.md
Normal file
1080
docs/07-项目管理/todo归档/2026-06.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`。
|
||||||
|
|||||||
1062
docs/07-项目管理/待审查归档/2026-06.md
Normal file
1062
docs/07-项目管理/待审查归档/2026-06.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/clipboard_20260618_125114.png
Normal file
BIN
docs/clipboard_20260618_125114.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
1298
docs/todo.md
1298
docs/todo.md
File diff suppressed because it is too large
Load Diff
86
docs/待决策.md
86
docs/待决策.md
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已决归档
|
## 已决归档
|
||||||
|
|||||||
1436
docs/待审查.md
1436
docs/待审查.md
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -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_id(provider 每轮新 id)
|
/// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_id(provider 每轮新 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(),
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(路径 B):write_file 的行级 unified diff(旧文件 vs 新内容)。
|
/// AE-2025-03(路径 B):write_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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", ());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
15
src/App.vue
15
src/App.vue
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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: currentText → onContentChange 合并到下方 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 小字 + 上下留白),不抢正常消息视觉焦点。 */
|
||||||
|
|||||||
75
src/components/ErrorBoundary.vue
Normal file
75
src/components/ErrorBoundary.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 跟随(吸附时调用) */
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
46
src/i18n/en/auditLog.ts
Normal 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
7
src/i18n/en/error.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '已压缩上下文',
|
||||||
|
|||||||
@@ -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: ' · 目录',
|
||||||
|
|||||||
46
src/i18n/zh-CN/auditLog.ts
Normal file
46
src/i18n/zh-CN/auditLog.ts
Normal 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
7
src/i18n/zh-CN/error.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
error: {
|
||||||
|
title: '页面渲染异常',
|
||||||
|
desc: '当前区域因内部错误无法显示,可尝试重新加载。',
|
||||||
|
retry: '重试',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: '默认' },
|
||||||
|
|
||||||
// ===== 连接管理面板 =====
|
// ===== 连接管理面板 =====
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default {
|
|||||||
group: {
|
group: {
|
||||||
taskCount: '{n} 个任务',
|
taskCount: '{n} 个任务',
|
||||||
unknownProject: '未知项目',
|
unknownProject: '未知项目',
|
||||||
|
empty: '暂无任务',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 新建任务模态
|
// 新建任务模态
|
||||||
|
|||||||
11
src/main.ts
11
src/main.ts
@@ -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");
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
34
src/utils/markdown.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user