重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
141 lines
17 KiB
Markdown
141 lines
17 KiB
Markdown
# 经验记录
|
||
|
||
> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」,区别于 [功能决策记录](./功能决策记录.md)(记需求规格 + 设计决策规格)。
|
||
>
|
||
> 创建:2026-06-14(从功能决策记录.md 分流出经验性条目) | 维护:随开发追加
|
||
|
||
## 约定
|
||
|
||
- 按类型分组:**踩坑**(隐性坑/反直觉)/ **约定**(代码实现约定 / 命名约定)/ **技巧**(具体技巧/配置)/ **bug 排查**(bug 定位过程与教训)。
|
||
- 每条标题标 `[来源日期]` + `[Sprint]`(如有),便于回溯原上下文。
|
||
- 三要素:**现象/决策** → **原因/根因** → **状态/教训**。
|
||
- 与功能决策记录区分:这里记「怎么实现的细节坑」,不记「为什么这么设计」。
|
||
|
||
---
|
||
|
||
## 一、踩坑
|
||
|
||
### i18n 模块必须命名空间化导出(扁平导出会断 $t + 键覆盖)[2026-06-14]
|
||
|
||
- **现象**:左侧菜单显示 `'nav.tasks'`(原样键名);`dashboard` 整页显示 key 名;`AiChat` 的 `$t('ai.assistant')` 失效。
|
||
- **决策**:每个 i18n 模块文件 `export default { 命名空间: {...} }`(如 `nav.ts` → `{ nav: {...} }`),**禁止扁平导出顶层词条**。模板查询走 `$t('命名空间.key')`。
|
||
- **根因**:`index.ts` 聚合是 `Object.assign` 扁平合并各模块顶层 key(见「locale 拆分 + glob 聚合」决策)。扁平导出导致两个 bug:① `$t('nav.tasks')` 找 `messages.nav` 不存在,原样显示键名;② 扁平键(如 nav 的 `ideas`/`projects`/`tasks`/`knowledge`)与同名命名空间模块(ideas.ts/projects.ts/...)按文件名字母序互相覆盖。本次 nav.ts 扁平导出导致 4 个键被覆盖。
|
||
- **状态/教训**:✅ 系统性修复,共 4 模块扁平已全部改嵌套(zh/en 8 文件):nav / common(8 文件 25 处 $t 引用)/ dashboard / ai。原本正确嵌套:ideas/projects/tasks/settings/knowledge/projectDetail/aiChat。**教训**:扁平导出是体系性 bug 非单点。排查「$t 显示原样键名」时应**优先怀疑模块导出结构(扁平 vs 嵌套)**,而非 SSR / locale 初始化。
|
||
- **绕路纠错**:曾误判根因为 SSR(实际 Tauri 纯客户端无 SSR)→ nav 走 `getNavTranslations` 硬编码 map + displayText 绕路 → 清除绕路恢复标准 `$t`。
|
||
|
||
---
|
||
|
||
## 二、约定
|
||
|
||
### Anthropic 流式 output_tokens 当累计值直接覆盖 / 流式 token 落库走累加模式 [2026-06-13]
|
||
|
||
- **决策**:① `message_delta` 事件的 output_tokens 直接覆盖 completion_tokens,**不像 prompt 那样累加**;② `save_conversation` upsert 路径 token 读旧值叠加(非覆盖);`run_agentic_loop` 局部累加器每轮叠加、退出时一次性传 save。
|
||
- **原因**:① Anthropic 协议在 `message_delta` 返回的是**累计** output_tokens(截至当前总量),非增量;当增量处理会重复计算。OpenAI 则是末 chunk 一次性给全量——两协议语义不同,各自处理。② 审批暂停→恢复 spawn 全新 `run_agentic_loop` 实例,新 loop 局部累加器从 0 起;若覆盖写会丢旧 loop 已落库的 token。累加保证跨 loop 实例的对话总用量正确。
|
||
- **状态**:✅ 2026-06-13(两协议各自语义处理 + 跨 loop 累加保对话总量)
|
||
|
||
### db 字段加列须同步四处:migration + crud 白名单 + AI 工具层白名单 + 工具描述 [2026-06-14]
|
||
|
||
- **现象**:AI 对话让 AI 绑定目录,update_project(path) 报「不允许更新字段 'path'」,但 db schema 和 crud 白名单都已有 path。
|
||
- **根因**:可更新字段有两套独立白名单——crud.rs::allowed_columns_for(DB 层)+ ai.rs 工具闭包硬编码 match(AI 工具层)。加 path/stack 时只同步 DB 层漏 AI 工具层,两层不一致。
|
||
- **教训**:加 Record 可变字段同步四处(migration + crud 白名单 + ai.rs 工具白名单 + 工具描述)。排查「DB 有字段但工具报不允许」直查 ai.rs 硬编码。架构债:白名单双份去重。
|
||
|
||
### Tauri 命令文件拆子 module:命令函数必须 glob `pub use *`,不能逐个显式 [2026-06-14]
|
||
|
||
- **现象**:把含 `#[tauri::command]` 的单文件(如 ai.rs)拆成 `ai/` 子 module 时,mod.rs 用 `pub use self::commands::{ai_chat_send, ...}` 逐个显式重导出 17 个命令,`cargo check` 报 40 个 E0433:`cannot find __cmd__ai_chat_send in ai` / `cannot find __tauri_command_name_ai_chat_send in ai`。
|
||
- **根因**:`#[tauri::command]` 宏不只生成命令函数本身,还用 `paste!` 宏拼接生成一组同模块定义的内部符号(`__cmd__xxx`、`__tauri_command_name_xxx`)。`generate_handler!` 解析 `commands::ai::ai_chat_send` 时会查找 `commands::ai::__cmd__ai_chat_send`。逐个 `pub use self::commands::{ai_chat_send}` 只拉函数本身,**拉不到这些 `__cmd__` 内部符号**(即使它们在原模块是 pub 的)。
|
||
- **教训**:拆命令文件时,mod.rs 重导出命令必须用 `pub use self::commands::*;`(glob 把宏生成的全部符号一起拉到上层路径),不能用逐个显式。非命令 pub 项(如 `build_ai_tool_registry`/`restore_pending_approvals`)可逐个显式。后续若拆 idea.rs/project.rs/task.rs 等其他含命令的大文件,同此模式。
|
||
- **状态**:✅ 2026-06-14 验证(ai.rs 拆 11 子 module,glob 重导出后 cargo check 0 error)
|
||
|
||
### 跨层模块拆分:`super::xxx` 路径失效需改全限定 [2026-06-14]
|
||
|
||
- **现象**:ai.rs(commands 直接子模块)拆到 `ai/xxx.rs`(commands 孙模块)后,6 个子文件 `use super::now_millis` 全报 E0425 unresolved import。
|
||
- **根因**:`super` 指向当前模块的父——ai.rs 时 `super` = `commands`(`now_millis` 定义处);拆到 `ai/xxx.rs` 后 `super` = `commands::ai`,`now_millis` 在祖父模块 `commands`。
|
||
- **教训**:拆层后所有 `super::xxx` 引用需重审。父模块的 helper(如 `now_millis`)改全限定 `crate::commands::now_millis` 最稳(不依赖层级)。或拆层前把 helper 下沉到子 mod.rs 内 `use` 一次,子文件用 `super::xxx`。
|
||
- **状态**:✅ 2026-06-14 验证(批量改 `crate::commands::now_millis`,6 文件 20+ 处)
|
||
|
||
### 删文件后被 linter/工具重建为 0 字节触发 E0761 [2026-06-14]
|
||
|
||
- **现象**:`rm commands/ai.rs` 后某 linter/hook 又建了 0 字节的 ai.rs,触发 `E0761: file for module ai found at both ai.rs and ai/mod.rs`,且 Rust 优先选空文件导致后续 40 个 `cannot find __cmd__xxx`(与 glob 重导出坑叠加,表象一致根因不同)。
|
||
- **教训**:拆分时删原文件后**立即 ls 验证不存在**再跑 cargo check,避免空文件 + 目录并存的 E0761 与命令宏符号坑混淆。E0761 出现先查是否有 0 字节残留文件。
|
||
- **状态**:✅ 2026-06-14 验证(删空 ai.rs 后通过)
|
||
|
||
### ALLOWED_COLUMNS 从全局共享演进为按表隔离 [2026-06-13]
|
||
|
||
- **决策**:`crud.rs` 列名白名单从单一全局 `ALLOWED_COLUMNS` 改为 `allowed_columns_for(table)` 按表 match;`validate_column_name(field, table)` 接收表名;宏 `query`/`update_field` 传 `$table`。专用更新路径列(knowledges.embedding 走 set_embedding、projects.deleted_at 走 soft_delete/restore)排除出白名单。
|
||
- **演进原因**:原原则(见功能决策记录需求澄清「代码审查甄别原则」)基于「全局白名单够防注入」。本轮多代理代码审查发现**真实 bug**:全局白名单**误含 ideas 表没有的 `reasoning` 列**(reasoning 属 knowledges/V10),`update_idea("reasoning")` 会 validate 通过但 SQLite 报 `no such column`——错误从「白名单拒绝」退化成「底层 SQL 错」且语义错。按表隔离既修此 bug(ideas 白名单不含 reasoning)又防未来跨表字段(update_task 误传 projects 的 `name` 在校验阶段拒绝,非靠 SQL 兜底)。
|
||
- **代价/取舍**:12 表 × N 列的 match 冗长,但数据驱动、可读、一次写对。**规模判断不变**(仍不加分页/不拆 LIMIT),仅白名单从「全局防注入」升级为「按表防注入 + 防跨表字段」。
|
||
- **状态**:✅ 2026-06-13 落地(cargo check + df-storage 32 test 全绿,含 `update_field_rejects_cross_table_column_tasks_name` 用例验证跨表字段被拒)
|
||
|
||
### 配置存储:SQLite/AppState Arc<Mutex>,非 Tauri app config [2026-06-13]
|
||
|
||
- **决策**:KnowledgeConfig(提取+注入共 5 项)**存 AppState 内存**(`knowledge_config: Arc<Mutex<KnowledgeConfig>>`),前后端通过 `knowledge_get_config`/`knowledge_save_config` IPC 读写;**不引入 tauri-plugin-store**。
|
||
- **演进**:[2026-06-13 初版设计] 写「存 Tauri app config」 → [2026-06-13 审查修正] 代码实证项目 Cargo.toml 仅 opener+window_state 两插件,**从未用过 config/store 机制**;现有设置走两条路(SQLite 存 provider / localStorage 存 UI 偏好) → [2026-06-14] **`SettingsRepo` 兑现本条预言**:`app_settings` KV 表(V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete,不走 `impl_repo!` 宏因 KV 无固定 schema)。localStorage 11 key 迁移启动:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency) + `df-ai-active-conv`;例外 `df-ai-gen`/`df-ai-text`(流式临时快照,每个 delta 写一次,SQLite 高频写拖慢流式,留 localStorage)。
|
||
- **原因**:AI Provider 配置已是 SQLite+Repo+IPC 模式,知识库行为配置(后端行为,非 UI 偏好)对齐同模式最一致。引入 tauri-plugin-store 是全新基础设施依赖,与既有 DB 路线割裂。AppState Arc<Mutex> 内存持有 + IPC 读写,启动时 `default()` 初始化(Tier 1 未持久化到 DB,进程重启回默认——够用,因这是行为偏好非数据)。未来要持久化时复用同一套 SettingsRepo 即可。
|
||
- **状态**:✅ 已实施(Tier 1)
|
||
|
||
### 知识删除语义:knowledge_archive 软删除(命名统一)[2026-06-13]
|
||
|
||
- **决策**:知识删除 command 命名 `knowledge_archive`(执行 `UPDATE status='archived'`),**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例;主列表 `knowledge_list(status=None)` 默认 `AND status!='archived'` 过滤。
|
||
- **原因/取舍**:idea/task/project 的 `delete_xxx` 都是硬删(DELETE FROM),若 knowledge 也叫 delete 却做归档,API 语义混淆(调用方期望数据消失,实际还在 DB)。conversation 模块已有正确先例(archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
|
||
- **状态**:✅ 已实施(Tier 1)
|
||
|
||
### Store 状态字段用 getter 替代引用快照 [Sprint 10]
|
||
|
||
- **决策**:`useProjectStore()` 返回对象的状态字段(projects/tasks/ideas/workflowExecutions/liveEvents/loading/error)改 getter 实时读 state,而非 `ideas: state.ideas` 引用快照。
|
||
- **原因**:引用快照在 `loadIdeas()` 等重新赋值 state.ideas 后,返回对象的 ideas 属性不更新(刷新后视图空,需切菜单再切回才显示);getter 每次读 state,响应链成立。computed(stats/pendingApproval)在 reactive 内仍自动解包,各视图用法零改动。
|
||
- **状态**:🚧 Sprint 10(编译/构建通过,未 tauri dev 实测,根因通杀 Projects/Tasks/Dashboard)
|
||
|
||
---
|
||
|
||
## 三、技巧
|
||
|
||
### migrate_v4:PRAGMA table_info 探测列存在性 [Sprint 10]
|
||
|
||
- **决策**:v4 加 `archived` 列时,用 `PRAGMA table_info` 幂等探测列是否已存在,而非仅依赖 `schema_version` 版本号 gate。
|
||
- **原因**:历史坏库 `schema_version` 值混乱(早期迁移异常致版本号与实际 schema 不符),版本号不可靠;直接探列存在性最稳——已存在则跳过,不存在则补建,幂等可重入。
|
||
- **状态**:✅ Sprint 10
|
||
|
||
### Vite 端口 `strictPort: true` 不自动迁移 [Sprint 1]
|
||
|
||
- **决策**:`vite.config.ts` 设 `port: 1420` + `strictPort: true`,端口被占时**直接报错退出**而非自动 +1 迁移;`tauri.conf.json` 的 `devUrl` 写死 `http://localhost:1420`。
|
||
- **原因**:Tauri webview 启动时按 `devUrl` 加载前端,若 Vite 因冲突静默迁移到 1421 而 devUrl 仍是 1420 → 白屏/连不上,错误难定位(易误判为前端代码 bug)。`strictPort` 让端口冲突当场炸出,定位明确。代价:1420 被占需手动杀进程,但换取「devUrl 与实际端口必一致」的不变量。
|
||
- **状态**:✅ Sprint 1(本次会话核对:1420 vs 2661 反复折腾后回退到 1420,即此耦合的直接体现)
|
||
|
||
---
|
||
|
||
## 四、bug 排查
|
||
|
||
### ai_tool_executions 审计回写失效(Med/High 审批后卡 pending)[#54 实测]
|
||
|
||
- **现象**:用户审批 Med/High 工具后执行成功(副作用落库,如 create_project→projects 有记录),但 `ai_tool_executions` 仍 `status=pending / decided_by=None / executed_at=None / result=None`,审计未闭环。Low 工具正常(`decided_by=auto` 完整)。
|
||
- **根因(代码层定位)**:`crud.rs:103` 宏 `impl_repo!` 生成的通用 `query` 硬编码 `ORDER BY created_at DESC`,但 `ai_tool_executions` 表**无 `created_at` 列** → `audit_finalize` 的 `query("tool_call_id", x)` SQL 报 `no such column: created_at` → `.unwrap_or_default()` 吞错返回空 → `if let Some(rec)` 为 None → **永不回写**。Low 工具不走 query(`process_tool_calls` Low 分支直接 `audit_tool_call` insert 完整记录)故不受影响。
|
||
- **架构隐患**:通用 `query` 的 `ORDER BY created_at` 假设所有表都有该列——`ai_tool_executions`(及潜在其他无 `created_at` 的表)任何 `query()` 调用都静默失败;`unwrap_or_default` 吞 SQL 错误放大隐患。
|
||
- **修复**:✅ 已落地(2026-06-13)。采用方向①:`crud.rs` 给 `AiToolExecutionRepo` 加专用 `find_by_tool_call_id`(裸 SQL `ORDER BY requested_at DESC LIMIT 1`,绕过宏的 `created_at` 假设);`ai.rs audit_finalize` 改用之,查不到记录改 `tracing::warn`(不再 `unwrap_or_default` 静默吞错)。
|
||
- → 未改宏(方向②影响 7+ 表)/ 未加列(方向③需迁移):隐患仅 `ai_tool_executions` 一处暴露,局部修最小影响。
|
||
- → **架构隐患仍存(未根治)**:通用 `query`/`list_all` 宏对无 `created_at` 的表(`ai_tool_executions`/`node_executions`/`workflow_executions`)调用仍静默失败。当前仅 `ai_tool_executions` 有 `query` 调用且已绕开,余者暂无 `query` 调用点。未来新增调用时,要么该表登记 `created_at`,要么宏做容错。
|
||
- **教训**:宏生成的通用方法对表 schema 的隐式假设(这里「所有表都有 created_at」)是隐蔽的系统性风险;`unwrap_or_default()` 吞错误让 bug 隐形——关键路径慎用。
|
||
|
||
### reasoning 字段回填(修 bug:prompt 要求但写库丢弃)[2026-06-13]
|
||
|
||
- **现象/决策**:`KnowledgeRecord` 加 `reasoning: Option<String>`(V10 ALTER),`extract_knowledge_from_conversation` 解析 LLM JSON 的 `reasoning` 字段写入主表;前端详情溯源区展示「🤖 AI 判断依据」。
|
||
- **根因**:`EXTRACTION_SYSTEM_PROMPT` 早已要求 LLM 输出 `reasoning: "为何值得沉淀"`,但提炼循环(ai.rs 旧版)只取 kind/title/content/tags/confidence,**reasoning 被 LLM 产出却遭代码丢弃**——是信息链断裂的 bug,非缺功能。审核员光看 content 结论,缺 AI 判断依据(尤其 confidence=low 的弱信号更靠 reasoning 解释为何还提炼)。回填后溯源完整。
|
||
- **状态**:✅ 已实施(reasoning 存主表 + extracted 事件 context.reasoning 双写,前端优先取主表降级取事件)
|
||
- **教训**:LLM 输出字段与代码消费字段须对账——prompt 要求 LLM 产出的字段,代码侧漏消费是常见隐性 bug。
|
||
|
||
### prompt_tokens=0:深挖证伪非代码 bug(疑 GLM 订阅端点 message_start 缺 input_tokens)[#54 实测发现]
|
||
|
||
- **现象**:`ai_conversations.prompt_tokens=0`(completion=1496 正常)。GLM-订阅(anthropic 协议)1 对话 24 消息,所有 assistant 消息 `usage=None`。
|
||
- **深挖结论(→ 修正初判)**:初判「anthropic_compat usage 解析漏 input_tokens,待修」**证伪**。逐段验证:
|
||
1. `anthropic_compat` message_start 取 `input_tokens→prompt_tokens` **有单测**(input=42 过);
|
||
2. `stream_llm`(857)`final_usage=chunk.usage.clone()` 累积对;
|
||
3. `ai.rs:699` `tokens.add` 链路对。
|
||
代码按标准 Anthropic 协议解析正确。`inp as u32`(Some→值,None→0):completion 有值说明 message_delta 的 output GLM 返回了,**prompt=0 = GLM 订阅端点 message_start 疑未返回 `usage.input_tokens`**(协议非标)。**勿改 anthropic_compat**(改了 = 误改正确实现)。
|
||
- **状态**:📐 待修(误判)→ 🚫 非代码 bug。待抓 GLM 订阅 SSE 原文确认 input_tokens 在哪个事件/字段(临时打 message_start/message_delta 的 usage JSON 日志,测完删);若确认端点缺则属 provider 兼容性待办,非解析 bug。
|
||
- **教训**:bug 定位优先用单测/逐段验证证伪代码层假设,不要急着改「看似正确」的实现。深挖证伪避免了一次误改。
|
||
|
||
---
|
||
|
||
**相关文档**:
|
||
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格
|
||
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代
|