重构: 文档汇总+进度看板+孤儿任务清理脚本+gitignore 噪音排除
- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
This commit is contained in:
150
docs/05-代码审查/全局代码review-2026-06-15.md
Normal file
150
docs/05-代码审查/全局代码review-2026-06-15.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 全局代码 Review(2026-06-15)
|
||||
|
||||
> 来源:7 维度 general-purpose agent 并行深入扫(workflow `wnrzlw8ub`,DRY / 架构 / 潜在bug / 简洁性 / 安全 / AI可靠 / 工作流引擎)。汇总 agent stall(处理 7 维度大 JSON + 输出大 schema 卡死 6 次重试),主代理从 workflow journal 提取 7 维度 result 接手汇总。
|
||||
> 原则:全局最优、不过度设计、务实修复(最小改动根治)、聚焦真问题(不报 style/吹毛求疵)。
|
||||
> 去重源:本轮已修项排除(CR-01 keyring 清理 = security#1、CR-02 AiChat confirm = DRY#3),embed timeout 在 AI可靠 与 架构 两维度重复(合并)。
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
- 原始 38 findings → 去重/排已修后 **~30**
|
||||
- **P1 可执行 6**(明确小修,立即推进)
|
||||
- **P1 需设计 3**(跨层/A-B/行为变更,进设计文档)
|
||||
- **P2 可执行 ~13**(清债/死代码批)
|
||||
- **P2 需设计 ~10**(进 todo)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P1 — 可执行(立即推进)
|
||||
|
||||
| # | 维度 | 问题 | 位置 | 修复方向 |
|
||||
|---|---|---|---|---|
|
||||
| R-P1-1 | AI可靠 | **Anthropic 流式 error 事件误判正常完成**:`apply_anthropic_event` 的 `type=="error"` 分支返 `finished:true`,stream_llm 视为正常完成 → 残缺响应持久化、无 AiError、用户以为答完实际中途出错(OpenAI 路径走 Err,行为不一致) | `anthropic_compat.rs:201-205` + `stream_recv.rs:142-145` | error 事件走 Err 路径(StreamChunk 加 `error: Option<String>` 字段,stream_llm 识别后走 AiError + return None 丢弃残缺) |
|
||||
| R-P1-2 | bug | **shell::execute 超时未 kill 子进程**:`timeout` 包裹 `cmd.output()`,超时返 Err 但子进程未 kill(tokio Child 默认无 kill_on_drop)→ 僵尸/孤儿累积 + fd 泄漏 | `crates/df-execute/src/shell.rs:55-63` | 改 `spawn()` 拿 Child + `kill_on_drop(true)` 兜底,或超时分支显式 `child.kill().await` + wait 回收 |
|
||||
| R-P1-3 | workflow | **审批 Ok 后取消 TOCTOU**:HumanNode Ok 返回后 join_all 让出执行权窗口内前端 cancel → set_cancelled 改 Cancelled → 随后 set_completed 命中 is_legal 拒绝(Cancelled→Completed) → bail,已批准审批报失败(与 B-03b-R1 Err 分支防护对称的另一半) | `crates/df-workflow/src/executor.rs:126` | set_completed 前加 `if !is_cancelled(node_id)` 短路(与 139 行 Err 分支对称) |
|
||||
| R-P1-4 | DRY | **build_provider+resolve+ensure_resolved_key 7 处复制,6 处漏空 key 早失败校验**:只有 agentic.rs 含 ensure_resolved_key,title/knowledge_inject×2/project/ai_node×2 跳过 → keyring 读不到时空 key 照发请求致 401,用户看「key 已保存」反复重试无解 | 7 调用点:`agentic.rs:48-58`/`title.rs:60-65`/`knowledge_inject.rs:33,320`/`project.rs:378`/`ai_node.rs:118,309` | `secret.rs` 加工厂 `build_provider_for(record) -> Result<Box<dyn LlmProvider>, String>`(resolve→ensure_resolved→build),7 处替换 |
|
||||
| R-P1-5 | workflow | **update_task status 无值校验**:白名单只校验列名不校验值,任意字符串落库,TaskStatus 枚举形同虚设 → 拼写错误(in-progess/in progress)静默落库,按枚举查询全漏,任务「消失」(todo T-260614-03 旁路未根治) | `src-tauri/src/commands/task.rs:74-85` | field=="status" 时校验 value ∈ TaskStatus variants(types.rs 补 is_valid 反查),非法返 Err |
|
||||
| R-P1-6 | workflow | **HumanNode 无效 decision 直接 Err 无重试**:decision ∉ options 或空 → return Err → 节点 Failed → 整层中止,用户一次手误(多尾空格/'y')杀死工作流,无法纠错 | `crates/df-nodes/src/human_node.rs:67-73` | return Err 改 continue(warn 日志),select! 续等下一条合法 Response,超时兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P1 — 需设计(进设计文档/todo)
|
||||
|
||||
### R-PD-1 编辑 provider 不改密钥默默清 DB 明文,密钥永久丢失(security P1)
|
||||
- 位置:`commands.rs:329-355`(ai_save_provider 空密钥分支)+ `crud.rs:896`(INSERT OR REPLACE 全字段覆盖)
|
||||
- 问题:api_key 空=编辑不改约定下,无条件把 record.api_key 置空走 INSERT OR REPLACE。**未迁移态** provider(迁移失败 DB 仍有明文 keyring 空)用户仅改 name/base_url,空 api_key 把 DB 明文覆盖成空 → keyring 一直空 → resolve 返空 → provider 报废,密钥静默丢失。
|
||||
- 修复方向:空 api_key 时先确认 keyring 有/DB 有再决定清 DB——若 keyring 无且原 DB 非空,先 set_provider_secret 补迁再清 DB(即时迁移),保住密钥不丢。
|
||||
- 进:todo 新增条目 + 功能决策记录(密钥迁移健壮性取舍)
|
||||
|
||||
### R-PD-2 run_workflow IPC 经 ScriptNode 执行前端任意 shell(security P1)
|
||||
- 位置:`workflow.rs:36-44`(run_workflow 接 DagDef)+ `script_node.rs:34-42`(config.command 直传)+ `shell.rs:38/42`(cmd /C \| sh -c)
|
||||
- 问题:前端可构造任意 DagDef 提交,ScriptNode 从 config.command 取原始串交 shell 解释器,**无白名单/无工作目录锚定/无审批**(workflow 系统独立于 AI 工具 RiskLevel 审批链)。前端可 `del /S` 或 curl 外发。
|
||||
- 修复方向(三选一):① state.rs build_registry 不注册 'script' 掐断(最安全最小);② 限定工作目录在已绑定项目 path 内 + 高危命令前缀走 HumanNode 审批;③ ScriptNode 命令白名单(npm/git/mvn 前缀 + 参数过滤)。
|
||||
- 进:todo 新增 + 安全设计文档(工作流脚本执行边界)
|
||||
|
||||
### R-PD-3 DAG 条件边从不求值,条件分支整体失效(workflow P1)
|
||||
- 位置:`executor.rs:62-97`(adjacency_in 构建 + inputs 收集不区分 condition)+ `conditions.rs:16`(ConditionEngine 从未被 executor 调用)
|
||||
- 问题:边有 `condition: Option<String>`,build_dag 写入 runtime Dag,但 executor 无条件把每条边前驱输出灌入 target,topological_layers 把条件边计入入度。`add_edge_with_condition("a","c","false")` 实际 c 永远执行。**条件分支这一 DAG 核心能力整体失效,且对用户静默**。
|
||||
- 修复方向:executor inputs 收集处用 ConditionEngine.evaluate(edge.condition, &pred_output) 过滤;topological_layers 前过滤无效边或执行时按条件短路 target 为 Skipped。起步先打通数据流过滤,调度短路二期。
|
||||
- 与 todo `T-260614-11 条件表达式引擎升级`同一根因(conditions 仅 true/false 字面量 + 从未接线)。进:任务推进构想 / 条件引擎设计文档
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 可执行(清债/死代码批)
|
||||
|
||||
| # | 维度 | 问题 | 位置 | 修复 |
|
||||
|---|---|---|---|---|
|
||||
| R-P2-1 | AI可靠 | MAX_AGENT_ITERATIONS(=10) 达上限静默截断,末轮 tool_result 不回传 LLM 且无提示 | `agentic.rs:81-217` | 因达上限退出时 emit AiError/warn「达到最大轮次,可能未完成」 |
|
||||
| R-P2-2 | bug | write_file 每次覆盖非空文件生成 .bak 不清理(污染 git/构建/list_directory AI 上下文) | `tool_registry.rs:471-476` | rename 成功后 remove .bak(保留 rename 失败分支不删兜底) |
|
||||
| R-P2-3 | bug | build_dag 不校验边 source/target 存在,野边静默吞(节点空输入跑错无报错) | `registry.rs:52-61` | add_edge 前校验两端存在,bail 早返回 |
|
||||
| R-P2-4 | bug | TokenAccumulator.add 用 u32 += 无饱和,恶意/异常 provider 返巨大值溢出回绕打乱 budget | `conversation.rs:26-29` | saturating_add |
|
||||
| R-P2-5 | DRY | now_millis 时间工具两层重复(commands::now_millis + crud::now_millis_str) | `commands/mod.rs:15` + `crud.rs:357` | df-core 提供 pub fn,两处 use |
|
||||
| R-P2-6 | 架构 | extract_error_diag 字节窗口扫描 UTF-8 边界脆弱(依赖中文恰好 3 字节巧合) | `stream_recv.rs:28-40` | 改白名单码 raw.contains(code_str),去滑窗 |
|
||||
| R-P2-7 | DRY | **client builder 三级降级逐字复制 + 中间级重建无意义**(第一级 connect_timeout(30s) 失败→第二级同配置重建必同因失败→只有第三级 Client::new() 不同)。⚠️ 本轮 B3 刚加的「中间级重建」审查质疑冗余 | `anthropic_compat.rs:235-247` + `openai_compat.rs:232-244` | 抽 `build_llm_client()` 到 df-ai/http.rs + 简化为两级(删中间同配置重建级) |
|
||||
| R-P2-8 | 简洁 | df-ai::router 整模块死代码(route() 6 分支全返 default_model,零外部引用) | `router.rs:8-51` | 删文件 + lib.rs 删 mod |
|
||||
| R-P2-9 | 简洁 | df-ai::stream::StreamCollector 整模块死代码(与 TokenAccumulator 重叠未用) | `stream.rs:6-45` | 删文件 + lib.rs 删 mod |
|
||||
| R-P2-10 | 简洁 | Dag::predecessors/successors 死方法(executor 已自建 adjacency) | `dag.rs:65-80` | 删两方法 |
|
||||
| R-P2-11 | 简洁 | NodeRegistry::is_registered/registered_types 死方法 | `registry.rs:66-74` | 删两方法 |
|
||||
| R-P2-12 | 简洁 | DagDef::from_dag_edges 死方法(注释自承无法还原节点配置) | `dag_def.rs:77-97` | 删方法 |
|
||||
| R-P2-13 | 简洁 | set_waiting/set_skipped 死代码(全仓零调用,误导状态机认知) | `state.rs:87-111` | 删两方法(set_cancelled 保留+注释「唯一受控旁路」) |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 需设计(进 todo)
|
||||
|
||||
- **R-PD-4** keyring 迁移失败明文密钥长期滞留 SQLite 文件(无加密)— `secret.rs:50-72`:补 N 次失败阈值警告(不改兼容时序)
|
||||
- **R-PD-5** approve_human_approval IPC 不校验 decision ∈ options(放行非法值,依赖下游 HumanNode 兜底,IPC 成功+工作流失败割裂)— `workflow.rs:179-210`
|
||||
- **R-PD-6** AiSession 单例:try_continue 读 active_conversation_id 竞态(靠 switch readonly 间接保护,脆弱耦合)— `agentic.rs:255-291`:从 pending_approvals 取 conversation_id 解耦
|
||||
- **R-PD-7** LlmProvider trait 抽象缺口:name() 语义错位(OpenAI 返模型名/Anthropic 返固定串)+ supported_features/ProviderFeatures 死代码(两 provider 实现但零读取)— `provider.rs:155-160,217`:补 endpoint() 默认方法 + 删 supported_features
|
||||
- **R-PD-8** AiProviderRecord 整条穿透 IPC 边界(DB schema 演进直接破坏前端契约,models 字段 provider 返串/conversation 返数组不一致)— `commands.rs:282-295`:定义 ProviderDto/ConversationSummary 映射层
|
||||
- **R-PD-9** 命令层承担业务逻辑(agentic loop/tool_registry 717 行/audit reason 映射堆 commands/ai)— `agentic.rs`+`tool_registry.rs`+`audit.rs`:最小起步把 audit 工具名→文案映射作 display_hint 注册进 AiToolRegistry(消除双份);agentic loop 下沉 df-ai 较大进 todo
|
||||
- **R-PD-10** .map_err(\|e\| e.to_string()) 10 文件 85 处复制(强类型 Error 拍平成自由文本,分类信息丢弃)— 全 commands/:加 err_str helper 统一日志点(是否带分类前缀需设计)
|
||||
- **R-PD-11** 目录防重复绑定逻辑两处重复(find_binding_conflict vs bind_dir_to_project)— `project.rs:249-260` + `tool_registry.rs:92-102`:抽公共 find_path_conflict
|
||||
- **R-PD-12** run_workflow AI 工具是 no-op 桩但 prompt/audit/ToolCard 当真实能力宣传(LLM 调用走审批拿空结果,体验断裂)— `tool_registry.rs:383-390`+`prompt.rs:57`+`audit.rs:120-123`+`ToolCard.vue:367`:删假能力 or 真接线(与 R-PD-2 协同)
|
||||
- **R-PD-13** run_workflow 转发任务 Lagged 静默丢前端事件无补偿(256 容量击穿时关键终态事件永久丢)— `workflow.rs:70-97`:终态事件兜底重发 or Lagged 时从 DB 重读补发
|
||||
- **R-PD-14** df-ideas::promotion IdeaPromoter/PromotionPolicy/try_promote 死代码且 do_promote 是空壳 TODO(误接入得虚假成功)— `promotion.rs:21-92`:删死码保留 PromotionResult(确认归属后)
|
||||
|
||||
---
|
||||
|
||||
## 架构洞察
|
||||
|
||||
1. **df-ai 多处重构残留死代码**:router(route 全返 default_model)/stream(与 TokenAccumulator 重叠)/supported_features(零读取)——「为不存在需求预建的抽象」,零引用可删。
|
||||
2. **LlmProvider trait 抽象缺口**:name() 语义错位 + 无 base_url/endpoint 访问器 → 诊断(stream_recv fmt_diag)只能用 name() 近似 provider_type,401 排查难定位是 key/url/model 哪个配置错(与本轮 B-260615-01 stream_recv 诊断约束呼应)。
|
||||
3. **命令层臃肿**:agentic loop(ReAct 编排)/tool_registry(项目CRUD+文件+安全校验 717 行闭包)/audit(工具名→文案映射)堆在 src-tauri/commands/ai,无法被 df-nodes/AiNode 复用(AiNode 自己重写 LLM 调用)。最小起步:audit 映射下沉 registry(display_hint)。
|
||||
4. **df-workflow ConditionEngine 从未接线**:DAG 条件分支整体失效(R-PD-3),与 todo `T-260614-11 条件表达式引擎升级`同根因——conditions 仅 true/false 字面量 + executor 不调用。需统一在条件引擎设计文档收敛。
|
||||
5. **工作流 ScriptNode 任意 shell(R-PD-2)**:独立于 AI 工具审批链的安全缺口,前端 IPC 直接触发,需工作流脚本执行边界设计。
|
||||
|
||||
---
|
||||
|
||||
## 推进编排
|
||||
|
||||
1. **P1 可执行 6 项**(R-P1-1~6)→ workflow 并行推进(文件域隔离 + 实现/审查 pipeline)
|
||||
2. **P1 需设计 3 项**(R-PD-1/2/3)→ 进设计文档(密钥迁移健壮性 / 工作流脚本执行边界 / 条件引擎)+ todo
|
||||
3. **P2 可执行清债批**(R-P2-1~13)→ 顺带推进(死代码删除 + 小修,零风险减法)
|
||||
4. **P2 需设计**(R-PD-4~14)→ 补 todo
|
||||
|
||||
---
|
||||
|
||||
## 推进状态(2026-06-15)
|
||||
|
||||
**批1 核查闭环**(主代理独立 cargo check 全量 + 三 crate test 全绿):
|
||||
- ✅ R-P1-2 shell kill_on_drop(`df-execute/shell.rs`:kill_on_drop(true)+spawn+wait_with_output,timeout drop child 触发 kill)
|
||||
- ✅ R-P1-3 审批 Ok TOCTOU 短路(`df-workflow/executor.rs:126`:Ok 分支加 is_cancelled 短路,新测 `test_cancelled_node_skips_set_completed`,15 passed)
|
||||
- ✅ R-P1-5 update_task status 校验(`df-core/types.rs` is_valid+valid_values+7 单测;`task.rs` status 值校验 Err 含合法值清单,df-core 7 passed)
|
||||
- ✅ R-P1-6 HumanNode 无效 decision continue(`df-nodes/human_node.rs`:Err→continue+warn,2 测改 `_ignored_then_timeout`,15 passed)
|
||||
|
||||
**批2 核查闭环**(主代理独立核查 git diff + cargo check 全量 exit 0 + df-ai 10 passed):
|
||||
- ✅ R-P1-1 Anthropic 流式 error 误判完成(`provider.rs` StreamChunk 加 `error: Option<String>` 字段 `#[serde(skip)]`;`anthropic_compat.rs:205` error 分支 finished→false+error=Some(全分支补 error:None 初始化);`stream_recv.rs:142` chunk.error 识别 emit AiError(MidStream)+return None,与 Err 分支对称;新单测 2 个,df-ai **10 passed**)
|
||||
- ✅ R-P1-4 build_provider DRY(`secret.rs` build_provider_for 工厂已落(resolve→ensure_resolved→build),5 调用点全用;`ai_node.rs:50-53` parse_params 内联空 key 校验(df-nodes 架构边界不依赖 src-tauri,语义对齐 ensure_resolved_key);裸 build_provider 残留 = lib.rs 工厂定义 + ai_node 生产(前面 parse_params 已校验) + ai_node 测试,合理)
|
||||
|
||||
**P1 可执行 6 项全闭环**(批1 4 + 批2 2)。
|
||||
|
||||
---
|
||||
|
||||
## 推进状态(2026-06-15)— P2 清债批
|
||||
|
||||
**批3 核查闭环**(主代理独立核查 git diff + cargo workspace 全量 exit 0 + 5 crate test 共 126 passed):
|
||||
- ✅ 批3-A df-ai 域:R-P2-7 client builder 三级→两级(删中间同配置重建级,两 provider 对称)+ R-P2-8 router.rs 删 + R-P2-9 stream.rs 删(lib.rs 删 2 mod);决策不抽 build_llm_client(过度设计),df-ai **31 passed**
|
||||
- ✅ 批3-B df-workflow 域:R-P2-3 build_dag 边校验(node_ids HashSet O(1) + bail 野边)+ R-P2-10 dag 删 predecessors/successors + R-P2-11 registry 删 is_registered/registered_types + R-P2-12 dag_def 删 from_dag_edges(同步删 use Dag)+ R-P2-13 state 删 set_waiting/set_skipped(set_cancelled 保留扩注释),df-workflow **15 passed**
|
||||
- ✅ 批3-C df-core+DRY 域:R-P2-4 TokenAccumulator saturating_add 3 处(add prompt/completion + total)+ R-P2-5 now_millis 统一 df-core(types.rs pub fn + lib.rs re-export + commands/mod.rs/df-storage crud.rs 转发保留函数名,37 调用点零破坏);review crate 归属错(df-core/conversation.rs 实为 src-tauri/commands/ai/、src-tauri/crud.rs 实为 df-storage/)已按真实位置修正,df-core **7 passed**
|
||||
- ✅ 批3-D src-tauri 域:R-P2-1 agentic converged 互斥(达 MAX 且 has_tool_calls 仍 true 时 emit AiError+warn 前置警示「末轮工具结果未回传」,仍入库 Completed 保留内容,与 break 收敛分支互斥不重复 emit)+ R-P2-2 write_file .bak 清理(rename 成功后 old_size>0 时 remove,忽略清理失败)+ R-P2-6 extract_error_diag 重写(`&bytes[i..i+3]` 字节切片→`chars().collect()`+char 迭代+HTTP_CODES 白名单,修多字节 UTF-8 panic 风险 + 长数字串内嵌误命中),devflow **58 passed**
|
||||
|
||||
**P2 可执行 13 项全闭环**。
|
||||
|
||||
---
|
||||
|
||||
## 推进状态(2026-06-15)— P1 需设计 3 项设计文档
|
||||
|
||||
**3 设计文档已起草**(general-purpose agent 读码 + 细化方案,待用户核对):
|
||||
|
||||
- 📄 **R-PD-1 密钥迁移健壮性** → [密钥迁移健壮性-2026-06-15.md](../02-架构设计/密钥迁移健壮性-2026-06-15.md):推荐方案 A「编辑路径即时迁移」——DB 有明文+keyring 无时先 `set_provider_secret` 补密钥,成功后清 DB 明文,失败 Err 阻断 DB 不动(最小局部改动 commands.rs 一处,放弃 B 反方向维持未迁移态 / C 显式标志位 schema 演进成本)
|
||||
- 📄 **R-PD-2 工作流脚本执行边界** → [工作流脚本执行边界-2026-06-15.md](../02-架构设计/工作流脚本执行边界-2026-06-15.md):推荐方案① 删 `src-tauri/src/state.rs:227-229` script 注册掐断入口 + 下线 demoDag(DevFlow workflow 纯演示,唯一构造点 demoDag 三步 echo,一行删换零攻击面,黑名单/参数过滤是不完备运行时博弈),未来真要脚本能力新建独立 BuildNode。**路径修正**:build_registry 实在 `src-tauri/src/state.rs:225`(review 原文 `state.rs` 无 crate 前缀易歧义——`crates/df-workflow/src/state.rs` 是 StateMachine 状态机与节点注册无关)。**R-PD-2 必须先于 R-PD-12**(否则 LLM 暗道绕过 RiskLevel 审批链)
|
||||
- 📄 **R-PD-3 条件表达式引擎** → [条件表达式引擎-2026-06-15.md](../02-架构设计/条件表达式引擎-2026-06-15.md):推荐分阶段 Phase1 数据流过滤(executor inputs 收集处调 ConditionEngine + adjacency_in 携 condition,零终态冲突独立可 ship)/ Phase2 调度短路(层调度前求值条件全 false 则 target 不执行,复活 `set_skipped` 旁路与 `set_cancelled` 同型,注释区分语义)。**待用户确认 3 决策点**:A 引擎实现(手写最小求值器 vs evalexpr 库)/ B 终态机制(set_skipped 复活 vs 其他)/ C 求值失败兜底(默认 true vs false)
|
||||
|
||||
**与 R-P2-13 的张力**:R-P2-13 删了 set_waiting/set_skipped(当时全仓零调用,扩写 set_cancelled 注释「唯一受控旁路」);R-PD-3 Phase2 接线条件引擎后 set_skipped 有了真实消费者需复活——set_cancelled「唯一旁路」注释届时需同步改。
|
||||
|
||||
**review 路径归属勘误汇总**(本轮 3 处,已分别在对应设计文档/实现修正):
|
||||
- R-P2-4 `df-core/conversation.rs` → 实 `src-tauri/src/commands/ai/conversation.rs`(TokenAccumulator)
|
||||
- R-P2-5 `src-tauri/src/crud.rs` → 实 `crates/df-storage/src/crud.rs`(now_millis_str)
|
||||
- R-PD-2 `state.rs` build_registry → 实 `src-tauri/src/state.rs:225`(非 `crates/df-workflow/src/state.rs`)
|
||||
359
docs/05-代码审查/全栈代码审查报告-2026-06-14.md
Normal file
359
docs/05-代码审查/全栈代码审查报告-2026-06-14.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 全栈代码审查报告(2026-06-14)
|
||||
|
||||
> 范围:Rust 9 crate + Tauri 命令层 + Vue 3 前端(stores/composables/views/components/api),约 25k 行。
|
||||
> 方法:5 个 general-purpose 子代理按模块并行审查(df-nodes/workflow/execute、df-ai/ideas/storage、src-tauri commands、stores/composables、views/components/api),主代理对高严重度项逐条读码核实。
|
||||
> 互斥关系:与 [aichat审查报告-2026-06-14.md](../02-架构设计/aichat审查报告-2026-06-14.md)(AI Chat 专项)、[工作流审批审查报告-2026-06-14.md](../02-架构设计/工作流审批审查报告-2026-06-14.md)(工作流审批专项)去重;本轮不重复两份已记录的内容,仅记新发现与核查澄清。
|
||||
> 增补:2026-06-14 追加 §0.1 修复进度表 + 在 FR-D1/FR-P1/FR-P2/FR-R4/FR-R5 章节内联标注已修复状态(对照 commit 36d68dd / 4a95f6a / 4b5f096)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 核查澄清(审查代理误判,不记为 bug)
|
||||
|
||||
### ✅ 已澄清:`df-ideas/promotion.rs:80-91` `do_promote` 非"伪造成功"
|
||||
|
||||
子代理报告称 `do_promote` 返回 `promoted:true` 却未建项目,是"成功撒谎"高严重度 bug。**核查后判定为误判**:
|
||||
|
||||
- IPC 层 `src-tauri/src/commands/idea.rs:112` `promote_idea` **真正调用** `df_project::manager::ProjectManager::create_from_idea` 构造项目实体 + 映射 `ProjectRecord` + `update_full` 回写想法。前端走此 IPC,不走 crate。
|
||||
- `df-ideas/src/promotion.rs` 的 `do_promote`(Auto 策略分支)**无任何调用方**,是 [功能决策记录 §想法立项](../02-架构设计/功能决策记录-2026-06-14.md) 明确的"crate 留纯决策 TODO"——crate 不依赖 df-storage/df-project 以避循环依赖,副作用放 IPC 组合。
|
||||
- [todo.md:145](../todo.md) 已标注 `do_promote crate 层 TODO(现走前端闭环)`。
|
||||
|
||||
**保留的低优提示**:`do_promote` 返回值 `promoted:true` 对一个未接 df-project 的函数是误导性签名,若将来有人直接调用会产生真实的"成功撒谎"。建议接入前把返回改 `promoted:false` + `reason: "crate 层未接入"`,或加 `#[allow(dead_code)]` + 注释固化"无副作用占位"语义。**不计为新 bug,仅留改进提示。**
|
||||
|
||||
## 0.1 修复进度(2026-06-14 增补)
|
||||
|
||||
> 后续 commit 已落地的修复项汇总。下表 ID 沿用 commit message 编号(与本报告 §2-§6 的 FR-S/C/R/P/D 编号体系不完全一致,已括注对应章节)。报告内已有条目的,已在对应章节标题追加 ✅ 标记并补「已完成」说明;以下为**报告原未单列、commit 已落地**的项。
|
||||
|
||||
| commit ID | 问题 | 位置 | 状态 |
|
||||
|---|------|------|------|
|
||||
| 36d68dd FR-D6 | 补 `delete_task`/`update_task` 工具(防误用 `delete_project` 清理孤儿任务)| `tool_registry.rs` | ✅ 已完成(原缺任务级删改工具,迫使 LLM 误调项目级删除)|
|
||||
| 36d68dd FR-D7 | 抽 `bind_dir_to_project` 消除 `create_project`/`bind_directory` 重复(DRY)| `tool_registry.rs` | ✅ 已完成 |
|
||||
| 36d68dd FR-D8 | `create_idea` schema 补 `priority` 字段(前后端契约对齐)| `tool_registry.rs` | ✅ 已完成 |
|
||||
| 4a95f6a FR-D4 | `replace_tool_result_content` 正向 O(n) 改反向 `rposition`(审批替换命中最近)| `crates/df-ai/src/context.rs` | ✅ 已完成(本报告未单列此 crate 层方法;低频审批路径,不引入索引)|
|
||||
|
||||
> 本报告 §6 FR-D4 指向的是 `migrations.rs` 的 13 个 if 链 DRY(与此处 commit 内 FR-D4 不同名同号),两者无关,勿混淆。报告 FR-D1(confirmDialog 重复)即 commit 内 FR-D5,已在 §6 内联标注。
|
||||
|
||||
---
|
||||
|
||||
## 1. 与已有文档去重
|
||||
|
||||
以下子代理报告的项**已在现有文档/todo 记录**,本报告不重复,仅交叉指引:
|
||||
|
||||
| 子代理发现 | 已记录位置 | 状态 |
|
||||
|---|---|---|
|
||||
| 条件引擎空壳(`conditions.rs` 仅认 true/false 字面量) | todo.md:30 (B-260614-02 修默认值) + todo.md:112 (T-260614-11 表达式升级待做) | 已记录 |
|
||||
| 路径穿越 / symlink 逃逸 | todo.md:102 (T-260614-04 已完成 canonicalize) | 已修(注:canonicalize 防逃逸,但 **未防 read_file TOCTOU**,见 FR-S2) |
|
||||
| localStorage 跨窗口传 currentText | todo.md:98 (B-260614-05,Sprint 19 有意保留) | 已记录 |
|
||||
| ai_key 明文存 SQLite | 功能决策记录-归档-2026-06-14.md(旧记录,无根治决策) | **未根治,本轮重申见 FR-S1** |
|
||||
| Settings.vue 1042 行 god file | todo.md:88 (T-260614-06 待立项) | 已记录 |
|
||||
| confirmDialog 4 处重复 | 隐含在 View改造指南,无显式 todo | 记为 FR-D1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 安全(最高优先级)
|
||||
|
||||
### 🔴 FR-S1 — api_key 明文存储 + 经 IPC 明文传前端 [已核实]
|
||||
|
||||
**位置**:
|
||||
- `crates/df-storage/src/migrations.rs:445-457`(`ai_providers.api_key TEXT NOT NULL` 明文列)
|
||||
- `crates/df-storage/src/models.rs:138`(`api_key: String` 裸字段)
|
||||
- `src-tauri/src/commands/ai/commands.rs:299-318`(`ai_save_provider` 直接 `update_full` 落盘)
|
||||
- `src/views/Settings.vue:53`(`listProviders` 返回完整 key,前端 `maskKey` 仅展示层)
|
||||
|
||||
**问题**:三个环节都明文——DB 文件 `app_data_dir/devflow.db` 明文落盘全部 provider 密钥;list 命令经 IPC 把明文 key 传到前端 webview;前端仅做视觉 mask。任何能读 DB 文件的进程(备份、同步盘、其他本机进程)直接拿全部密钥。
|
||||
|
||||
**严重度**:高(桌面应用典型安全缺口,密钥=成本+越权)。
|
||||
|
||||
**建议**:OS 级凭据存储(Windows Credential Manager / macOS Keychain / Linux Secret Service,用 `keyring` crate 包一层),DB 只存引用 id;list 命令返回 mask 后的占位,仅编辑保存时按需取明文。
|
||||
|
||||
### 🔴 FR-S2 — `read_file` TOCTOU + `write_file` 无大小限制 [已核实]
|
||||
|
||||
**位置**:`src-tauri/src/commands/ai/tool_registry.rs:364-417`
|
||||
|
||||
**问题**:
|
||||
- `read_file:364-380` 先 `tokio::fs::metadata(path)` 检查大小 ≤1MB,再 `tokio::fs::read_to_string(path)` 重新打开读——两个独立 syscall 之间路径可被 symlink 替换,1MB 限制可被绕过(检查时指向小文件、读取时切到大文件)。
|
||||
- `write_file:399-417` 无任何 `content.len()` 校验,LLM 经审批可写超大文件撑爆磁盘/对话历史(虽有 `truncate_for_persist` 50KB 兜底持久化,但磁盘写入无防护)。
|
||||
|
||||
**注**:T-260614-04 已加 `resolve_workspace_path` canonicalize 防 symlink 逃逸,但 canonicalize 在文件存在时才做、且不防检查/读取两步间的替换。
|
||||
|
||||
**严重度**:高(审批门控后的 LLM 写权限,破坏面大)。
|
||||
|
||||
**建议**:`read_file` 改单次 `File::open → file.metadata() → read` 同一句柄消除竞态;`write_file` 加 `content.len()` 上限(如 1MB)超限 bail。
|
||||
|
||||
### 🔴 FR-S3 — `approve_human_approval` 未校验 decision 白名单 [已核实]
|
||||
|
||||
**位置**:`src-tauri/src/commands/workflow.rs:180-206`
|
||||
|
||||
**问题**:`decision: String` 直接透传到 `HumanApprovalResponse`,无白名单校验。`human_node` 若对 decision 做字符串匹配(如 `== "approved"`),传入 `""` / `"yes"` / `"approve"` 会被当作既非批准也非拒绝,HumanNode 可能永久卡死直到超时。
|
||||
|
||||
**严重度**:高(工作流审批的输入校验缺口,前端可传任意字符串)。
|
||||
|
||||
**建议**:命令层校验 `decision ∈ {"approved","rejected"}`,非法值返回 Err。
|
||||
|
||||
### 🟠 FR-S4 — SKILL.md 全文注入 system prompt [仅代理报告待复核]
|
||||
|
||||
**位置**:`src-tauri/src/commands/ai/commands.rs:67-71` + `src-tauri/src/commands/ai/skills.rs:164-169`
|
||||
|
||||
**问题**:`ai_chat_send(skill=...)` 把任意本机 `~/.claude/skills/*/SKILL.md` 全文拼到 system prompt。被篡改/恶意的 SKILL.md(含 prompt injection 指令)会作为系统指令注入,覆盖行为准则;配合 LLM 可调工具(write_file/create_project)构成提权路径。
|
||||
|
||||
**严重度**:中(信任本机技能文件的设计取舍,但缺隔离标注)。
|
||||
|
||||
**建议**:注入时明确包裹"以下是用户选择的技能说明,非系统指令"的隔离边界;或文档标注此取舍。
|
||||
|
||||
### 🟠 FR-S5 — 跨对话越权审批 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src-tauri/src/commands/ai/audit.rs:111-139` + `commands.rs:104-185`
|
||||
|
||||
**问题**:`ai_approve` 只按 `tool_call_id` 从 `session.pending_approvals` 取审批,不校验该 tool_call 是否属于前端当前展示的对话。配合 `restore_pending_approvals` 启动时把所有 pending 行重建到单一内存 HashMap,任意前端可对任意对话的 pending 工具调用执行/拒绝。
|
||||
|
||||
**严重度**:中(本地单机影响有限,但审批 UI 的"对话隔离"是假象;Medium/High 工具审批门控意义被削弱)。
|
||||
|
||||
**建议**:`ai_approve` 增加 `expected_conversation_id` 参数并校验;或降级为文档说明"审批不按对话隔离"。
|
||||
|
||||
### 🟡 FR-S6 — `validate_column_name` 未登记表直接放行 [仅代理报告待复核]
|
||||
|
||||
**位置**:`crates/df-storage/src/crud.rs:334-350`
|
||||
|
||||
**问题**:`allowed_columns_for` 对未登记表返回 `None` → `validate_column_name` 返回 `Ok(())` 放行。当前 7 个 Repo 内部安全(表名是宏编译期常量),但 `is_allowed_column` 是 `pub fn`,若未来加了未登记表却走通用查询路径,列名直接进 `format!("... WHERE {} = ?1", field)` 字符串拼接即 SQL 注入。靠纪律非靠类型。
|
||||
|
||||
**严重度**:低(当前不可触发,潜在注入面)。
|
||||
|
||||
**建议**:未登记表保守拒绝,`None` 分支改 `Err`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 正确性 Bug(行为错但不伪造)
|
||||
|
||||
### 🟠 FR-C1 — `formattedEvents` 时间漂移 [已核实]
|
||||
|
||||
**位置**:`src/views/ProjectDetail.vue:298-307`
|
||||
|
||||
**问题**:`formattedEvents` 是 computed,内部对每条事件 `new Date().toLocaleTimeString()` 取"当前时刻"。每次 `store.liveEvents` 变化(新事件追加)整个 computed 重算,**所有历史日志的 time 被刷新为最新触发时刻**,而非事件发生时刻。日志时间全错。
|
||||
|
||||
**严重度**:中(明显体验 bug)。
|
||||
|
||||
**建议**:事件入数组时固化时间戳(store push 时记 `Date.now()` 或用后端事件自带的 timestamp),computed 只读不改时间。
|
||||
|
||||
### 🟠 FR-C2 — `net_sentiment=0` 文案/样式矛盾 [已核实]
|
||||
|
||||
**位置**:`src/views/Ideas.vue:103`(模板)+ `:368`(`sentimentClass`)
|
||||
|
||||
**问题**:模板 `adversarialEval.net_sentiment > 0 ? 正面 : 负面`(`> 0` 严格),`sentimentClass` `sentiment >= 0 ? 'positive' : 'negative'`(`>= 0` 含零)。`net_sentiment === 0` 时:文案走 else 显示「负面」,样式走 `positive`。视觉与文案矛盾。
|
||||
|
||||
**严重度**:中(评估卡片核心展示,矛盾误导用户)。
|
||||
|
||||
**建议**:统一三档 `> 0 / < 0 / === 0`(中性),文案 + class 同源判定。
|
||||
|
||||
### 🟡 FR-C3 — Settings.vue 三个 timer 只清一个 [已核实]
|
||||
|
||||
**位置**:`src/views/Settings.vue:749-751`
|
||||
|
||||
**问题**:组件有 `_concurrencyTimer`(660)、`_knowledgeSaveTimer`(717)、`_toastTimer`(390)三个 setTimeout,`onUnmounted` 只 `clearTimeout(_concurrencyTimer)`。另两个泄漏,unmount 后若 fire 会写已销毁 reactive(`toast`/`knowledgeConfig`)。
|
||||
|
||||
**严重度**:低(3000ms/300ms 短 timer,实际触发概率低,但属明确的资源管理缺口)。
|
||||
|
||||
**建议**:`onUnmounted` 清全部三个 timer。
|
||||
|
||||
### 🟡 FR-C4 — `MIGRATION_VERSION` 死常量与逻辑错位 [已核实]
|
||||
|
||||
**位置**:`crates/df-storage/src/migrations.rs:7`
|
||||
|
||||
**问题**:`const MIGRATION_VERSION: i32 = 12`,但 `run()` 的 if 链已调到 `migrate_v13`(实际版本 13)。常量全仓未被引用(dead constant),却留在文件头作"当前迁移版本"误导。新加 v14 迁移时若有人据此常量判断,会踩坑。
|
||||
|
||||
**严重度**:低(当前无功能影响,纯误导 + 易踩坑)。
|
||||
|
||||
**建议**:删除未引用常量;或改成数组驱动的迁移注册(`&[(version, migrate_fn)]` 循环),让编译器/结构保证版本号与执行链同步。
|
||||
|
||||
### 🟡 FR-C5 — `AiConversationDetail` 缺 `readonly` 字段 [已核实]
|
||||
|
||||
**位置**:`src/api/types.ts:242-246`(缺字段)vs `src-tauri/src/commands/ai/commands.rs:450-455`(后端生成中返回 `readonly:true`)
|
||||
|
||||
**问题**:后端 `ai_conversation_switch` 在 `session.generating` 时返回 `readonly: true`,前端类型定义未声明。store 层读 `detail.readonly` 会 TS 报错或被忽略,生成中切换对话时前端无法感知只读态。
|
||||
|
||||
**严重度**:低(TS 类型缺口,影响开发期类型安全 + 一个未利用的只读信号)。
|
||||
|
||||
**建议**:`AiConversationDetail` 加 `readonly?: boolean`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 并发 / 竞态(前端高频路径)
|
||||
|
||||
### 🟠 FR-R1 — `switchConversation` 无切换 token,竞态致历史错配 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src/composables/ai/useAiConversations.ts:44-121`
|
||||
|
||||
**问题**:`switchConversation` 含多个 await(`aiApi.switchConversation`、`aiApi.pendingToolCalls`)。快速连点 A→B,若 B 网络更快先返回,`state.activeConversationId=B` 但 A 后返回又把 `state.messages` 覆盖成 A 的历史——最终 activeId=B、messages=A,视图与激活会话不一致。B 的流式 delta 也可能被 `isCurrent` 判定错配,串话到 A 视图。
|
||||
|
||||
**严重度**:中(用户高频操作路径,错配难复现但体验崩坏)。
|
||||
|
||||
**建议**:加切换 token(`latestSwitchId`),await 后比对,过期响应丢弃;或用 `generatingConvId` 而非 `activeConversationId` 路由流式事件。
|
||||
|
||||
### 🟠 FR-R2 — 全局单看门狗 timer 多窗口互踩 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src/composables/ai/useAiStream.ts:18-44` + `useAiWindow.ts`(共享 state 单例)
|
||||
|
||||
**问题**:`_streamWatchdog` 模块级单 timer。分离窗口模式主/分离窗口共享同一 `state`,两侧 `handleEvent` 都 `resetStreamWatchdog()`。主窗口进入审批等待(clear timer)后,分离窗口的一个 delta 又 `resetStreamWatchdog()` 重启,审批态被误触发 130s 超时强制收尾。
|
||||
|
||||
**严重度**:中(隐蔽难复现,审批态被误中断)。
|
||||
|
||||
**建议**:按 `conversation_id` 维护 `Map<convId, timer>`,或分离窗口模式下由一方独占看门狗。
|
||||
|
||||
### 🟡 FR-R3 — `liveEvents` 数组无限增长 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src/stores/project.ts:213`
|
||||
|
||||
**问题**:`state.liveEvents.push(payload)` 从不裁剪。长时间运行的工作流让数组无限膨胀,每次 push 触发整个数组的响应式依赖重算(含 `formattedEvents` computed 全量 map)。
|
||||
|
||||
**严重度**:低(需超长工作流才显现,但内存+响应式开销随时间线性增长)。
|
||||
|
||||
**建议**:加上限(如 `length > 500` 时 `shift` 最旧)或改环形缓冲。
|
||||
|
||||
### 🟡 FR-R4 — LLM `complete()` 同步路径无超时无重试 [仅代理报告待复核] ✅ 已完成(commit 36d68dd)
|
||||
|
||||
**位置**:`crates/df-ai/src/openai_compat.rs:230-243` + `anthropic_compat.rs:226-232`
|
||||
|
||||
**问题**:`Client::builder()` 只设 `connect_timeout(30s)` 刻意不设总 timeout。`complete()`(同步调用,非流式)路径无整体超时、无重试、无取消令牌。远端建连成功但响应慢(网络静默、限流排队)时 `send().await` 无限挂起,卡死调用方任务。流式路径注释说由上层 idle timeout 兜底,但 complete() 无此兜底。
|
||||
|
||||
**严重度**:中(一次网络抖动挂死一轮对话)。
|
||||
|
||||
**建议**:`complete()` 用 `tokio::time::timeout` 包整体调用;加重试(指数退避 2-3 次)。
|
||||
|
||||
> **已完成**:`complete()` 路径用 `RequestBuilder::timeout(Duration::from_secs(60))` 设 60s 单请求超时;不影响 `stream` 流式路径(仍靠上层 idle timeout 兜底)。重试未加(当前需求仅防挂死)。commit 36d68dd。
|
||||
|
||||
### 🟡 FR-R5 — `findToolCall` / `flatMap` 全量线性扫描 [仅代理报告待复核] ✅ 已完成(commit 4b5f096)
|
||||
|
||||
**位置**:`src/composables/ai/useAiEvents.ts:63-71` + `useAiSend.ts:82-84`
|
||||
|
||||
**问题**:每个 `AiToolCallCompleted`/`AiApprovalRequired`/`AiApprovalResult`/`approveToolCall` 都遍历全部 messages 再遍历每条 toolCalls。长对话(数百消息)高频工具调用 O(n²)。
|
||||
|
||||
**严重度**:低(性能,长对话显现)。
|
||||
|
||||
**建议**:维护 `Map<toolCallId, AiToolCallInfo>` 索引,`AiToolCallStarted` 登记,事件直接查 Map。
|
||||
|
||||
> **已完成**:`useAiEvents::findToolCall` 改为反向遍历(命中最近一条同 id 工具调用)。**放弃 Map 索引方案**——审批替换等场景需命中最新 toolCall,Map 索引易留陈旧引用。commit 4b5f096。
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能(热路径)
|
||||
|
||||
### 🟡 FR-P1 — `dag.rs` 拓扑排序 O(V·E) + 入度 unwrap panic 风险 [仅代理报告待复核] ✅ 已完成(commit 4b5f096 / commit 内编号 FR-D1)
|
||||
|
||||
**位置**:`crates/df-workflow/src/dag.rs:65,74,114`
|
||||
|
||||
**问题**:`successors`/`predecessors` 是 `self.edges.iter().filter(...)` 全表扫描。`topological_layers` 对每个出队节点调 `successors`,整体 O(V·E);`executor.rs:81` 又对每个节点调 `predecessors`,再叠加 O(V·E)。DAG 变大(几十节点上百边)明显变慢。另 `dag.rs:115` `in_degree.get_mut(&succ).unwrap()`,若 edges 含 target 不在 nodes 的野节点(successors 不过滤),succ 不在 in_degree 表 → unwrap panic。
|
||||
|
||||
**严重度**:低(当前 DAG 规模小无感)。
|
||||
|
||||
**建议**:预建 `adjacency_out`/`adjacency_in: HashMap<NodeId, Vec<NodeId>>`,`add_edge*` 维护,拓扑降 O(V+E);`successors` 过滤野节点或 unwrap 改 `if let Some`。
|
||||
|
||||
> **已完成**:`dag.rs::topological_layers` 一次性遍历边建 `adjacency_out`(出边表)+ 入度表,BFS 分层走索引;`executor.rs::run` 预建 `adjacency_in`(入边索引)取前驱。O(V·E) → O(V+E),14 测试全绿(含 executor 3 测)。commit 4b5f096(该 commit 内编号为 FR-D1)。
|
||||
|
||||
### 🟡 FR-P2 — `search_vector` 全表扫 + SELECT * [仅代理报告待复核] ✅ 已完成(commit 4a95f6a / commit 内编号 FR-D2)
|
||||
|
||||
**位置**:`crates/df-storage/src/crud.rs:1176-1209`
|
||||
|
||||
**问题**:每次向量检索 `SELECT * FROM knowledges WHERE status='published' AND embedding IS NOT NULL` 拉全部已发布记录的完整 BLOB(含 content/title 大文本),Rust 端算余弦。仅用了 embedding 却拉了全部字段。注释自评"<50k <50ms",但无 LIMIT 上限,行数无界增长。
|
||||
|
||||
**严重度**:低(知识检索热路径,当前数据量可接受)。
|
||||
|
||||
**建议**:SELECT 只取 id+embedding,算完 top-N 的 id 再回表取详情;长期换 sqlite-vec(注释已预告)。
|
||||
|
||||
> **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。
|
||||
|
||||
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核]
|
||||
|
||||
**位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`
|
||||
|
||||
**问题**:整个数据库一条 `Connection`,所有读写串行抢 `tokio::sync::Mutex`。每条 CRUD(含 `list_all`/`search`)都 spawn_blocking + blocking_lock。全局热点锁,并发查询互相阻塞。WAL 已开但读路径未享并发红利。代码注释已有 r2d2 TODO 未做。
|
||||
|
||||
**严重度**:低(单用户桌面应用,并发量小,当前无感)。
|
||||
|
||||
**建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。
|
||||
|
||||
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实]
|
||||
|
||||
**位置**:`crates/df-nodes/src/human_node.rs:54`
|
||||
|
||||
**问题**:`cancel_tick = interval(500ms)`,select! 内每 500ms 检查 `is_cancelled`。最长 3600s 阻塞节点 = 7200 次无谓锁竞争(StateMachine HashMap 锁)。魔法数字无注释。
|
||||
|
||||
**严重度**:低(开销小但模式不佳)。
|
||||
|
||||
**建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。
|
||||
|
||||
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src-tauri/src/commands/ai/prompt.rs:70-86`
|
||||
|
||||
**问题**:`ai_chat_send` 每次调 `build_system_prompt` → `state.projects.list_active()` → `take(20)` 拼 format! 字符串。热路径每对话重扫全表+重拼。
|
||||
|
||||
**严重度**:低(项目表小,SQL 本身快,format! 重复分配是主要开销)。
|
||||
|
||||
**建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。
|
||||
|
||||
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核]
|
||||
|
||||
**位置**:`src/components/AiChat.vue:670-678`
|
||||
|
||||
**问题**:`watch(() => store.state.messages, ..., { deep: true })` 内对整个 messages 做 `JSON.stringify(msgs.map(...))`。流式生成期间 currentText 每个 delta 触发 messages 对象变更,deep watch 每秒数十次全量序列化。长对话主线程阻塞。
|
||||
|
||||
**严重度**:低(与 aichat-review AR-1 流式 Markdown 全量重解析叠加放大)。
|
||||
|
||||
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
|
||||
|
||||
---
|
||||
|
||||
## 6. 可读性 / DRY(重构项)
|
||||
|
||||
### FR-D1 — confirmDialog 逻辑 4 处重复 [已核实] ✅ 已完成(commit 4a95f6a / commit 内编号 FR-D5)
|
||||
`Ideas.vue:217` / `Projects.vue:137` / `ProjectDetail.vue:226` / `Settings.vue:403` 各一份 `confirmState + confirmDialog + answerConfirm`,代码几乎逐字相同。抽 `useConfirmDialog()` composable 配合现有 `ConfirmDialog.vue`。
|
||||
|
||||
> **已完成**:抽 `src/composables/useConfirm.ts`,Projects/ProjectDetail/Ideas/Settings 四视图改用 composable,各削减约 14 行重复(commit 4a95f6a;该 commit 内编号为 FR-D5)。
|
||||
|
||||
### FR-D2 — `parseTags`/`parseScores`/`parseStack` JSON 解析散落 [已核实]
|
||||
同类"JSON 字符串→数组"解析在 Ideas/Knowledge/Projects/ProjectDetail 多处独立实现。收敛到 `utils/json.ts` 的 `parseJsonArray`/`parseJsonObject`。
|
||||
|
||||
### FR-D3 — `set_waiting`/`set_skipped`/`emit_human_approval_request` 死代码 [仅代理报告待复核]
|
||||
`df-workflow/state.rs:87-111`(set_waiting/set_skipped 无调用方)+ `eventbus.rs:44`(emit_human_approval_request 无调用方)。`set_cancelled` 绕过状态机转换校验直接 insert。建议清理死代码 + `set_cancelled` 纳入合法转换白名单。
|
||||
|
||||
### FR-D4 — migrations 13 个手写 if 链 DRY 反例 [已核实]
|
||||
`migrations.rs:38-90` 13 个 `if current_version < N { migrate_vN()? }` 手写重复,新加迁移易漏注册且编译器不报。改数组驱动 `&[(version, fn)]` 循环(与 FR-C4 同根,一并修)。
|
||||
|
||||
### FR-D5 — 状态机后门 + 死代码清理集合 [仅代理报告待复核]
|
||||
合并 FR-D3 + `state.rs` 的 `set_*` 系列审计:保留 `set_cancelled`(cancel IPC 必需)但纳入 `is_legal` 转换图;删除 `set_waiting`/`set_skipped` 未用方法。
|
||||
|
||||
---
|
||||
|
||||
## 7. 核对通过的项(无问题)
|
||||
|
||||
- **R6 await 缺失是孤立 bug**:其余 10 处 send/emit/channel 操作全部正确 await(已逐条核查,见 workflow-approval-review 结论)。
|
||||
- **执行链完整**:`run_workflow → spawn → DagExecutor::run → join_all(node.execute) → HumanNode select! → approve IPC → Response → HumanNode 返回 → executor 继续`,无断裂;cancel 通路 `cancel_workflow_node → set_cancelled → HumanNode 500ms 轮询 is_cancelled → Err` 也完整。
|
||||
- **SQL 参数化扎实**:`query`/`update_field` 经 `validate_column_name` 白名单 + `params![]` 参数绑定,无 value 拼接注入(FR-S6 是潜在面非现行漏洞)。
|
||||
- **tool_registry 无运行时注册入口**:`AiToolRegistry` 只在 `build_ai_tool_registry` 编译期注册,前端无法注入恶意工具。
|
||||
- **v-html 安全**:`AiChat.vue:200` `renderMd` 经 DOMPurify sanitize;`ToolCard.vue` 多处 v-html 均为常量 SVG 非用户输入。
|
||||
- **事务边界**:`purge_with_descendants` 子表先删父表后删单事务 commit;迁移脚本幂等(PRAGMA 探测 + IF NOT EXISTS)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议追加到 todo.md 的项
|
||||
|
||||
按严重度给 todo ID(待用户确认是否进看板):
|
||||
|
||||
| 建议 ID | 对应 | 优先级 |
|
||||
|---|---|---|
|
||||
| FR-S1(api_key 加密) | 本报告 §2 | P0 |
|
||||
| FR-S2(read_file TOCTOU + write_file 大小限制) | 本报告 §2 | P0 |
|
||||
| FR-S3(approve decision 白名单) | 本报告 §2 | P0 |
|
||||
| FR-R1(switchConversation 切换 token) | 本报告 §4 | P1 |
|
||||
| FR-R2(看门狗多窗口互踩) | 本报告 §4 | P1 |
|
||||
| FR-C1(formattedEvents 时间漂移) | 本报告 §3 | P1 |
|
||||
| FR-C2(net_sentiment=0 矛盾) | 本报告 §3 | P1 |
|
||||
| FR-S4/S5(SKILL.md 注入 / 跨对话审批) | 本报告 §2 | P2 |
|
||||
| FR-R4(complete() 无超时) | 本报告 §4 | P2 |
|
||||
| FR-C3/C4/C5(timer 清理 / 迁移常量 / readonly 类型) | 本报告 §3 | P2 |
|
||||
| FR-P1~P6 / FR-D1~D5(性能 + 重构) | 本报告 §5-6 | P2/长期 |
|
||||
|
||||
**优先修 3 件**(安全 + 明显体验):
|
||||
1. **FR-S1** api_key 加密(凭据泄露面最大)
|
||||
2. **FR-S3** approve decision 白名单(一行校验,防 HumanNode 卡死)
|
||||
3. **FR-C1** formattedEvents 时间漂移(明显体验 bug,改动小)
|
||||
|
||||
> 本报告仅审查 + 文档,不含代码修改。实际修复另起会话。
|
||||
205
docs/05-代码审查/工作区多角度走查-2026-06-15.md
Normal file
205
docs/05-代码审查/工作区多角度走查-2026-06-15.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 工作区多角度代码走查(2026-06-15)
|
||||
|
||||
> 范围:工作区未提交改动 22 文件 547 行 + 近 6 提交前端改动。4 路并行代理按文件域切分走查(AiChat.vue / View 层 / stores 重构 / composables+ToolCard)。
|
||||
> 方法:每路代理独立 `git diff` + 多维度走查(正确性/健壮性/边界/性能/DRY/安全/异步时序/i18n/Vue3+Pinia+TS 规范),主代理整合 + 去重 + 关联已有 todo。
|
||||
> 性质:**dry — 仅走查 + 文档,不改代码**(本会话职责:走查/整理/建待办,见 memory `session-role-diagnose-only`)。
|
||||
> 关联:[近期改动代码审查-2026-06-15.md](./近期改动代码审查-2026-06-15.md)(FR-S1/S7/S8+近5提交);[自研块级memo流式渲染审查-2026-06-15.md](./自研块级memo流式渲染审查-2026-06-15.md)(块级 memo,CR-04~07,本走查跳过不重复)。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 必须修复(4 独立根因)
|
||||
|
||||
### ① [src/stores/project.ts:273] selectType 字段名与后端 IPC 不匹配 → 多选审批静默失效(P0 功能)
|
||||
|
||||
**来源**:stores 代理。
|
||||
|
||||
**改什么**:`invoke('approve_human_approval', {...})` 传 `selectType`(camelCase),后端 `src-tauri/src/commands/workflow.rs:211` 签名 `select_type: Option<String>`(snake_case)。Tauri 2 `#[tauri::command]` **默认不做 camelCase→snake_case 转换**(全项目仅 `state.rs:30` 有 `rename_all`,与 IPC 命令无关)。
|
||||
|
||||
**影响**:后端收到 `select_type = None` → 归一化为 `Single`(workflow.rs:214-217)→ 多选 UI 勾多项提交被后端拒「单选审批只能提交一个决策」。**单选偶然兼容(缺省=Single)掩盖了问题**。F-260615-01 多选审批功能完全不可用。
|
||||
|
||||
**证据**:同 invoke 对象 `execution_id`/`node_id` 已是 snake_case,唯独 `selectType` 破坏约定;`grep selectType` 在 `src-tauri/` 零命中。
|
||||
|
||||
**怎么改**:
|
||||
```diff
|
||||
- selectType: state.pendingApproval.select_type ?? 'single', // Tauri camelCase→snake_case 自动转 select_type
|
||||
+ select_type: state.pendingApproval.select_type ?? 'single',
|
||||
```
|
||||
删误导性注释。单行修复。**修后须手动验证一次多选审批全链路**。
|
||||
|
||||
---
|
||||
|
||||
### ② [useAiSend/useAiStream/useAiEvents] 流式 timeout/error/stop 收尾不清队列 → 队列消息静默丢失(P0 功能)
|
||||
|
||||
**来源**:composables 代理。
|
||||
|
||||
**改什么**:`sendMessage`(useAiSend.ts:36-42)在 `state.streaming === true` 时入队 `state.queue`。`drainQueue`(useAiSend.ts:25-29)**仅在 AiCompleted 事件中调用**(useAiEvents.ts:218)。若流式因 `onStreamTimeout`/`AiError`/`stopChat` 收尾(非走 AiCompleted),队列消息**永远不被发出**,用户输入"消失"无提示。
|
||||
|
||||
**影响**:用户生成中输入消息 → 流式超时/stopChat/后端崩溃 → 队列消息静默丢失,用户以为已发送但无回复。
|
||||
|
||||
**关联去重**:与 **B-260615-22**(前后端状态不同步,streaming 复位撞后端拦截)相关但不同角度——B-22 讲状态不同步致发送撞拦截,本项讲收尾路径不清队列致消息丢失。两者独立。
|
||||
|
||||
**怎么改**:`onStreamTimeout`(useAiStream.ts:21-49)/ `AiError` case(useAiEvents.ts:222-237)/ `stopChat` 三处收尾路径都应清空队列并提示(或回填输入框):
|
||||
```diff
|
||||
// onStreamTimeout() 末尾,clearStreamWatchdog() 之后
|
||||
+ if (state.queue.length) {
|
||||
+ state.queue = []
|
||||
+ content += '\n(待发送队列中的消息已取消,请重新发送)'
|
||||
+ }
|
||||
```
|
||||
`approveToolCall`(useAiSend.ts:82-107)IPC 失败 catch `throw e` 路径同样漏清队列,归并本项一起修。
|
||||
|
||||
---
|
||||
|
||||
### ③ [useAiStream.ts onStreamTimeout] 超时不回滚 running toolCall → 审批后卡片永久骨架屏(P0 功能,B-07 残留)
|
||||
|
||||
**来源**:composables 代理。
|
||||
|
||||
**改什么**:`approveToolCall`(useAiSend.ts:82-98)乐观置 `tc.status = 'running'` 后,若后端既不回 `AiToolCallCompleted` 也不回 `AiApprovalResult`(IPC 成功但后端 hang),看门狗超时回调 `onStreamTimeout`(useAiStream.ts:21-49)**只复位 `state.streaming`/`generatingConvId`/`currentText` + push 错误气泡,不碰 `messages[].toolCalls[].status`**。卡片 status 仍是 running → 渲染骨架屏(ToolCard.vue:24),审批按钮在 `pending_approval` 分支(ToolCard.vue:31)才显示 → 用户看到永久骨架屏,无重审入口。
|
||||
|
||||
**关联去重**:**B-260615-07 已实施**(approveToolCall 乐观置 running 加 watchdog 兜底),但 B-07 只加了 watchdog 重启,**onStreamTimeout 回调本身没回滚 toolCall.status**——这是 B-07 的残留遗漏点。本项是 B-07 的补全。
|
||||
|
||||
**怎么改**:`onStreamTimeout` 中扫一遍把 running 的 toolCall 回滚:
|
||||
```diff
|
||||
// onStreamTimeout() 中,clearStreamWatchdog() 之后、push 消息之前
|
||||
+ for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
+ const tcs = state.messages[i].toolCalls
|
||||
+ if (!tcs) continue
|
||||
+ for (const tc of tcs) {
|
||||
+ if (tc.status === 'running') { tc.status = 'rejected'; tc.result = '审批/执行超时未响应' }
|
||||
+ }
|
||||
+ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ④ i18n 硬编码一组 → 英文 locale 中英混杂(P1 文案)
|
||||
|
||||
**来源**:View 代理(ProjectDetail 多选按钮 🔴)+ composables 代理(onStreamTimeout 文案 🟡)。
|
||||
|
||||
**改什么**:
|
||||
- **`ProjectDetail.vue:201`**(🔴):多选审批按钮 `确认({{ multiDecisions.length }})` 硬编码,同文件其他文案全走 `$t('projectDetail.xxx')`,i18n 体系完整。英文 locale 下多选弹层中英混杂。
|
||||
- **`useAiStream.ts:39-41`**(🟡):`onStreamTimeout` 两条错误文案硬编码(B-260615-03 改了文案但没接 i18n)。
|
||||
- **`AiChat.vue:675`**(⚪):handleSend toast `发送失败:${msg}` 硬编码。
|
||||
|
||||
**关联去重**:`AiChat.vue:459` confirmClearChat 硬编码 = **B-260615-20 已记技术债**(「文案硬编码未接 i18n,同 confirmClearChat 先例」),不重复记,但本组修复时一并处理。
|
||||
|
||||
**怎么改**:补 i18n key(中英):
|
||||
- `projectDetail.approvalConfirm` / `approvalConfirmCount({count})`
|
||||
- `ai.streamInterruptedAfterTool` / `ai.streamInterrupted`
|
||||
- `aiChat.toastSendFail`
|
||||
模板/composable 改用 `t('...')`。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 建议改进
|
||||
|
||||
### ⑤ 四份 `.ai-md` 样式逐字重复 ~180 行 → 抽全局样式或 `<AiMarkdown>` 组件(最大 DRY 债)
|
||||
|
||||
**来源**:View 代理。**位置**:ProjectDetail.vue:549-607 / Ideas.vue:815-873 / Knowledge.vue:610-668 / TaskDetail.vue。
|
||||
|
||||
`:deep(p/ul/li/code/pre/blockquote/h1-h3/a/strong/hr/table/th/td)` 样式规则**逐字符相同**(仅外层选择器不同)。B-260615-24(TaskDetail)首创,B-260615-25 复制粘贴到三视图。composable 抽了渲染逻辑(useMarkdown),样式没跟着抽。
|
||||
|
||||
**怎么改**:方案 A(推荐,轻量)— `src/styles/ai-md.css` 全局规则 + `main.ts` import 一次,四视图删 scoped 重复块。方案 B(彻底)— 抽 `<AiMarkdown :text>` 组件含 v-html+scoped 样式。先 A,第 5 处需求再升 B。
|
||||
|
||||
### ⑥ useMarkdown 加 `useRendered(getText)` 辅助 → 消除 computed+onMounted 三处重复(与 ⑤ 同源)
|
||||
|
||||
**来源**:View 代理。ProjectDetail:251 / Ideas:225 / Knowledge:249 三处 `renderedDesc = computed(() => { void mdReady.value; return renderMd(...) })` + `onMounted(loadMarkdown)` 模式逐字一致。
|
||||
|
||||
**怎么改**:useMarkdown 加 `useMarkdownRender(getText)` 返回 `{ html, loadMarkdown }`,调用点一行。做 ⑤ 方案 B 组件则此项自动消失。
|
||||
|
||||
### ⑦ switchConversation `JSON.parse(arguments)` 无逐条容错 → 单条坏参数清空整对话
|
||||
|
||||
**来源**:composables 代理。useAiConversations.ts:81 `JSON.parse(tc.function?.arguments || '{}')` 在 map 内,外层 try-catch(:87)粒度过粗——单条 tool_call arguments 损坏 → `state.messages = []` 整对话空白。
|
||||
|
||||
**怎么改**:包成安全函数逐条容错(返回 {} 兜底)。
|
||||
|
||||
### ⑧ args 消费方全用 `as any` → 类型逃逸
|
||||
|
||||
**来源**:composables 代理。ToolCard.vue:241,328 + useAiConversations.ts:71。`AiToolCallInfo.args` 类型 `unknown`(types.ts:205)但消费方 `as any` 绕过,拼写错无编译期检查。
|
||||
|
||||
**怎么改**:定义 `type ToolArgs = Record<string, unknown>`,出口收窄 + 消费方类型守卫。
|
||||
|
||||
### ⑨ approveToolCall 重复查找 → 复用 findToolCall
|
||||
|
||||
**来源**:composables 代理。useAiSend.ts:82-86 `state.messages.flatMap(m => m.toolCalls || []).find(...)`,而 findToolCall(useAiEvents:75)已是反向遍历统一查找。同一语义两套查找,DRY 违反。
|
||||
|
||||
**怎么改**:`approveToolCall` 改用 `findToolCall(toolCallId)`(useAiSend 已 import useAiEvents,不新增耦合方向)。
|
||||
|
||||
### ⑩ useAiEvents switch 缺 AiHeartbeat case
|
||||
|
||||
**来源**:composables 代理。useAiEvents.ts:115 看门狗重置白名单 `['AiApprovalRequired','AiCompleted','AiError']`,AiHeartbeat 不在排除表 → 会 resetStreamWatchdog(行为正确),但 switch(:118)无 AiHeartbeat case 落到末尾。功能对但可读性差。
|
||||
|
||||
**怎么改**:补 `case 'AiHeartbeat': break`(显式 no-op)。
|
||||
|
||||
### ⑪ approveHumanApproval 签名 decision/decisions 歧义
|
||||
|
||||
**来源**:stores 代理。project.ts:255 `(decision, comment?, decisions?)` 三参,多选传首项 decision 凑数 + 全量 decisions,依赖后端归一化。前端注释「decision 传首项(向后兼容)」冗余。
|
||||
|
||||
**怎么改**:改 options 对象签名 `{ decisions: string[], comment? }`,或不改则在 JSDoc 明确「decisions 优先,decision 占位」契约。
|
||||
|
||||
### ⑫ ToolCard 杂项
|
||||
|
||||
**来源**:composables 代理。formatBytes(undefined) 误显「0 B」(:227,应区分 undefined 与 0);shouldKeepOpen 与 toolCategory 命名判定风格不一(:131 精确 vs :206 includes);v-for `:key="arg.key"` 无 index 兜底(:33,LLM 畸形 args 极低概率)。
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 可选优化
|
||||
|
||||
- **_toastTimer 卸载未 clearTimeout**(AiChat.vue:436,728)— onBeforeUnmount 清了 rafId 漏了 _toastTimer,同组件不对称。补一行。
|
||||
- **markdown 外层容器 span/p 包 block**(ProjectDetail:119 span / Ideas:66 p)— 改 div 避免 inline 包 block,浏览器容错实际不崩。
|
||||
- **内联 SVG 常量散落重复**(ToolCard.vue:127-128,213-218)— 抽 SVG 常量表。
|
||||
- **appSettings.ts:8 注释「store 层」指代模糊**(settings.ts 已删)— 改指 `stores/ai.ts`。
|
||||
- **stores/index.ts 空行不统一** — 格式。
|
||||
- **router TaskDetail 路由 icon 语义**(:44-49)— 核对是否有 menu 遍历逻辑,详情页防误入菜单。
|
||||
- **options 透传缺 shape 校验**(project.ts:271)— 后端兜底已校验,记录。
|
||||
- **projectNameById computed 每实例重建**(ToolCard.vue:288-293)— 可选提升 store/inject 共享。
|
||||
- **useConfirm answerConfirm resolve null 静默**(:43-47)— dev 警告。
|
||||
- **useAiEvents.ts:25 `t` 的 `as any`** — 已知权衡(绕 vue-i18n TS2589),记录。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 亮点
|
||||
|
||||
1. **settings.ts mock 删除彻底零残留**(stores 代理):`git log -p` 确认是初始提交纯 mock(98 行硬编码 provider+连接),全 src 零 `useSettingsStore`/`AIProvider`/`GeneralSettings` import(grep 验证)。`api/settings`(HTTP 层)同名不同模块不构成残留。真实 provider CRUD 走 `api/ai.ts`+`stores/ai.ts`,偏好走 `appSettings.ts`,删除干净。
|
||||
2. **useConfirm 抽取消除重复彻底**(composables 代理):Projects/ProjectDetail/Ideas/Settings/AiChat 五处全迁移,无残留 `window.confirm`、无旧 confirmState 定义。Settings reactive→ref 模板自动解包正确。
|
||||
3. **useMarkdown 单例+DOMPurify+缓存+兜底四件套**(View 代理):模块级 `_marked/_purify/mdReady/_mdCache` 单例避免多视图重复加载 marked,renderMd 强制 sanitize 杜绝 XSS,未就绪 escapeFallback 降级。三视图复用零成本。
|
||||
4. **`void mdReady.value` 显式响应式依赖**(View 代理):修了 B-25 漏响应式致首次纯文本后不重算的坑,三处一致 + 注释到位。Vue3 computed 追踪非同步读的正确用法。
|
||||
5. **findToolCall 反向遍历 + 不建索引的决策有据**(composables 代理):注释详述 state.messages 多处整体替换致独立 Map 索引易失配,反向扫描零额外状态。onStreamTimeout 复制扫描逻辑而非 import 是为避循环依赖,判断准确。
|
||||
6. **switchConversation 二次 token 比对**(composables 代理):第二个 await 后再 `if (mySwitchId !== _latestSwitchId) return`,防 A→B 切换期间 A 的 pending 覆写 B,补了首个异步点后的竞态漏。
|
||||
7. **sendMessage IPC 失败回滚完善**(composables 代理):复位 streaming+清 watchdog+移除 user 消息和空气泡,让 handleSend 回填输入框重试。
|
||||
8. **多选审批状态清空双保险**(View 代理):watch pendingApproval 在 showDialog 前清 multiDecisions,handleApprovalMulti 结束也清,immediate:true 覆盖首次挂载。
|
||||
9. **demoDag 下线决策有据**(View 代理):R-PD-2 删除根因明确(script 节点不再注册,构造含 script 的 DagDef 会 build_dag 失败),删得干净无死代码。
|
||||
10. **i18n 中英 key 完整对齐**(composables 代理):aiTool.ts 两份逐行比对,11 个 CRUD key 中英都有,无缺 key。
|
||||
|
||||
---
|
||||
|
||||
## 📊 摘要
|
||||
|
||||
| # | 等级 | 文件:行 | 修改内容 | 对应 todo |
|
||||
|---|------|---------|----------|-----------|
|
||||
| ① | 🔴P0 | project.ts:273 | selectType→select_type,多选审批静默失效 | B-260615-31 |
|
||||
| ② | 🔴P0 | useAiSend/Stream/Events | timeout/error/stop 收尾清队列,防消息丢失 | B-260615-32 |
|
||||
| ③ | 🔴P0 | useAiStream onStreamTimeout | 回滚 running toolCall,防骨架屏卡死(B-07 残留) | B-260615-33 |
|
||||
| ④ | 🔴P1 | ProjectDetail:201 + useAiStream:39 + AiChat:675 | i18n 一组(多选按钮🔴+错误文案🟡+toast⚪) | CR-260615-08 |
|
||||
| ⑤ | 🟡P1 | ProjectDetail/Ideas/Knowledge/TaskDetail | .ai-md 180 行抽全局样式/组件 | CR-260615-09 |
|
||||
| ⑥ | 🟡P2 | ProjectDetail:251/Ideas:225/Knowledge:249 | useMarkdown useRendered 辅助(同 ⑤ 源) | CR-260615-10 |
|
||||
| ⑦ | 🟡P2 | useAiConversations.ts:81 | JSON.parse 逐条容错 | CR-260615-11 |
|
||||
| ⑧ | 🟡P2 | ToolCard:241,328 + useAiConversations:71 | args 去 as any 收窄类型 | CR-260615-11 |
|
||||
| ⑨ | 🟡P2 | useAiSend.ts:82-86 | approveToolCall 复用 findToolCall | CR-260615-11 |
|
||||
| ⑩ | 🟡P2 | useAiEvents.ts:118 | switch 补 AiHeartbeat case | CR-260615-11 |
|
||||
| ⑪ | 🟡P2 | project.ts:255 | approveHumanApproval 签名改 options 对象 | CR-260615-11 |
|
||||
| ⑫ | 🟡P2 | ToolCard.vue 多处 | formatBytes/命名判定/key 兜底 | CR-260615-11 |
|
||||
| ⚪ | P3 | AiChat:436 等 10 项 | 见 ⚪ 区 | CR-260615-12 |
|
||||
|
||||
**总计**:🔴4(3 个 P0 功能 + 1 个 P1 文案)🟡8 ⚪10
|
||||
|
||||
**总体评价**:工作区改动主干质量高(settings.ts 删除彻底/useConfirm 收敛/useMarkdown 四件套/响应式修复正确/竞态防护到位)。**3 个 P0 功能 bug 集中在 AI 交互异步收尾路径**(队列丢失/骨架屏卡死)+ **1 个 IPC 字段名笔误**(selectType)—— 均非设计缺陷,是收尾路径不完整 + 命名笔误,修复成本低。最大技术债是 ⑤ .ai-md 样式 180 行重复(B-24/25 复制粘贴源头),建议抽全局样式根治。
|
||||
|
||||
**质量评级**:良(改 ①②③④ 达优;⑤⑥ 顺带清最大 DRY 债)
|
||||
|
||||
---
|
||||
|
||||
## 来源
|
||||
|
||||
- 4 路并行代理走查(AiChat.vue / View 层 / stores 重构 / composables+ToolCard),2026-06-15,本会话内 spawn。
|
||||
- 关联已有 todo:B-260615-07(approveToolCall watchdog,③ 残留点)、B-260615-20(handleSend toast i18n 技术债,④ 同源)、B-260615-22(前后端状态不同步,② 相关)、B-260615-24/25(.ai-md 样式首创/复制,⑤ 源头)。
|
||||
214
docs/05-代码审查/架构与缺陷复核报告-2026-06-14.md
Normal file
214
docs/05-代码审查/架构与缺陷复核报告-2026-06-14.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 代码架构与缺陷复核报告(2026-06-14)
|
||||
|
||||
> 范围:在 [全栈代码审查报告-2026-06-14.md](./全栈代码审查报告-2026-06-14.md) 基础上的**复核 + 回归审计 + 架构层补充**。
|
||||
> 方法:4 路并行(安全/并发性能/架构/工作流),其中安全路子代理失控(误改任务表无产出),改由**主代理直读 5 文件**完成(tool_registry / crud / workflow / commands / audit)。其余 3 路子代理产出完整。
|
||||
> 模式:**dry — 仅审查 + 文档,不改代码**。
|
||||
> 互斥:与全栈报告去重;本报告只记**复核结论(confirm/refute/已修)+ 新发现 + 报告修正**,不重复已记录的项。
|
||||
|
||||
---
|
||||
|
||||
## 0. 复核总览
|
||||
|
||||
| 类别 | 数量 | 说明 |
|
||||
|---|---|---|
|
||||
| 已修确认(最近提交修复正确) | 8 | FR-S2/S3/S4/S6、AR-3、FR-R1(主路径)/R3、FR-C1 |
|
||||
| confirm(复核证实仍存在) | 9 | FR-S1/S5、FR-R4/R5、FR-P2/P3/P5/P6、FR-P1(O(V·E)部分) |
|
||||
| refute(原报告误判/已澄清) | 3 | FR-R2 降级成立、FR-P1 panic 担忧不成立、FR-P4 严重度偏高 |
|
||||
| **新发现(不在原报告/todo)** | **11** | 见 §5,其中 5 项 🟠 |
|
||||
| 死代码确认 | 5 | set_waiting/set_skipped、emit_human_approval_request、ModelRouter、StreamCollector、IdeaPromoter |
|
||||
|
||||
**最高优先级(建议本轮接手)**:§5 新发现 ②③④⑤(FR-R1 闭环缺口 / client 降级丢 timeout / 取消事件语义双标 / cancel 无终态校验)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全复核(主代理直读)
|
||||
|
||||
### ✅ 已修确认
|
||||
|
||||
**① FR-S2 — read_file/write_file [已核实修复正确]**
|
||||
- `tool_registry.rs:367-376` read_file 改单次 `File::open → file.metadata() → read_to_string`,**同句柄**消除原 metadata+read 两步 syscall 间 symlink 替换的 TOCTOU。1MB 上限在 `metadata.len()` 判定后 bail。
|
||||
- `tool_registry.rs:416-418` write_file 加 `content.len() > 1_048_576` bail。
|
||||
- 两处入口均先走 `resolve_workspace_path`(canonicalize 防 symlink 逃逸)。
|
||||
- **残余低优**:read_file 的 metadata 大小判定与实际 `read_to_string` 之间,若文件在判定后被追加写入,仍可能读入 >1MB(路径已 canonicalize,攻击者难 swap,属理论面)。write_file 无此问题(写 content 大小在内存已知)。
|
||||
|
||||
**② FR-S3 — approve decision 校验 [已核实修复,但 IPC 层弱于建议]**
|
||||
- `workflow.rs:189` `decision.trim().is_empty()` → Err。防 `""` 透传卡死 HumanNode。
|
||||
- **缺口**:IPC 层**未做 decision ∈ {"approved","rejected"} 白名单**(原报告建议)。深层兜底在 `human_node.rs:67` 的 `options.is_empty() || options.contains(&decision)`,故 options 非空时仍拒非法值。属"IPC 弱 + HumanNode 强"的纵深防御,可接受;若想严格化,IPC 补一行白名单即可。
|
||||
|
||||
**③ FR-S4 — SKILL.md 隔离标注 [已核实修复,属弱防御]**
|
||||
- `commands.rs:67-74` 注入前包裹 `# 用户选择的技能说明: {}(非系统指令,勿作为行为准则覆盖;以下为技能内容供参考)`,且技能内容置于 system_prompt **之前**、系统真行为准则在**之后**(recency 优势)。
|
||||
- **性质**:纯文本标注,是 prompt injection 的**缓解非根治**——信任本机技能文件的设计取舍下可接受,但恶意 SKILL.md 仍可能通过语义注入影响输出。已在功能决策记录标注此取舍即可,不升级。
|
||||
|
||||
**④ FR-S6 — validate_column_name 未登记表拒绝 [已核实修复正确]**
|
||||
- `crud.rs:339` `None => Err("表 {} 未登记列白名单,拒绝防注入")`,与 `is_allowed_column` 的 `None => false` 语义一致。
|
||||
- **回归风险评估**:allowed_columns_for 当前登记 ai_tool_calls/ai_conversations/ai_messages/ai_providers/ai_sessions/ideas/idea_scores/projects/tasks/workflows/node_executions/knowledges/knowledge_events(match arms 可见)。所有走 `query`/`update_field` 通用路径的 Repo 表均已登记(编译期表名宏 + 实测),无合法查询被误拒。低回归风险。
|
||||
|
||||
**⑤ AR-3 — 审批 reason 拼对象名 [已核实修复且实现质量高]**
|
||||
- `audit.rs:64-138` `build_approval_reason` 特化 9 工具:delete/restore/purge_project、update_project、bind_directory、create_task、create_project、create_idea、run_workflow。
|
||||
- `resolve_project_label`(:44)查 `ProjectRepo::get_by_id` 取 name,查不到回退 `(id=x)`,空 id 返回空串。
|
||||
- 无 N+1:每次审批单工具单查询,非循环。
|
||||
- 未特化工具走 risk fallback 模板。覆盖完整。
|
||||
|
||||
### 🟠 FR-S1 — api_key 明文三连 [维持未修]
|
||||
确认现状未动(migrations.rs/models.rs/commands.rs/Settings.vue 四处仍明文)。沿用全栈报告结论,P0 待 OS keychain 落地。
|
||||
|
||||
### 🟡 FR-S5 — 跨对话越权审批 [确认降级文档成立]
|
||||
`commands.rs:117-120` ai_approve 仅按 tool_call_id 从 pending_approvals 取,无对话归属校验。但本地单机 + restore 只载当前对话 + UI 隔离,实际触发面窄。维持降级为文档说明。
|
||||
|
||||
---
|
||||
|
||||
## 2. 并发/性能复核(子代理产出,11 项)
|
||||
|
||||
| # | 项 | 判定 | 证据要点 | 严重度 |
|
||||
|---|---|---|---|---|
|
||||
| FR-R1 | 切换 token | **已修(主路径) + 遗漏点** | `_latestSwitchId` 在首个 await 后比对正确,**但 pendingToolCalls(:108) await 后无二次比对**,A→B 快切可覆写 B 的 pendingApprovals | 🟠(见新发现①) |
|
||||
| FR-R2 | 看门狗多窗口互踩 | **refute(降级成立)** | 分离窗口=独立 WebviewWindow=独立 JS realm,模块级 state 各持一份不共享 | 🟢 |
|
||||
| FR-R3 | liveEvents 限长 | **已修正确** | `project.ts:215` splice(0, len-200),无 off-by-one,配合 `_ts` | 🟢 |
|
||||
| FR-R4 | complete 无超时无重试 | **confirm** | openai/anthropic_compat 仅 `connect_timeout(30s)`,complete() 同步路径无总 timeout,远端建连后挂起则永久 hang | 🔴 |
|
||||
| FR-R5 | findToolCall O(n²) | **confirm** | useAiEvents.ts:63 双层 for+find,4 处事件触发点 | 🟡 |
|
||||
| FR-P1 | dag O(V·E) + panic | **confirm(O(V·E)) / refute(panic)** | successors/predecessors iter().filter 全表扫;但 in_degree 已对全部 node_ids 初始化且 contains 过滤野节点,`unwrap()` 不会 panic | 🟠→🟡 |
|
||||
| FR-P2 | search_vector SELECT * | **confirm** | crud.rs:1183 `SELECT *` 拉 content/reasoning 大文本,无 LIMIT,Rust 算余弦 | 🟠 |
|
||||
| FR-P3 | 单连接 Mutex | **confirm** | db.rs:13 `Arc<Mutex<Connection>>`,每 CRUD spawn_blocking+blocking_lock 串行 | 🟠 |
|
||||
| FR-P4 | 500ms 轮询锁竞争 | **confirm(轮询) / refute(严重度)** | interval(500ms) 确实存在,但 std Mutex 无竞争纳秒级,"7200 次锁竞争"表述偏重 | 🟡 |
|
||||
| FR-P5 | build_system_prompt 全表扫 | **confirm** | prompt.rs:75 `list_active()` 全表 + take(20) + format!,每消息+每 agentic 轮触发;list_active 无 LIMIT | 🟠 |
|
||||
| FR-P6 | watch messages JSON.stringify | **confirm** | AiChat.vue:650 `{deep:true}` + 全量 stringify,snapshot 比对只省后续逻辑不省 stringify 本身 | 🟠 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构层(子代理产出,最大结构性风险)
|
||||
|
||||
**架构健康度:🟢 基本健康(7/10)**。依赖图无环、df-core 严格叶子、IPC 无 SQL 泄漏、前后端类型对齐度高、僵尸 crate 清理彻底(grep df_evolve/plugin/stages/task/traceability 零残留)。
|
||||
|
||||
**最大结构性风险**:🟠 **crate 层与 IPC 层职责边界双向错位**——领域逻辑(评估编排/晋升事务补偿/AI 扫描编排)沉淀在 IPC commands 层,而对应的 crate 模块(df-ideas::promotion、df-project::manager、df-ai::router/stream)反而是空壳或死代码。
|
||||
|
||||
### 3.1 依赖图与职责
|
||||
|
||||
- `df-nodes` 同时依赖 df-core/df-execute/df-workflow/df-ai 四个,是唯一多父汇聚点。结构正确(节点需调执行器/工作流/AI),非阻塞。
|
||||
- `df-workflow/src/conditions.rs:16-33` ConditionEngine 仅识别 true/false 字面量,其余默认 false(保守拒绝,安全)。文档承诺的 JSON Path/数值/逻辑组合语法全 TODO。todo.md:137 T-260614-11 已记。
|
||||
|
||||
### 3.2 空壳/死代码(5 处,建议清理或显式标注)
|
||||
|
||||
| # | 位置 | 判定 | 严重度 |
|
||||
|---|---|---|---|
|
||||
| ⑥ | `df-workflow/state.rs:87-100` set_waiting/set_skipped | 全仓零调用方,纯死代码 | 🟡 |
|
||||
| ⑦ | `df-workflow/eventbus.rs:43-46` emit_human_approval_request | 全仓零调用方(HumanNode 用 async send) | 🟡 |
|
||||
| ⑧ | `df-ai/router.rs` ModelRouter | **route() 全返回 default_model,lib.rs 未导出,零调用方** | 🟠 |
|
||||
| ⑨ | `df-ai/stream.rs` StreamCollector | 流式接收走 IPC 层 stream_recv.rs,此模块零调用方 | 🟠 |
|
||||
| ⑩ | `df-ideas/promotion.rs:45-91` IdeaPromoter/try_promote/do_promote | 全 TODO 占位,IPC 层 idea.rs 重写了晋升逻辑,crate 层零调用 | 🟠 |
|
||||
|
||||
> 注:coordinator.rs(df-ai)是 B 路线**有意占位**(文件头注释+文档对齐),非死代码,建议 run() 改 `bail!("未实现")` 更安全。
|
||||
|
||||
### 3.3 IPC 层 carry 领域逻辑(3 处)
|
||||
|
||||
- `idea.rs:184-231` evaluate_idea:评估编排 + 结果组装 + `scores*10` 缩放 + recommendation/action_items 扁平化映射,整段领域逻辑落 IPC。
|
||||
- `idea.rs:140-153` promote_idea:跨表事务补偿(回写失败→补偿删 project)落 IPC。
|
||||
- `project.rs:335-377` scan_project_with_ai:build_scan_prompt/parse_scan_result 纯函数级 AI 编排落 IPC。
|
||||
|
||||
后果:领域逻辑绑 Tauri State 无法 crate 级单测;AI 工具 create_* 与 IPC create_* 各写一份字段默认值(tool_registry.rs vs project.rs/task.rs),字段增删易漏改分叉。
|
||||
|
||||
### 3.4 前后端类型 drift(3 处注释错误)
|
||||
|
||||
- `types.ts:88` TaskStatus 注释 `review_ready/merged/abandoned` 三值后端枚举不存在(实际 todo/in_progress/in_review/testing/done/blocked/cancelled)。🟠
|
||||
- `types.ts:89` Priority 注释方向反(前端 `0=critical/3=low`,后端 df-core `Low=0/Critical=3`)。🟠
|
||||
- `types.ts:39` ProjectStatus 注释漏 testing/releasing。🟡
|
||||
- 类型本身 string 不阻断,但注释误导前端排序/展示逻辑写反。
|
||||
|
||||
### 3.5 错误处理双轨
|
||||
|
||||
- df-storage 用强类型 `df-core::Error`(thiserror),其余 crate(df-ai/df-ideas/df-project/df-workflow/df-execute)全 anyhow。`df-core::Error` 的 Workflow/Execution/AiProvider/Plugin 等变体零使用=死变体。🟠
|
||||
- IPC 层统一 `.to_string()` 压平成中文消息,前端无法区分 404/500。🟡(Tauri 限制下折中,可接受)
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流审批链复核(子代理产出)
|
||||
|
||||
### 4.1 死代码(confirm 3 处)
|
||||
set_waiting/set_skipped、emit_human_approval_request 零调用(见 §3.2 ⑥⑦)。set_cancelled 绕 is_legal 直接 insert(state.rs:106),todo 决策采"修法 B 绕而非纳入白名单",与全栈报告建议分歧,属待统一项。
|
||||
|
||||
### 4.2 回归审计(最近 5 提交无 P0/P1 回归)
|
||||
|
||||
- ✅ R6 human_node send 加 await(human_node.rs:42),测试 `request_is_emitted_to_bus` 验证。
|
||||
- ✅ select! 三分支(Response 双键过滤 / sleep_until 无漂移 / cancel 首 tick 丢弃)正确。
|
||||
- ✅ Lagged(n) continue 容忍正确。
|
||||
- ✅ decision 校验(human_node.rs:67 三态 + IPC workflow.rs:189 双层)。
|
||||
- ✅ executor Err 处理 is_cancelled guard(executor.rs:123-141)跳 set_failed,避开 Cancelled→Failed 非法 transition。
|
||||
- ✅ cancel 链端到端通(IPC→registry→共享 StateMachine→HumanNode 500ms 轮询→Err→executor)。
|
||||
|
||||
### 4.3 集成测试缺口(confirm)
|
||||
前端无 human DAG 入口(demoDag 仅 3 个 script 节点),现有测试用 mock `CancelSelfNode` 验证取消,**非真 HumanNode 路径**。select! cancel 分支 + interval tick 逻辑无端到端覆盖。todo.md:90 B-03b-R8 已记。
|
||||
|
||||
---
|
||||
|
||||
## 5. 新发现汇总(11 项,不在原报告/todo)
|
||||
|
||||
### 🟠 高优(建议本轮处理)
|
||||
|
||||
**新-① [src/composables/ai/useAiConversations.ts:108-126] FR-R1 闭环不完整:pendingToolCalls await 后无 token 比对**
|
||||
A→B 快切时,首个 await 已挡住 messages/activeConversationId 覆写,但 `aiApi.pendingToolCalls`(:108) 慢返回仍会覆写 B 的 `state.pendingApprovals`(:120)。建议补 `if (mySwitchId !== _latestSwitchId) return`。
|
||||
|
||||
**新-② [crates/df-ai/src/{openai,anthropic}_compat.rs] client builder 降级丢 connect_timeout**
|
||||
`Client::builder().connect_timeout(30s).build().unwrap_or_else(|_| Client::new())` — build 失败降级到 `Client::new()` 丢失连接阶段超时。建议降级分支显式 `Client::builder().build()`。
|
||||
|
||||
**新-③ [crates/df-workflow/src/executor.rs:130-135] 取消节点状态保 Cancelled 但仍发 NodeFailed 事件,语义双标**
|
||||
已取消节点状态机保 Cancelled,但事件总线仍 emit `NodeFailed { error: "人工审批被取消" }`。前端若按 NodeFailed 事件分支判断会误归类"失败"而非"取消"。建议加 `NodeCancelled` 事件 variant 或 NodeFailed 加 `cancelled: bool` 字段。
|
||||
|
||||
**新-④ [src-tauri/src/commands/workflow.rs:218-236] cancel_workflow_node 无节点终态前置校验**
|
||||
仅校验 execution_id 在注册表,不校验 node_id 状态。对已 Completed/Failed/Cancelled 节点调 set_cancelled 会静默覆盖终态(set_cancelled 绕 is_legal)。建议加 `match sm.get(&node_id) { Running|Waiting => {}, _ => Err }` 守卫。
|
||||
|
||||
**新-⑤ [前端无 human DAG 入口] 端到端 human 审批测试缺口**
|
||||
见 §4.3,CancelSelfNode 自取消与 HumanNode 走 select! cancel 分支是两条代码路径,后者无覆盖。
|
||||
|
||||
### 🟠 中优
|
||||
|
||||
**新-⑥ [df-ai/router.rs] ModelRouter 空壳死代码** — 见 §3.2 ⑧,route() 全返回 default_model,零调用方。
|
||||
|
||||
**新-⑦ [df-ai/stream.rs] StreamCollector 死代码** — 见 §3.2 ⑨,流式逻辑全在 IPC stream_recv.rs。
|
||||
|
||||
**新-⑧ [df-ideas/promotion.rs] IdeaPromoter 死代码** — 见 §3.2 ⑩,IPC 层重写了晋升。
|
||||
|
||||
**新-⑨ [types.ts:88-89] TaskStatus/Priority 注释 drift** — 见 §3.4,误导前端排序逻辑。
|
||||
|
||||
**新-⑩ [df-core/error.rs] 错误类型双轨,半数变体死代码** — 见 §3.5。
|
||||
|
||||
### 🟡 低优
|
||||
|
||||
**新-⑪ [useAiWindow.ts:79-95 + useAiStream.ts:18] 主窗口看门狗在分离窗口接管生成时仍跑**
|
||||
detach 时主窗口 streaming/generatingConvId 未清,看门狗续计 130s 后往主窗口 state 补幽灵错误消息,但用户已切走无实际危害,仅主窗口面板重开时看到一条幽灵错误。
|
||||
|
||||
---
|
||||
|
||||
## 6. 报告修正(refute 原 report 误判)
|
||||
|
||||
| 原报告项 | 修正 |
|
||||
|---|---|
|
||||
| FR-R2 看门狗多窗口互踩 | **应标 refute**:分离窗口独立 JS realm 不共享 state 单例,降级理由成立(todo 已记"评估维持",复核确认) |
|
||||
| FR-P1 in_degree.unwrap() panic 风险 | **refute**:in_degree 对全部 node_ids 初始化 + contains 过滤野节点,unwrap 不 panic |
|
||||
| FR-P4 "7200 次锁竞争" | 严重度偏高:std Mutex 无竞争纳秒级,实为微秒级开销;保留"500ms 轮询模式不佳"批评,建议改 broadcast/Notify |
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议进 todo 的项(去重后)
|
||||
|
||||
> 已在 todo 的不重复;下列为本复核**新产出**,待用户确认进看板。
|
||||
|
||||
| 建议 ID | 对应 | 优先级 |
|
||||
|---|---|---|
|
||||
| 复核-新① FR-R1 pendingToolCalls 闭环 | useAiConversations.ts:108 补 token 比对 | P1 |
|
||||
| 复核-新② client 降级丢 timeout | openai/anthropic_compat 降级分支显式重建 | P2 |
|
||||
| 复核-新③ 取消事件语义双标 | 加 NodeCancelled variant 或 cancelled 字段 | P2 |
|
||||
| 复核-新④ cancel 无终态校验 | workflow.rs:223 加状态前置守卫 | P2 |
|
||||
| 复核-新⑤ human 端到端测试缺口 | (= todo B-03b-R8,已记,提优先级) | P1 |
|
||||
| 复核-新⑥⑦⑧ 死代码三连 | ModelRouter/StreamCollector/IdeaPromoter 删或标注 | P2 |
|
||||
| 复核-新⑨ types.ts 注释 drift | TaskStatus/Priority 注释对齐 df-core 枚举 | P2 |
|
||||
| 复核-新⑩ 错误类型双轨 | df-core::Error 瘦身或各 crate 收敛 | P2 长期 |
|
||||
| FR-R4 complete 无 timeout | (已在 todo,复核确认 🔴,建议提 P1) | P1 |
|
||||
| FR-S1 api_key 明文 | (已在 todo P0,复核确认未动) | P0 |
|
||||
|
||||
**优先处理 3 件**(影响实际行为):
|
||||
1. **复核-新①** FR-R1 闭环缺口(审批卡片可见性错配,一行修)
|
||||
2. **复核-新③④** 取消事件语义 + 终态校验(审批链语义正确性)
|
||||
3. **FR-R4** complete 无 timeout(一次网络抖动挂死整轮对话,提 P1)
|
||||
|
||||
---
|
||||
|
||||
> 本会话**只做代码审查 + 文档记录**,不含代码变更。实际修复另起会话。
|
||||
147
docs/05-代码审查/架构审查-2026-06-15.md
Normal file
147
docs/05-代码审查/架构审查-2026-06-15.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 架构审查报告(2026-06-15)
|
||||
|
||||
> 范围:Rust workspace(8 crate + src-tauri 汇聚层,14586 行)+ 前端(Vue3+Pinia,11830 行),共约 26k 行。
|
||||
> 方法:2 个 general-purpose 子代理并行审「Rust crate 架构」「前端架构」,主代理直读 df-core/df-execute/coordinator/conditions 核实空壳与抽象层,并对 3 个 🔴 删代码/可见断言做 grep 复验(全部坐实)。
|
||||
> 去重:与 [全栈代码审查报告-2026-06-14.md](全栈代码审查报告-2026-06-14.md)(bug/性能 FR-*)、[架构与缺陷复核报告-2026-06-14.md](架构与缺陷复核报告-2026-06-14.md)(复核+回归审计)、[aichat审查报告-2026-06-14.md](../02-架构设计/aichat审查报告-2026-06-14.md)(AR 系列)互补——**本报告只记架构层面**(模块边界/依赖方向/抽象层次/扩展性/状态管理/技术债),不重复 bug、性能、aichat 专项项。
|
||||
> 性质:**dry — 仅审查 + 文档,不改代码**。
|
||||
|
||||
---
|
||||
|
||||
## §1 架构总览
|
||||
|
||||
### 1.1 Crate 地图(Rust)
|
||||
|
||||
| Crate | 职责 | 内部依赖 | 行数 |
|
||||
|-------|------|---------|------|
|
||||
| **df-core** | 纯类型定义(ID 别名/状态枚举/new_id)+ error + events | 无(根) | 429 |
|
||||
| **df-storage** | SQLite 持久化(11 个 Repo + models + migrations) | df-core | 2522 |
|
||||
| **df-workflow** | 工作流引擎(DAG/Node trait/Executor/Registry/StateMachine/EventBus/Conditions) | df-core | 1120 |
|
||||
| **df-ai** | AI 编排(LlmProvider trait + OpenAI/Anthropic compat + 工具注册 + 上下文 + 流式) | df-core | 2588 |
|
||||
| **df-nodes** | 3 个内置节点(AiNode/ScriptNode/HumanNode) | df-core + df-workflow + df-ai + df-execute | 773 |
|
||||
| **df-execute** | 仅一个 `shell::execute` 函数(带 TODO) | df-core | 76 |
|
||||
| **df-ideas** | 想法池领域(capture/scoring/adversarial 启发式/promotion 占位) | df-core | 759 |
|
||||
| **df-project** | 项目领域(create_from_idea / scan 技术栈探测) | df-core | 486 |
|
||||
| **src-tauri** | 汇聚层:47 个 IPC 命令 + AppState + 跨 crate 业务编排 | 全部 8 crate | 5711 |
|
||||
|
||||
**依赖图(无环,星型收敛)**:
|
||||
```
|
||||
df-core (零依赖根)
|
||||
/ / | \ \ \
|
||||
storage workflow ai ideas project execute
|
||||
| | |
|
||||
└───┴── nodes (唯一多依赖者)
|
||||
|
|
||||
src-tauri (依赖全部 8 crate)
|
||||
```
|
||||
除 df-nodes 依赖 df-workflow+df-ai 外,**6 个 crate 间零横向依赖**——业务域 crate(ideas/project)不依赖持久化层(storage),靠 src-tauri 在 IPC 层组合。
|
||||
|
||||
### 1.2 前端分层地图
|
||||
|
||||
| 层 | 关键文件 | 职责 | 备注 |
|
||||
|----|---------|------|------|
|
||||
| stores | project(337) / ai(84) / knowledge(157) / appSettings(139) / settings(99) | 领域状态 | project 是上帝 store;settings 死代码 |
|
||||
| composables/ai | 6 文件(events/stream/send/conversations/window/panel) | AI 业务逻辑 | 为拆而拆,循环依赖 |
|
||||
| views | 9 个(4875 行) | 页面 | Ideas 929 / Settings 1032 偏重 |
|
||||
| components | AiChat(1359) / ToolCard(760) 等 | UI 组件 | AiChat 巨型 |
|
||||
| api | 8 文件(736 行) | Tauri invoke 封装 | 分层清晰 |
|
||||
|
||||
---
|
||||
|
||||
## §2 架构级问题清单
|
||||
|
||||
### 🔴 结构风险(6)
|
||||
|
||||
#### ① [df-execute] 空壳 crate + src-tauri 死依赖 — 应合并删除
|
||||
**现状**:`crates/df-execute/src/lib.rs:7` 仅 `pub mod shell;`;`shell.rs` 全文 73 行只 1 个 `execute` 函数且带 `TODO: 完整实现`(:33)。全仓库唯一调用方是 `df-nodes/src/script_node.rs:34,42`。`src-tauri/Cargo.toml:32` 声明 `df-execute` 依赖,但 grep 确认 `src-tauri/src/` **零处引用 `df_execute`**(已复验)。
|
||||
**架构影响**:一个 76 行/2 文件、仅暴露 1 函数的 crate,是过度拆分典型。其存在理由("执行运行时",原含 docker/git_ops/ssh)重构删除后已不成立。src-tauri 那条依赖是残留死代码。
|
||||
**建议**:合并 df-execute 进 df-nodes(作 `script_node` 内部模块或 `df-nodes::shell`),删该 crate + `src-tauri/Cargo.toml:32` 死依赖。净减 1 crate + 1 死依赖。
|
||||
|
||||
#### ② [stores/settings.ts] mock 上帝 store 死代码 — 零消费者
|
||||
**现状**:`stores/settings.ts:32-75` 持硬编码 mock 数据(Anthropic/Zhipu/DeepSeek provider、5 连接、theme/dataDir),全写死展示字符串。grep 确认仅 `stores/index.ts:6` 导出,**全 src 零组件 import `useSettingsStore`**(已复验)。真实 provider CRUD 走 `api/ai.ts` + Settings.vue 本地 ref,真实偏好走 appSettings。
|
||||
**架构影响**:双轨幻觉——同名 `settings` 一真(appSettings/SQLite)一假(settings.ts/mock),后者 `AIProvider`/`Connection` interface 还被 re-export,新人易误用。与「做减法」画像直接冲突。
|
||||
**建议**:删 `stores/settings.ts` + `stores/index.ts:6-7` 导出;interface 若有外部依赖迁 `api/types.ts`。零风险减法。
|
||||
|
||||
#### ③ [stores/project.ts] 上帝 store(四领域强耦合 + 越层 invoke)
|
||||
**现状**:`project.ts` 同时管 projects/tasks/ideas/workflowExecutions/liveEvents/pendingApproval 六类 state(:9-25),方法覆盖四领域 CRUD + 事件监听 + 审批。`project.ts:252` `invoke('approve_human_approval')`、`:271` `invoke('cancel_workflow_node')` **直接调 invoke 绕过 api 层**——全项目仅此一处 store 越层(其余 50+ invoke 全在 `api/*.ts`)。
|
||||
**架构影响**:违反单一职责;workflow 审批逻辑寄生 project store;分层不彻底。
|
||||
**建议**:拆 project/task/idea/workflow 四 store(至少 workflow+审批独立);`approve_human_approval`/`cancel_workflow_node` 沉 `api/workflow.ts`。
|
||||
|
||||
#### ④ [composables/ai/] 6 文件为拆而拆 — events↔stream 循环依赖
|
||||
**现状**:6 个 composable 全部 `import { state } from stores/ai`(共享单例),且互相模块级 import 形成环:`useAiEvents.ts:18` → useAiStream(reset);`useAiStream.ts:12` → useAiEvents(nextMsgId) ← **双向**;`useAiSend`→useAiStream+useAiEvents;`useAiConversations`→useAiEvents+useAiPanel;`useAiWindow`→其余三者。`useAiStore()` 只是 `...useAiXxx()` 展开,每个 composable 恰被一处展开、零外部复用。
|
||||
**架构影响**:拆分未降耦合,反把一个内聚状态机(流式事件→看门狗→队列→对话)打散到 6 文件,靠 ES module 循环 import 维持。events↔stream 双向依赖靠函数提升侥幸可运行,属脆弱结构。
|
||||
**建议**:要么承认是单 store 内聚逻辑、合回 `stores/ai.ts`(最简);要么把 `nextMsgId`/`state` 等共享提独立 `aiShared.ts` 破环。
|
||||
|
||||
#### ⑤ [App.vue:273] `/decisions` 路由死链 — 用户可见坏链
|
||||
**现状**:`App.vue:273` secondaryNav 含 `{ path: '/decisions', label: 'nav.decisions' }`,但 `router/index.ts` 无此路由(已复验,仅 i18n 文案存在)。点击落空白/重定向。
|
||||
**架构影响**:导航与路由不同步,用户可感。
|
||||
**建议**:补路由+视图,或从 secondaryNav 删该项(务实减法)。
|
||||
|
||||
#### ⑥ [前端] 模块级全局态散落 + 多窗口 localStorage 手搓同步
|
||||
**现状**:跨组件状态大量用模块级 `let`/`const` 而非 store——`useAiEvents.ts:27-32` 四个、`useAiStream.ts:18` watchdog、`useAiWindow.ts:17-18`、`useAiConversations.ts:44` switchId、`stores/project.ts:27,329`。多窗口(主窗口+ai-detached)各独立 JS realm,模块级单例**不跨窗口共享**;`useAiWindow.ts:30-31` 用 `localStorage.setItem('df-ai-gen'/'df-ai-text')` 手动快照,分离窗口 `resumeInDetached`(:82-95) 读回——手搓跨窗口 IPC,仅覆盖 currentText 两字段。
|
||||
**架构影响**:模块级 state 单窗口内是隐式全局,调试难追踪;「双窗口同时操作同一对话」竞态靠 localStorage 单字段兜不住。
|
||||
**建议**:跨窗口状态显式走 Tauri event(emit/listen)而非 localStorage 快照;模块级私有态集中文档化。
|
||||
|
||||
### 🟡 改进(6)
|
||||
|
||||
#### ⑦ [df-core] 名不副实——类型库而非核心
|
||||
`lib.rs:5` 自述"核心类型定义",但 `types.rs` 全是 `pub type XId = String` 别名 + 状态枚举 + `new_id()`,**无 trait/无抽象/无行为契约**。真正核心 trait(`Node` 在 df-workflow、`LlmProvider` 在 df-ai)各自下沉到功能 crate。命名误导读者对其角色预期。**建议**:改名 `df-types`,或注释明确"只放跨 crate 共享数据类型,不放 trait"。低优先。
|
||||
|
||||
#### ⑧ [src-tauri] IPC 层成事实业务编排层(5711 行)
|
||||
crate 故意解耦(ideas 不依赖 project/storage),代价是「两 crate 怎么协作」无处安放,全部上浮 src-tauri。典型 `commands/idea.rs:96-161` `promote_idea`:取 IdeaRecord → 调 df_project 构造 Project → 插 df_storage → 补偿删除 → 回写 ideas,其中 `record_to_idea`(:252)、分数 `*10` 缩放(:224)、JSON 组装(:208) 全在 IPC 文件。`df-ideas/promotion.rs:82` 的 `do_promote` 反而是空壳——领域逻辑被分置两处,领域 crate 残缺。src-tauri(5711) > 最大 crate(df-ai 2588),IPC 不是薄转发而是应用服务层。**建议**:中期引入 `df-app` 或 src-tauri 内 `services/` 收编跨 crate 编排,让 IPC 回归薄转发;至少把 `record_to_idea` 这类纯映射下沉回 df-ideas。技术债,不紧急。
|
||||
|
||||
#### ⑨ [src-tauri/commands/ai] AI agent loop 在 IPC 层而非 df-ai
|
||||
`commands/ai/` 11 文件 3438 行,含 `agentic.rs`(292) ReAct 主循环、`audit.rs`(342) 工具审计、`knowledge_inject.rs`(557) 提炼。df-ai 提供 trait + provider 实现,但「怎么编排多轮工具调用」这层智能在 src-tauri,使 df-ai 退化为「LLM 调用 SDK」。与 aichat 审查「单链 ReAct、coordinator 空壳」一致——智能层无处安放暂栖 IPC。**建议**:观察项,B 路线(多 agent 协作)立项时一并从 IPC 抽出,现不宜妄动。
|
||||
|
||||
#### ⑩ [views] 3 个 view 绕 store 调 api
|
||||
`Projects.vue:160,163,187`(scanStack/checkBinding/scanWithAi)+ `ProjectDetail.vue:211` 混用 store 与 api;`Settings.vue:374,387` 完全不走 store(provider CRUD 直接 aiApi + 本地 ref),自带 toast(:390) 与 App.vue 全局 toast 重复。分层不一致——同性质「读后端数据」有的进 store 有的留 view 本地。**建议**:scan/checkBinding 预览型只读可接受;Settings provider 列表应进 store(与 `stores/ai.ts:39` providers 合一,避免双份真源);toast 二选一。
|
||||
|
||||
#### ⑪ [stores/index.ts] barrel 漏导 useAiStore
|
||||
`stores/index.ts` 导出 project/knowledge/settings/appSettings,**漏 `useAiStore`**(`stores/ai.ts:73`)。故 App.vue/AiChat.vue/AiDetached.vue 全部直连 `stores/ai` 文件绕过 barrel。barrel 形同虚设,ai store 成「特殊公民」。**建议**:补 `export { useAiStore } from './ai'`。
|
||||
|
||||
#### ⑫ [api/types.ts] 前后端类型契约手写 + `as any` 绕过
|
||||
`api/types.ts:1` 注释"与 Rust Record 严格对齐"但纯手写无代码生成。漂移风险点:`stores/project.ts:234` `payload.event as unknown as HumanApprovalRequest`(字段存在性无校验);`:73,145,182` `(state.xxx[idx] as any)[field]=value`(field 是 string 无约束);`useAiConversations.ts:71` 历史消息全程 `(m:any)`、`:81` JSON.parse 无 try-catch;Settings/ToolCard 多处 `as any`。契约脆弱集中在「动态字段更新」和「事件 payload 强转」。**建议**:动态 field 改 keyof 约束或具名方法;payload 强转改类型守卫;长期考虑 ts-rs 代码生成。
|
||||
|
||||
### ⚪ 观察(4)
|
||||
|
||||
**⑬ [df-ai/coordinator.rs:3 等] 空壳是 B 路线有意占位** — `coordinator.rs:3` 注释 `⚠ B 路线占位...勿删`;`df-workflow/conditions.rs:4` `TODO: 实现完整条件表达式`(当前只识 true/false 字面量,保守拒绝=安全);`df-ai/router.rs:42` ModelRouter 全返 default_model(no-op);`df-ideas/promotion.rs:67` SemiAuto TODO。coordinator 有明确归属保留;conditions/router 若长期无调用方依赖其"未来能力",可删减负(YAGNI)。决策性。
|
||||
|
||||
**⑭ [AiSession] 单例 + generating bool 锁死多会话并发** — `state.rs:164` `ai_session: Arc<Mutex<AiSession>>`(全局唯一),`commands.rs:45,48` `generating` bool 充当全局对话互斥锁,`state.rs:93-96` 注释自承认"per_conv 是应用级单一信号量非 map,未来多对话并发需改 HashMap"。同一时刻全局只能有一个 AI 对话生成。是有意识的设计取舍,符合单链 ReAct 现状,但是异步审批/多会话(见 memory devflow-async-approval-concept)的结构前置障碍。B 路线范畴,现不阻塞。
|
||||
|
||||
**⑮ [AppState] God Struct** — `state.rs:135-185` 含 15 个 Repo + event_bus + registry + ai_tools + ai_session + knowledge_config + llm_concurrency + workflow_state_registry。所有 IPC 共享巨型 State,无边界隔离。桌面应用规模可接受,若 IPC 做 service 化(⑧)State 可随之分组。
|
||||
|
||||
**⑯ [前端] IPC 命名风格不统一** — 命令名 snake_case(`list_projects`)/`ai_`前缀(`ai_chat_send`)混存;参数 camelCase(`convId`)vs snake(`projectId`)混。新增 IPC 需改 api+store+view 约 3 处,散弹度可接受。**建议**:命令名统一 snake_case,参数统一 camelCase(Tauri 自动转)。
|
||||
|
||||
---
|
||||
|
||||
## §3 亮点
|
||||
|
||||
- **扩展点单点注册,抽象得当**:新增工作流节点改 2 处(df-nodes 加 impl Node + `state.rs:225 build_registry` 注册一行);新增 AI 工具改 1 处(`tool_registry.rs` register);新增 provider 改 1 处(`df-ai/lib.rs:21 build_provider` match)。Registry/工厂用最朴素 HashMap,没搞插件动态加载那套过度抽象。**架构最健康的一面**。
|
||||
- **依赖图无环、星型收敛**:crate 间零横向耦合,编译/测试隔离好,6 crate 不互相牵连。
|
||||
- **空壳占位有显式标注**:coordinator 的 B 路线注释、conditions 的保守拒绝设计,非失控,是有意识的路线占位。
|
||||
- **前端减法实践**:`useConfirm`(抽 4 视图重复)、`constants/project`(统一状态映射)、`utils/time`(根治 Invalid Date)——好的减法。
|
||||
- **i18n/路由/api 分层清晰**:i18n 按模块分文件 zh/en 对称,路由全懒加载带 meta,api 层 8 文件按领域分 + barrel 统一导出(除 ③ project store 越层、⑪ ai store 漏导两处瑕疵)。
|
||||
- **工程素养细节**:LlmConcurrency 双层 Semaphore、promote_idea 补偿事务删除、FR-S7/S8 安全防护——显工程功底。
|
||||
|
||||
---
|
||||
|
||||
## §4 总评
|
||||
|
||||
**架构成熟度:中等偏清晰(Rust 7/10,前端 6/10)**。
|
||||
|
||||
**核心张力(1 个真问题 + 几个取舍)**:
|
||||
- **真问题**:df-execute 空壳化 + src-tauri 死依赖(①),应做减法合并。这是唯一违背「做减法」画像的硬伤。
|
||||
- **核心张力**:crate 刻意解耦 → 跨 crate 编排全部上浮 src-tauri → IPC 层 5711 行成事实业务层(⑧⑨)。这是「高内聚低耦合」的代价,当前规模可忍,是中期技术债。
|
||||
- **前端回潮**:AI 模块过度拆分(④)、project 上帝 store(③)、settings 死代码(②)三处结构性债。
|
||||
|
||||
**务实画像契合度**:整体偏「做减法、反过度抽象」——没为插件化搞动态加载、没给 df-core 塞虚 trait、Registry 用朴素 HashMap。违背处集中在 df-execute 存在(应删)和 src-tauri 膨胀(编排无处去)+ 前端 AI 模块拆分。
|
||||
|
||||
**行动建议(按 ROI)**:
|
||||
| 优先级 | 项 | 动作 | 成本 |
|
||||
|--------|-----|------|------|
|
||||
| 立即 | ② | 删 stores/settings.ts 死代码 | 零风险 |
|
||||
| 立即 | ⑤ | /decisions 死链(补路由 or 删 nav) | 零风险 |
|
||||
| 短期 | ① | df-execute 合并进 df-nodes + 删死依赖 | 低(1 crate+1 依赖) |
|
||||
| 短期 | ⑪ | stores/index.ts 补 useAiStore 导出 | 极低 |
|
||||
| 中期 | ③ | project.ts 拆分 + 审批沉 api 层 | 中 |
|
||||
| 中期 | ④ | ai composables 合回 or 破环 | 中 |
|
||||
| B 路线 | ⑧⑨⑭ | IPC 编排层抽取 / agent loop 下沉 / AiSession 多会话 | 大(随 B 路线) |
|
||||
129
docs/05-代码审查/自研块级memo流式渲染审查-2026-06-15.md
Normal file
129
docs/05-代码审查/自研块级memo流式渲染审查-2026-06-15.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 自研块级 memo 流式 Markdown 渲染 代码审查(2026-06-15)
|
||||
|
||||
> 范围:`src/components/AiChat.vue` ARC-260615-08 实施 diff —— 块级 memo 流式渲染(splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/scheduleStreamParse/renderContent + watch/onBeforeUnmount)+ confirm 抽取(CR-260615-02)。约 ~90 行改动。
|
||||
> 背景:决策转向(同日复盘)—— markstream-vue 试装弃用(样式 100% 还原成本高且脆),改自研块级 memo(方案 B 增强版)。详见 [aichat流式Markdown渲染调研-2026-06-15.md](../02-架构设计/aichat流式Markdown渲染调研-2026-06-15.md) §5。
|
||||
> 性质:**dry — 仅走查 + 文档,不改代码**(本会话职责:走查/整理/建待办,见 memory session-role-diagnose-only)。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 必须修复(0)
|
||||
|
||||
无。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 建议改进(3)
|
||||
|
||||
### ① [AiChat.vue splitBlocks] 手写正则切块与 marked 语法不一致 → 改用 marked.lexer()
|
||||
|
||||
**改什么**:`fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g` 两个不一致点:
|
||||
- 不要求 ``` 在行首 → 行中裸 ```(如文本解释 markdown 语法)被误当围栏切入;marked 要求行首(≤3 空格缩进)。
|
||||
- 固定匹配 3 backtick → LLM 展示嵌套代码用 4+ backtick 围栏(````````)时按 3 切错;marked 按围栏 backtick 数判定。
|
||||
|
||||
前块缓存(parseBlock)会**固化错误 html**——文本稳定后 splitBlocks 切错的块被缓存,marked 实际解析与之不符。决策备注「借鉴机制 2」,但机制 2 原生做法是 `marked.lexer()` 切块,实现用了手写正则偏离。
|
||||
|
||||
**怎么改**:用 marked.lexer 切块,与 marked 解析天然一致,零切错风险,零额外成本(marked 已加载):
|
||||
|
||||
```diff
|
||||
- /// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
|
||||
- /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||
- function splitBlocks(text: string): string[] {
|
||||
- const blocks: string[] = []
|
||||
- const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g
|
||||
- let last = 0
|
||||
- let m: RegExpExecArray | null
|
||||
- while ((m = fenceRe.exec(text)) !== null) {
|
||||
- if (m.index > last) {
|
||||
- for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
||||
- }
|
||||
- blocks.push(m[0])
|
||||
- last = m.index + m[0].length
|
||||
- }
|
||||
- if (last < text.length) {
|
||||
- for (const b of text.slice(last).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
||||
- }
|
||||
- return blocks.length ? blocks : [text]
|
||||
- }
|
||||
+ /// 切块:用 marked.lexer 切块(机制2原生做法),与 marked 解析一致,零切错风险。
|
||||
+ /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||
+ function splitBlocks(text: string): string[] {
|
||||
+ const tokens = _marked!.lexer(text)
|
||||
+ const blocks = tokens.map(t => t.raw).filter(Boolean)
|
||||
+ return blocks.length ? blocks : [text]
|
||||
+ }
|
||||
```
|
||||
|
||||
> 注:Vercel AI SDK 官方 cookbook `parseMarkdownIntoBlocks` 正是 `marked.lexer(md).map(t => t.raw)`。
|
||||
|
||||
### ② [parseBlock/parseBlockNoCache] DRY — parse 核心逻辑重复
|
||||
|
||||
**改什么**:`_purify!.sanitize(_marked!.parse(block) as string)` 在 parseBlock 和 parseBlockNoCache 各写一遍。
|
||||
|
||||
**怎么改**:parseBlock 内部调 parseBlockNoCache:
|
||||
|
||||
```diff
|
||||
function parseBlock(block: string): string {
|
||||
const cached = _blockCache.get(block)
|
||||
if (cached !== undefined) return cached
|
||||
- const html = _purify!.sanitize(_marked!.parse(block) as string)
|
||||
+ const html = parseBlockNoCache(block)
|
||||
if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()
|
||||
_blockCache.set(block, html)
|
||||
return html
|
||||
}
|
||||
```
|
||||
|
||||
### ③ [loadMarkdown] marked 就绪后流式不主动重算 → 首屏 marked 慢时末段纯文本
|
||||
|
||||
**改什么**:流式中 marked 异步加载完成(mdReady 翻转)后,streamingHtml 不会自动重算 —— scheduleStreamParse 只在 `currentText` 变化时触发。若 marked 在流式结束前就绪且无新 delta 到达,最后一段停留在 escapeHtml 纯文本(renderStreamingMd 的 `!mdReady` 兜底分支产物)。
|
||||
|
||||
**怎么改**:loadMarkdown 成功后若正在流式,主动触发一次:
|
||||
|
||||
```diff
|
||||
_purify = dp.default
|
||||
_mdCache.clear()
|
||||
mdReady.value = true
|
||||
+ // marked 就绪后若正在流式,主动重算(防首屏 marked 慢致末段停留纯文本)
|
||||
+ if (store.state.streaming && store.state.currentText) scheduleStreamParse(store.state.currentText)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 可选优化(2)
|
||||
|
||||
### ④ [parseBlock _blockCache] 超 limit 整体 clear 粗暴
|
||||
|
||||
`if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()` —— 清后下一帧所有前块全重 parse,可能瞬时卡顿。300 limit 对单条回复够(块数远不到),跨多条回复累积才触发,影响小。可改删最早一条做简易 LRU:`_blockCache.delete(_blockCache.keys().next().value)`。
|
||||
|
||||
### ⑤ [renderMd/renderStreamingMd 兜底] escapeHtml 重复
|
||||
|
||||
`escapeHtml(text).replace(/\n/g, '<br>')` 两处重复,可抽 `escapeFallback(text)`。一行重复,收益轻。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 亮点
|
||||
|
||||
- **退役 AR-1 纯文本短路**:流式全程有格式(标题/列表/代码块),D 级流式性能,零新依赖零样式对接(决策转向 markstream→自研的核心理由:保留原 `.ai-md` 样式 > 虚拟窗口/shiki 高级能力)。
|
||||
- **末块不缓存处理未闭合 token**:`parseBlockNoCache` 每次重 parse 末块,正确处理流式未闭合围栏/半截段落;前块缓存命中 O(全文)→O(末块)。
|
||||
- **rAF 节流 + 块级 memo 组合**:业界主流机制(Vercel AI SDK 做法),60fps 封顶防主线程阻塞掉帧。
|
||||
- **watch streaming 翻转清理时序正确**:结束 cancelAnimationFrame + 清 streamingHtml/lastStreamText,回 renderMd 走 final marked + 整段缓存;onBeforeUnmount 清 rAF 防 leak。
|
||||
- **confirm 顺带抽取(CR-260615-02)**:第五份 confirm 样板迁 useConfirm,DRY 收口(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各一份)。
|
||||
- **决策转向有完整留痕**:试装 markstream-vue 弃用 → 自研块级 memo,调研文档 §5 复盘记录含反转理由(「不要高级能力、要原样式」前提下自研最优)。
|
||||
|
||||
---
|
||||
|
||||
## 📊 摘要
|
||||
|
||||
| # | 等级 | 文件:行 | 修改内容 |
|
||||
|---|------|---------|----------|
|
||||
| ① | 🟡 | AiChat.vue splitBlocks | 手写正则切块改 marked.lexer()(与 marked 一致,防切错固化错误 html) |
|
||||
| ② | 🟡 | AiChat.vue parseBlock | parseBlock 内部调 parseBlockNoCache(DRY) |
|
||||
| ③ | 🟡 | AiChat.vue loadMarkdown | marked 就绪后若流式中主动 scheduleStreamParse(防末段纯文本) |
|
||||
| ④ | ⚪ | AiChat.vue _blockCache | clear 改删最早一条(简易 LRU) |
|
||||
| ⑤ | ⚪ | AiChat.vue 兜底 | escapeFallback 抽公共(DRY) |
|
||||
|
||||
**总计**:🔴0 🟡3 ⚪2
|
||||
|
||||
**总体评价**:块级 memo 实现方向正确(机制 2 + rAF + 末块重 parse + 退役 AR-1 纯文本短路),核心建议 ① 改 lexer 消除「手写切块与 marked 解析不一致」的固化风险——这是唯一偏离决策原意(机制 2 原生 = lexer)之处。
|
||||
|
||||
**质量评级**:良(改 ①②③ 达优)
|
||||
201
docs/05-代码审查/近期改动代码审查-2026-06-15.md
Normal file
201
docs/05-代码审查/近期改动代码审查-2026-06-15.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 近期改动代码审查报告(2026-06-15)
|
||||
|
||||
> 范围:工作区未提交 Rust(FR-S1 密钥管理 / FR-S7 写入防护 / FR-S8 symlink 防护)+ 近 5 个提交(Rust 后端 7 文件 + 前端 12 文件),约 770 行。
|
||||
> 方法:主代理深读工作区安全改动(secret.rs / tool_registry.rs / commands.rs / lib.rs,含 resolve_workspace_path 双层校验、write_file 原子写、list_dir symlink 处理)+ 2 个 general-purpose 子代理并行审「近 5 提交 Rust 后端」「近 5 提交前端」,主代理对三路结果核实去重并补查 keyring 闭环。
|
||||
> 去重:与 [全栈代码审查报告-2026-06-14.md](全栈代码审查报告-2026-06-14.md) / [架构与缺陷复核报告-2026-06-14.md](架构与缺陷复核报告-2026-06-14.md) 范围互补——前两份是 06-14 全栈基线审查,本轮针对 **06-14 之后的提交(FR-S 安全修复、confirm 抽取、dag 拓扑 O(V+E)、流式健壮性)+ 工作区未提交**。aichat AR 系列、FR-C/S/R 已修项不重复,仅审本轮代码本身引入或暴露的新问题。
|
||||
> 互斥:本报告 §①(FR-S1 keyring 清理闭环)与 [aichat审查报告](../02-架构设计/aichat审查报告-2026-06-14.md) 的 FR-S1 条目同源(密钥迁移至 keyring),本轮发现其删除路径闭环缺失。
|
||||
|
||||
---
|
||||
|
||||
## 审查范围
|
||||
|
||||
**工作区未提交(Rust)**:
|
||||
- `src-tauri/src/commands/ai/secret.rs`(新文件,FR-S1 keyring 管理)
|
||||
- `src-tauri/src/commands/ai/tool_registry.rs`(write_file 原子写+.bak / read_file 二进制降级+limit 上限 / list_dir symlink 防护)
|
||||
- `src-tauri/src/commands/ai/commands.rs`(ai_list_providers mask / ai_save_provider 密钥转 keyring)
|
||||
- `src-tauri/src/commands/ai/{agentic,knowledge_inject,title,mod}.rs`、`commands/project.rs`、`lib.rs`(resolve_provider_secret 接入 + 启动迁移)
|
||||
|
||||
**近 5 提交(commit 4b5f096 → f58743e)**:
|
||||
- Rust:`df-ai/{anthropic_compat,openai_compat,context}.rs`、`df-workflow/{dag,executor}.rs`、`df-storage/crud.rs`、`audit.rs`
|
||||
- 前端:`ToolCard.vue`、`AiChat.vue`、`useAiEvents.ts`、`useConfirm.ts`、4 个 view、2 个 i18n
|
||||
|
||||
---
|
||||
|
||||
## 🔴 必须修复(1)
|
||||
|
||||
### ① [commands.rs:390-401] 删 provider 不清理 keyring — FR-S1 密钥残留泄漏
|
||||
|
||||
**现状**:FR-S1 把密钥迁到 OS keyring(`devflow-ai-provider/<id>`),DB `api_key` 列置空。`secret.rs:44` 已定义 `delete_provider_secret`,但 `ai_delete_provider` 只调 `state.ai_providers.delete`,**keyring entry 永久残留**。
|
||||
|
||||
**问题**:用户「删除 provider」本意含撤销密钥,残留密钥仍可被同用户下任意进程读取;迁移后若同 id 被复用(new_id 为 uuid 概率极低,但外部指定 id 场景存在),旧密钥复活。FR-S1 安全特性闭环缺一环。
|
||||
|
||||
**修法**:删 DB 后清理 keyring(失败仅日志不阻断——DB 已删,残留 keyring 无消费方)。
|
||||
|
||||
```diff
|
||||
pub async fn ai_delete_provider(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?;
|
||||
+ // FR-S1:清理 keyring 残留密钥(失败仅日志,不阻断删除——DB 已删,残留 keyring 无消费方)
|
||||
+ if let Err(e) = super::secret::delete_provider_secret(&provider_id) {
|
||||
+ tracing::warn!("[FR-S1] 删除 provider 后清理 keyring 失败 {}: {}", provider_id, e);
|
||||
+ }
|
||||
let mut session = state.ai_session.lock().await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 建议改进(7)
|
||||
|
||||
### ② [audit.rs:50] `_` 把 DB Err 误报为「项目已不存在」
|
||||
|
||||
**现状**:`get_by_id` 的 `Err`(DB 故障/锁/连接断)与 `Ok(None)`(真不存在)被 `_` 合并,DB 出错时用户看到误导性「项目没了」而非错误。
|
||||
|
||||
**修法**:分三臂,`Err` 打日志回退裸 id。
|
||||
|
||||
```diff
|
||||
- match repo.get_by_id(id).await {
|
||||
- Ok(Some(p)) => format!("「{}」(id={})", p.name, id),
|
||||
- _ => format!("(项目已不存在, id={})", id),
|
||||
- }
|
||||
+ match repo.get_by_id(id).await {
|
||||
+ Ok(Some(p)) => format!("「{}」(id={})", p.name, id),
|
||||
+ Ok(None) => format!("(项目已不存在, id={})", id),
|
||||
+ Err(e) => { tracing::warn!(%id, error=%e, "查项目标签失败"); format!("(id={})", id) }
|
||||
+ }
|
||||
```
|
||||
|
||||
### ③ [openai_compat.rs:414] `anyhow::anyhow!(e)` 丢 error source 链
|
||||
|
||||
**现状**:非超时分支用 `anyhow!(e)` 把 `reqwest::Error` 整体塞进 message,丢失 `#[source]` 因果链(`?` 本会保留)。注释强调「不静默挂」,此处反而降低可追溯性。
|
||||
|
||||
**修法**:保留 source。
|
||||
|
||||
```diff
|
||||
- } else {
|
||||
- anyhow::anyhow!(e)
|
||||
- }
|
||||
+ } else {
|
||||
+ anyhow::Error::from(e)
|
||||
+ }
|
||||
```
|
||||
|
||||
### ④ [AiChat.vue:371-387] 第五份 confirm 逻辑未迁移 useConfirm
|
||||
|
||||
**现状**:本次「confirmDialog 抽 composable」收敛了 Projects/ProjectDetail/Ideas/Settings 四处 `{visible,msg,resolve}+Promise` 样板,但 `AiChat.vue` 是同模式第五处(reactive 版,删对话/清空消息),被遗漏。抽取做了一半,下次改 confirm 语义这里会脱节。
|
||||
|
||||
**修法**:直接复用 composable。
|
||||
|
||||
```diff
|
||||
- const confirmState = reactive({ visible:false, msg:'', resolve:null as null|((v:boolean)=>void) })
|
||||
- function confirmDialog(msg:string):Promise<boolean> { /* Promise 样板 */ }
|
||||
- function answerConfirm(ok:boolean) { /* ... */ }
|
||||
+ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
```
|
||||
|
||||
### ⑤ [ToolCard.vue:313] displayArgValue 对非 string id 降级为裸值
|
||||
|
||||
**现状**:项目名回显前提是 `typeof arg.val === 'string'`。若后端把 id 序列化成 number(JSON 无引号整数),`id` 变空串、分支不进、落到裸数字显示——恰是本次「可读化」要消灭的形态,且无告警。既然已为「查不到项目名」做 `projectIdNotFound` 兜底,类型不一致这一更基本的不可靠也应收口。
|
||||
|
||||
**修法**:归一为 string。
|
||||
|
||||
```diff
|
||||
- const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
+ const id = typeof arg.val === 'string' || typeof arg.val === 'number' ? String(arg.val) : ''
|
||||
```
|
||||
|
||||
> 若确认后端 id 恒为 string uuid,此项可降为 ⚪。
|
||||
|
||||
### ⑥ [tool_registry.rs:469-484] `.bak`/`.tmp-write` 残留污染目录视图
|
||||
|
||||
**现状**:FR-S7 覆盖前备份 `path.bak` + 原子写用 `path.tmp-write`,两者留在 workspace 内且**永不清理**,多次写入堆积;`list_directory` 不过滤它们(`is_noise_dir` 只过滤目录),LLM/用户看到的目录混入噪音文件,LLM 可能误读 `.bak` 当真实文件。
|
||||
|
||||
**修法**:`.bak` 是给用户恢复用的安全网不能删;改为 `list_dir_recursive` 把 `.bak`/`.tmp-write` 后缀并入跳过,或写入前清理同 path 旧 `.bak`(只留最新一份)。
|
||||
|
||||
```diff
|
||||
+fn is_noise_file(name: &str) -> bool {
|
||||
+ name.ends_with(".bak") || name.ends_with(".tmp-write")
|
||||
+}
|
||||
// list_dir_recursive 推 entry 前过滤
|
||||
+if is_noise_file(&name) { continue; }
|
||||
```
|
||||
|
||||
### ⑦ [crud.rs:1187] COLS 手写列串易与表结构漂移
|
||||
|
||||
**现状**:`COLS` 硬编码列名,表加列/`KnowledgeRecord` 改字段时编译期不报错(query_map 按列名读,列少才运行时崩)。「列限定」目标未真正达成——隐式依赖从「表全列」挪到「手写列串」。注释偏长(10 行 TODO)。
|
||||
|
||||
**修法**:加一行测试断言列数 == 14 防漂移,或注释压缩并点明「改表结构需同步本常量」。
|
||||
|
||||
### ⑧ [ToolCard.vue:288-293] projectNameById computed 过度结构化
|
||||
|
||||
**现状**:computed 每次重建覆盖全部项目(含回收站)的 Map,而审批卡通常仅 1-2 行用项目 id。为单点查询做的全量索引,收益成本不匹配。
|
||||
|
||||
**修法**:直接 find,命中即返(审批场景项目数有限、find 提前退出,响应式仍由 store 数组保证)。
|
||||
|
||||
```diff
|
||||
- const projectNameById = computed<Record<string,string>>(() => { /* 遍历构建 map */ })
|
||||
- const name = projectNameById.value[id]
|
||||
+ const name = projectStore.projects.find(p => p.id === id)?.name
|
||||
+ ?? projectStore.deletedProjects.find(p => p.id === id)?.name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 可选优化(5)
|
||||
|
||||
### ⑨ [dag.rs:126 / executor.rs:91] 邻接表两处镜像重复
|
||||
|
||||
`topological_layers` 与 executor 各自内联重建邻接表,旧 `successors`/`predecessors`(O(E) 全扫)成死代码候选。建议抽 `Dag::adjacency_out()/adjacency_in()` 供两处复用,或确认无调用方后删。
|
||||
|
||||
### ⑩ [anthropic_compat.rs:167] 占位 id `tool_missing_{idx}` 理论可撞
|
||||
|
||||
流式缺 id 用 index 兜底,同 index 复用理论可撞。实际 index 唯一、低概率,注释已说明。可加计数器求稳。
|
||||
|
||||
### ⑪ [useConfirm.ts:1] `//!` 注释风格
|
||||
|
||||
Rust doc 风格,但本仓 composable 既有此约定,保持一致即可,不必动。
|
||||
|
||||
### ⑫ [ToolCard.vue:61+] `parsed?.` 冗余可选链
|
||||
|
||||
这些引用都在 `v-if/parsed` 链内,到达时必非空。非本次引入。
|
||||
|
||||
### ⑬ [useAiEvents.ts:79] 注释「沿用正向扫描」表述含糊
|
||||
|
||||
改为「消息内 toolCalls 通常 1-2 条,正序即可」更清晰。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 亮点
|
||||
|
||||
- **FR-S7 原子写 + .bak 双保险**(tool_registry.rs:467-498):write 崩溃不留半成品(tmp→rename),误覆盖有 .bak 兜底 + 缩减>90% warn。堵住「LLM 把 write_file 当 edit 致数据彻底丢失」的真实事故(会话 3473fcb7,PROGRESS.md 762 行/72KB 被覆盖成 248 字节)。
|
||||
- **FR-S8 symlink 防护**(tool_registry.rs:528-540):`file_type()` 不跟随 symlink,symlink 标记但不递归——精准堵住「workspace 内 symlink 指向外部」的逃逸,且不取目标 metadata。防御点选在正确边界,非中间层冗余校验。
|
||||
- **findToolCall 反向扫描权衡留痕**(useAiEvents.ts:75-85):注释讲透「为何不建 Map 索引」(messages 多处整体替换、独立索引易陈旧),行为等价、最坏复杂度明确,务实画像下优选。
|
||||
- **renderMd 流式纯文本短路**(AiChat.vue:347-360):streaming 时跳过 marked+sanitize 避掉帧,刻意不缓存流式文本(防污染),false 翻转自动走 markdown 重渲染,决策清晰无副作用。
|
||||
- **resolve_workspace_path 双层校验**(tool_registry.rs:50-65):词法层 `starts_with`(兜底不存在路径)+ canonicalize 层(解析存在路径的 symlink),返回词法 resolved 保证前端友好。设计扎实。
|
||||
|
||||
---
|
||||
|
||||
## 📊 摘要
|
||||
|
||||
| # | 等级 | 文件:行 | 修改内容 |
|
||||
|---|------|---------|----------|
|
||||
| ① | 🔴 | commands.rs:390 | 删 provider 补 keyring 清理(FR-S1 闭环) |
|
||||
| ② | 🟡 | audit.rs:50 | Err 与 None 分臂,防误报「项目不存在」 |
|
||||
| ③ | 🟡 | openai_compat.rs:414 | 保留 error source 链 |
|
||||
| ④ | 🟡 | AiChat.vue:371 | 第五份 confirm 迁移 useConfirm |
|
||||
| ⑤ | 🟡 | ToolCard.vue:313 | id 归一 string 覆盖 number |
|
||||
| ⑥ | 🟡 | tool_registry.rs:469 | .bak/.tmp-write 列表过滤防噪音 |
|
||||
| ⑦ | 🟡 | crud.rs:1187 | COLS 列数断言防漂移 |
|
||||
| ⑧ | 🟡 | ToolCard.vue:288 | projectNameById 改 find |
|
||||
| ⑨ | ⚪ | dag.rs/executor.rs | 邻接表抽公共方法消除重复 |
|
||||
| ⑩ | ⚪ | anthropic_compat.rs:167 | 占位 id 加计数器 |
|
||||
| ⑪ | ⚪ | useConfirm.ts:1 | 注释风格(保持现状) |
|
||||
| ⑫ | ⚪ | ToolCard.vue:61+ | 去冗余可选链 |
|
||||
| ⑬ | ⚪ | useAiEvents.ts:79 | 注释表述 |
|
||||
|
||||
**总计**:🔴1 🟡7 ⚪5
|
||||
|
||||
**总体评价**:改动方向扎实——FR-S7/S8 安全防护、拓扑 O(V+E)、流式健壮性、confirm 抽取都对路,注释质量高(权衡/根因都留痕)。唯一真实闭环漏洞是 ①(FR-S1 删 provider 漏清 keyring)。其余为 DRY 收口(④抽取只完成 4/5)与可维护性。
|
||||
|
||||
**质量评级**:良(修 ①④ 可达优)
|
||||
Reference in New Issue
Block a user