新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 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/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -0,0 +1,407 @@
# 功能决策记录 — 归档
> 从 [功能决策记录.md](./功能决策记录.md) 归档的条目——纯实现流水、老 Sprint 决策、UX 微调、已被取代或合并的细节。这些条目在「3 个月回看是否仍影响系统/功能设计理解」判断下已不再需要常驻主文档,但完整保留以备回溯。
>
> 创建2026-06-14 | 性质:归档只读,不再维护更新
## 归档判断标准
- 一次性代码审查流水(甄别落地、骨架删除、列表查询过滤)
- UX 微调(工具卡片折叠、对话滚动、错误友好化、空白屏修复)
- 实现细节参数解析抽函数、token 记录策略、model 追溯)
- 老 Sprint 决策已被新设计取代或合并
> 其中**架构级结论**已提炼一句留在主文档对应小节,归档条目为细节展开。
---
## 一、AI Chat 上下文窗口与并发控制 [任务 #43 系列]
> 主文档保留一句架构结论:**ContextManager 类型替换为 messages 真相源 + 双层 Semaphore 并发控制(全局 3 / 单对话 2**。以下为实现细节归档。
### ContextManager 接线方式:类型替换而非 Wrapper [任务 #43]
- **决策**`AiSession.messages` 字段类型从 `Vec<ChatMessage>` 直接改为 `ContextManager`,后者成为消息的唯一持有者(内含 `Vec<TrackedMessage>` + token 缓存 + 裁剪能力)。不引入 Wrapper 包装层。
- **原因/取舍**Wrapper 方案下 Vec 是真相、ContextManager 是临时视图——每次 `build_for_request` 都要 clone 给 ContextManagertoken 缓存永远滞后一轮或每次重建(双重复制)。类型替换让 push 即刻更新 token 计数、零额外 clone、单一数据源。代价是 `save_conversation` 等需全量的场景要显式调 `all_messages_clone()`多一行但语义明确。13 处操作点中 6 处签名不变push/clear、4 处微调clone→all_messages_clone/iter、2 处需重写。
- **状态**:✅ 已落地2026-06-13cargo check + vue-tsc 通过,待 tauri dev 实测)
### Token 计数方案:字符粗估零依赖 [任务 #43]
- **决策**:用 `chars_count × 0.35` 粗估单条消息 token 数(~2.8 字符/token每条消息 +4 token 固定开销role 标记),每个 tool_call +30 tokenJSON 结构)。不引入 tiktoken-rs 或任何 tokenizer 依赖。
- **原因/取舍**:用途是「发送前判断是否超限」做预算控制,误差 ±15% 完全可接受tiktoken-rs 引入 BPE 数据文件约 5MB对 Tauri 桌面应用打包不友好provider 返回的 usage 可用于事后校准闭环暂不实施(原 calibrate 方法未接线Review 时已删,留待未来按 provider usage 重做。chars_ratio=0.35 对中英混合文本偏保守(纯英文 ~0.25,纯中文 ~0.5-0.7),宁可多算不少算。
- **状态**:✅ context.rs 已落地2026-06-13校准闭环暂缓
### 淘汰算法:分组滑动窗口 + 三元组保护 [任务 #43]
- **决策**超预算时从最旧消息开始按「淘汰单元」丢弃。Standalone 消息单独成单元;`Assistant(tool_calls) + Tool(result)* + Assistant(final_text)` 工具调用三元组作为原子整体要么全保留要么全丢弃。最后 6 条消息≈2 个完整用户轮次)设为保护区永不淘汰。
- **原因/取舍**:工具调用三元组若拆散会导致 LLM 看到工具调用但找不到对应结果(或反之),产生幻觉重复调用。保护区防止丢失即时上下文。替代方案是直接按消息数截断(简单但破坏三元组),或按 token 截断到某位置(可能从三元组中间切断)。分组滑动窗口在安全性和信息保留间取平衡。
- **状态**:✅ 已落地2026-06-13
### 裁剪时机build_for_request 时裁剪push 不触发 [任务 #43]
- **决策**token 预算检查和消息裁剪仅在 `build_for_request()` 构建请求时执行。`push()` 只做追加+计缓存,不做任何淘汰。
- **原因/取舍**Agentic Loop 一轮执行中会多次 pushassistant 回复 → tool_result → 可能再 assistant这些属于当前活跃轮次的消息绝不能被中途裁剪掉。只在「即将发给 LLM」这个时间点评估并裁剪旧消息语义清晰且安全。
- **状态**:✅ 已落地2026-06-13
### 持久化策略裁剪仅影响发送视图DB 存全量 [任务 #43]
- **决策**`all_messages_clone()` 返回全量未裁剪消息用于 save_conversation 落库;`build_for_request()` 返回裁剪后版本发给 LLM。两者解耦。
- **原因/取舍**:用户切换对话回来期望看到完整历史,不应因自动裁剪而永久丢失。裁剪是临时的「发送时压缩」类似 gzip。未来若要做永久摘要压缩将旧对话提炼为 summary 消息插入),那是独立 feature 不影响此设计。
- **状态**:✅ 已落地2026-06-13
### 多工具调用并行化join_all 无上限 [任务 #43]
- **决策**`process_tool_calls` 中 Low 风险工具收集后用 `futures::future::join_all` 并行执行Med/High 审批工具仍串行(需用户交互)。工具执行本身不限并发数(本地操作)。
- **原因/取舍**LLM 一次返回 N 个独立工具调用(如同时读 3 个文件)时,串行执行 = O(N×T),并行 = O(T)。N=3 时节省 ~600ms/轮。工具执行是本地 I/Oread_file/grep 等),无外部限流风险故不加 Semaphore。仅 LLM 调用受并发控制。
- **状态**:✅ 已落地2026-06-13
### LLM 并发控制:双层 Semaphore [任务 #43]
- **决策**AppState 新增 `LlmConcurrency`(封装两个双层 `Arc<Mutex<Arc<Semaphore>>>`——全局并发默认 3 / 单对话默认 2。permit 在 3 个叶子 LLM 调用点 acquire`run_agentic_loop` 内 stream_llm 前、`generate_title_via_llm``extract_knowledge_from_conversation`),作用域结束自动释放;工具执行不受控。`LlmConcurrency: Clone`(两 Arccheap作为参数串到底spawn 函数收 by-value move、叶子收 `&ref`。acquire 顺序固定 global→per_conv所有调用点一致防死锁。
- **原因/取舍**:多对话场景下同时跑 2-3 个对话可能撞 provider RPM 限制导致 429。双层控制全局防总并发失控单对话防单对话独占标题生成 + 主循环 + 提炼并发)。用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装而非裸 `Arc<Semaphore>`——tokio Semaphore permits 构造时固定不可增减,替换内层 Arc 即重建,已持有旧 permit 不受影响。permit 放叶子调用点(最接近真实 HTTP 调用)而非 command 入口,限流粒度精准且不阻塞非 LLM 路径。
- **状态**:✅ 已落地2026-06-13
### per_conv 实为应用级单一信号量(非 per-conv map[任务 #43 / Review 修正]
- **决策**`LlmConcurrency.per_conv` 字段命名暗示「单对话」并发,但实现是应用级**单一** `Semaphore`(非 `HashMap<conv_id, Semaphore>`)。当前不修实现,仅在 `state.rs` 结构体 doc 标注真实语义 + 未来重构路径。
- **原因/取舍**`AiSession``Arc<Mutex<AiSession>>` 单例,且 `generating` 互斥保证同一时刻仅一个对话的 `run_agentic_loop` 在跑。故 per_conv 实际退化为「单对话内并发」(主循环 stream_llm + 标题生成 + 知识提炼三者受限流约束)——命名虽宽泛但当前语义恰好正确,非 bug。改 HashMap 是过度设计(单例会话下多 map 项永不被并发访问)。风险留待未来:若支持多对话并发 loopper_conv 需随之改 `HashMap<conv_id, Semaphore>` 才名副其实。揭示 `LlmConcurrency``AiSession` 单例的隐式耦合——本次 review 最有价值的发现。
- **状态**:✅ 注释留痕2026-06-13 Review 修正state.rs 结构体 doc 标注;实现未改,非 bug多对话路线时重构
### 裁剪策略与模型选择正交 [任务 #43 / 架构边界]
- **决策**:上下文窗口管理的 `ContextConfig` 不含 `mode`/模型选择字段。「高精度/低精度对话」(深度思考/reasoning_effort/模型选择)属于 LLM 调用层参数(`CompletionRequest` 层面与裁剪策略sliding_window / 未来 summarization是**正交维度**,不混入 ContextManager。未来摘要压缩作为独立模块实现。
- **原因/取舍**避免把不同层面的控制拧到一个配置对象里——ContextConfig 只管「窗口多大、怎么裁」,模型/推理模式由调用方在构建 `CompletionRequest` 时决定。职责单一,后续扩展任一维度不影响另一侧。
- **状态**:✅ 架构边界已划定2026-06-13 讨论确认)
### save_conversation 异步化 [任务 #43]
- **决策**:循环结束时 spawn 异步落库,先释放 `generating=false` + emit `AiCompleted`,不阻塞前端收到完成事件。
- **原因/取舍**:当前 save_conversation 同步执行upsert + JSON 序列化),阻塞 Completed 事件几十~几百毫秒。落库失败不影响已完成的结果展示,异步化提升用户感知响应速度。代价是进程崩溃时最后一轮可能未落库(概率极低且下次启动可从 LLM provider 侧无法恢复 anyway
- **状态**:✅ 已落地2026-06-13
### build_for_request 只传 token 数值,不传 system_prompt 文本 [任务 #43 / Review 修正]
- **决策**`build_for_request(sys_tokens: u32) -> (Vec<ChatMessage>, bool)` 只接收 system prompt 的预估 token 数,不接收 `&str` 文本。system_prompt 的 token 估算在调用方(`ai.rs`)完成。
- **原因/取舍**职责单一——ContextManager 负责裁剪和消息管理,不需要知道 system prompt 的文本内容。避免每次调用传递可能很长的字符串(含知识库+技能注入后可达几千字符),且 `build_for_request` 内部不混入估算逻辑。
- **状态**:✅ 已落地2026-06-13
### join_all 不对 tool_calls 做 sort保留原始 index [任务 #43 / Review 修正]
- **决策**:收集 Low 风险工具时保留原始 `(index, draft)` 元组,不额外 `sort_unstable_by_key`。LLM 返回的 tool_calls 已按 index 有序sort 是多余且可能打乱语义顺序。
- **原因/取舍**`futures::join_all` 保证结果顺序与输入一致,只要输入有序输出就有序。去掉 sort 减少一次 O(n log n) 且避免意外重排。回填 session.messages 时按原始 tool_call_id 匹配即可,不依赖数组位置。
- **状态**:✅ 已落地2026-06-13
### build_for_request 视图裁剪,不 mutate self.messages [任务 #43 / Review 修正]
- **决策**`build_for_request(&self)` 改不可变借用,超预算时构造裁剪**视图**返回(`self.messages[trim_end..]` clone`drain` 修改自身。删除原会 `self.messages.drain(0..trim_end)``trim_to_budget` 方法。
- **原因/取舍**:原 mutate 实现违背「裁剪仅影响发送视图」契约——`drain``all_messages_clone()`save_conversation 数据源)也返回裁剪版,长对话每轮丢历史、累积性数据丢失。视图裁剪每轮 build 重算 trim_endO(n) 遍历淘汰单元n 通常 <50 可忽略),换全量持久化不被破坏。
- **状态**:✅ context.rs 已落地2026-06-13 Review 修正5 单测通过)
### AiSession.messages 类型替换为 ContextManager + 13 处接线 [任务 #43 / Part A 第二块]
- **决策**`AiSession.messages``Vec<ChatMessage>` 直接替换为 `ContextManager`(类型替换,非 wrapper 包装层。13 处操作点适配push/clear/len/iter 签名天然兼容零改动switch 对话改 `restore_from_messages(Vec)``replace_tool_result` 自由函数删除改走 `ContextManager::replace_tool_result_content` 方法DRY 收敛,方法已含 token 重估save_conversation 与 ensure_conversation_title 改 `all_messages_clone()` 取全量。
- **原因/取舍**:类型替换而非 wrapper 层——消息真相源唯一,避免 Vec + ContextManager 双存导致状态分裂ContextManager 方法签名刻意与原 Vec 操作对齐push/clear/len/iter13 处中 6 处零改动,改动面最小。核心改造点 run_agentic_loop 由 `session.messages.clone()` 全量塞入改为 `build_for_request(sys_tokens)`:调用方用 `TokenEstimator::estimate_text(&system_prompt)` 估算 system prompt tokenContextManager 只接收数值不接触 prompt 文本。
- **状态**:✅ ai.rs 已落地2026-06-13cargo check 通过 + df-ai context 5 单测绿)
### 裁剪触发时不 emit 前端事件 [任务 #43 / Part A]
- **决策**`build_for_request` 返回的 `trimmed: bool` 暂忽略(`let (history_msgs, _trimmed) = ...`),裁剪发生时不向前端 emit 任何事件。
- **原因/取舍**:裁剪是无损优化——内存 `all_messages_clone` 与 DB 持久化均保留全量历史,仅发送给 LLM 的视图裁掉旧消息。计划原拟用 `AiError` 通知前端,但 AiError 语义是「错误」,裁剪不是错误会误导用户以为出错;前端无需感知裁剪(对 UX 透明)。未来若需「已压缩早期历史」提示条,新增专用事件(如 AiContextTrimmed而非复用 AiError。
- **状态**:✅ ai.rs 已落地2026-06-13
### B1 Low 风险工具 join_all 并行 + 串行回填 [任务 #43 / Part B]
- **决策**`process_tool_calls` 重写——批量发 Started 后Low 风险工具 `futures::future::join_all` 并行 execute闭包内完成即 emit Completed/Error不持 session 锁),`join_all` 返回后串行 push tool_result + audit持锁。Med/High 审批逻辑不变。
- **原因/取舍**:原 for 循环逐个串行 executeN 个独立 Low 工具 = N 倍等待;并行化总耗时 ≈ 最慢一个。execute + emit 放闭包内完成即通知前端体感逐个出结果push/audit 必须持 session 锁故留 join_all 后串行。`join_all` 保序——结果顺序 = 输入顺序 = tc_list sort 后的原始 index 顺序tool_result 回填不乱序。
- **状态**:✅ ai.rs 已落地2026-06-13
### B1 附注tool_result push 顺序变化无语义影响 [任务 #43 / Part B]
- **决策**Med/High 占位 tool_result 先于 Low 结果 push分类阶段先处理审批占位Low 结果 join_all 后回填),与原「按 tc_list 交错顺序 push」不同。
- **原因/取舍**LLM 按 `tool_call_id` 关联 tool_result不看消息绝对位置连续 tool_result 都是 ContextManager 的 ToolResultTail、归同一三元组顺序不影响语义。两阶段占位先行 + 结果后填)比交错处理实现简单。
- **状态**:✅ ai.rs 已落地2026-06-13
### B2+B3 正常完成save/title/extract 打包后台 spawn [任务 #43 / Part B]
- **决策**`run_agentic_loop` 正常完成段,把 save_conversation + maybe_spawn_extraction + ensure_conversation_title 三者打包进**同一** `tauri::async_runtime::spawn` 后台 task主流程只做 `generating=false` + emit Completed。stop 路径2 处save 保持同步 awaittitle 改 `spawn_ensure_title` 后台。
- **原因/取舍**:① 计划原拟裸 spawn save`maybe_spawn_extraction` 明示「需在 save 之后(读已落库消息)」——裸 spawn save 与 extract 各自独立 task 顺序不保证extract 可能读到旧 DB打包同一 task 内 `save → extract → title` 串行 await 保顺序。② stop 路径 save 保持同步stop 后用户可能立刻发新消息触发新 loop两 loop 的 save 并发 upsert 会竞态(读旧值叠加丢 token正常完成段同风险但频次低、最多丢少量 token 累加(非功能错误),可接受。③ `ensure_conversation_title` 签名从 `provider: &dyn LlmProvider` 改收 `provider_config: &AiProviderRecord`、内部自建 provider——`&dyn` 非 'static 无法 move 进 spawn收 config 克隆进 task 后自建。
- **状态**:✅ ai.rs 已落地2026-06-13并发场景待实测
### Semaphore 重建采用「软收敛」策略 [任务 #43]
- **决策**:用户在 Settings 调整并发上限时,通过 `*semaphore = Arc::new(Semaphore::new(n))` 替换整个 Arc 内部值。已持有旧 permit 的任务不受影响,新请求走新限制。缩并发时实际并发 = 旧持有数 + 新上限(软收敛非硬切断)。
- **原因/取舍**tokio Semaphore 的 permits 数只能在构造时设定,运行时无法增减,这是标准限制。替代方案(如用 Mutex+计数器手动实现复杂度高且易出错。「软收敛」行为可接受——用户调低并发后进行中的请求不会被中断只是新请求受控待旧请求释放后新限制完全生效。UI 可提示「已有 N 个进行中请求」改善体验。
- **状态**:✅ 已落地2026-06-13
### protect_count=6 保护最近约 2 个用户轮次 [任务 #43]
- **决策**:淘汰算法保护最后 6 条消息不纳入淘汰单元。依据:每轮典型产生 User + Assistant[±tools] + Tool* ≈ 2~5 条6 条 ≈ 覆盖 2 个完整轮次。
- **原因/取舍**固定数值简单可靠。不改为动态轮次检测增加复杂度且轮次边界模糊——Assistant 纯文本 vs 带 tools 的 Assistant 消息算同一轮还是不同轮。6 是保守值,宁可多保几条也不要误裁活跃上下文。未来可根据实测调整。
- **状态**:✅ 已落地2026-06-13
### syncConcurrencyConfig 加 debounce 防快速连续 IPC [任务 #43]
- **决策**Settings 页面修改并发数值后,通过 debounce~300ms延迟调用 `ai_set_concurrency_config` IPC防止快速连续拖动滑块/按键时频繁重建 Semaphore。
- **原因/取舍**`@change` 在 input[type=number] 上只在失焦时触发已比 `@input`但用户可能快速点「保存」或连续调整两个值。Semaphore 重建虽轻量Arc::new但不该无节制地做。手写简易 debounce~5 行)即可,不需引入 lodash-es 依赖。
- **状态**:✅ 已落地2026-06-13
### spawn 异步 save_conversation 加 warn 日志 [任务 #43]
- **决策**`tauri::async_runtime::spawn(save_conversation_inner)` 内部用 `if let Err(e) = ... .await` 捕获错误并 `tracing::warn!` 记录,不静默吞掉。
- **原因/取舍**spawn 的 task 错误默认被 tokio 静默丢弃,调试时完全看不到落库失败。加一行 warn 零成本出问题时能从日志定位。不影响用户体验warn 不是 error
- **状态**:✅ 已落地2026-06-13
---
## 二、AI Chat 工具卡片折叠 [Sprint 10 + 2026-06-13]
> 纯 UX 微调,全部归档。
### 双层折叠:卡片级 + 内容级分离
- **决策**`expandedCards`(整卡 body 显隐)与 `expandedTools`read_file 代码预览级)两套独立状态。
- **原因**:卡片折叠和文件内容预览是两个维度,合并会互相干扰。
- **状态**:✅ Sprint 10
### running/pending_approval 强制展开
- **决策**:执行中、待审批卡片不可折叠,始终展开。
- **原因**:用户需看到骨架屏(执行中)和审批按钮(待操作)。
- **状态**:✅ Sprint 10
### 新内容追加自动收起旧卡
- **决策**deep watch `messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 展开态,保留活跃卡。
- **原因**:多步调用时旧结果折叠为单行 header界面紧凑类 ChatGPT/Cursor
- **状态**:✅ Sprint 10
### 首次加载 / 切对话不触发收起
- **决策**`isFirst` guard首次 snapshot 赋值后直接 return。
- **原因**:防切换对话或初次加载时把当前可见卡片误折叠。
- **状态**:✅ Sprint 10
### 短结果保持展开(审查①)
- **决策**`rejected`(拒绝原因)/ `write_file`(写入路径)短结果用 `shouldKeepOpen(tc)` 强制展开,仅 `read_file`/`list_directory`/通用 JSON 大体量结果折叠。
- **原因**:短结果折叠无紧凑收益,反隐藏关键信息(拒绝原因/写入路径)。
- **状态**:✅ Sprint 10
### 卡片宽度对齐气泡(审查②)
- **决策**:工具卡片 `max-width: 90%`,与 AI 文本气泡(`.ai-msg-bubble` max-width 90%)一致,不撑满 content 区。
- **原因**:卡片原默认 stretch 撑满 100%、气泡 90%视觉宽度不一致list projects 等卡片比回复气泡宽一截)。选限制卡片对齐气泡(非撑满气泡),保留气泡式留白;数据卡片 90% content 宽通常够展示文件树/代码(横向 `overflow-x:auto` 兜底)。
- **状态**:✅ 2026-06-13
### 折叠态 header 显示结果摘要 [2026-06-13]
- **决策**completed 工具卡片在 header 的 `.ai-tool-sub` 槽位(原仅 running 显示「执行中...」)追加结果摘要 `toolResultSummary(tc)`按工具返回结构提取一句话list_tasks→「12 项任务」、list_projects→「5 个项目」、list_ideas→「8 条想法」、create_project→「已创建xxx」、create_idea/task→「已创建title」、update_project→「已更新 status」、delete_project→「已删除」、run_workflow→「请到工作流页面运行」。`.ai-tool-sub` 加 ellipsis 截断防长标题撑破。
- **原因**list_tasks/list_projects/list_ideas/run_workflow 等工具无路径参数(不像 read_file/list_directory header 带路径),折叠态 header 仅剩工具名,光秃秃、信息密度低。摘要放 header非 body使折叠态也能一眼看到核心信息不必先展开。list_* 返回裸数组取 `.length`create 取 `.name`project/`.title`idea/task——字段不对称是后端 model 既定,摘要层各自适配。
- **状态**:✅ 2026-06-13
### 工具调用前 AI 总结文字去气泡 [2026-13](已回滚)
- **决策**AI 消息同时含文字 content 和 toolCalls 时,文字气泡加 `.ai-msg-bubble--plain`(去背景/边框/padding/圆角/max-width变紧凑纯文本紧贴卡片纯文字消息无工具卡片保留完整气泡。→ **已回滚**:恢复所有 AI 消息统一气泡。
- **原因**:原想条件去气泡兼顾紧凑与长回复可读性。→ 回滚原因:用户实测后强调「所有 AI 消息都应在气泡里」,条件去气泡造成两种 AI 消息外观(纯文字有气泡、工具总结无气泡)不一致、突兀。**紧凑不该靠去气泡实现**——牺牲一致性换紧凑得不偿失。未来若要紧凑走「气泡与卡片视觉一体」(卡片纳入气泡 / 连一体块 / 仅压间距)。
- **状态**:✅ 2026-06-13 落地 → 🔄 2026-06-13 回滚(一致性优先,布局方案待用户再定)
### 工具卡片区拆子组件ToolCard + ToolCardList [2026-06-13]
- **决策**:工具卡片区从 AiChat.vue原 1983 行)拆为两个子组件——`ToolCard.vue`单卡纯展示props: `tc`/`isExpanded`/`isContentExpanded`emits: `toggle`/`expand-content`/`approve`+ `ToolCardList.vue`(列表容器,持有折叠态 `expandedCards`/`expandedTools``defineExpose``collapseInactive`。AiChat.vue 仅 `import` + `ref` 持有列表 + 转发 `@approve``store.approveToolCall`
- **原因/取舍**:工具卡片占 AiChat.vue ~650 行(模板 107 + script 150 + CSS 392是高修改频率区折叠/摘要/时间轴等 UI 调整频发。拆出后卡片相关变更隔离在两文件1983 行主文件不再因 UI 调整反复动。auto-collapse 桥接选 `expose`+`ref`(方案 B而非 prop+emit / provide+inject——前者是 Vue3 标准模式、子组件自治折叠态、父级只调一个 `collapseInactive(activeIds)`。附带ToolCard 内用 `parsed` computed 缓存 `parseResult`(原模板 6 次重复 `JSON.parse`,每次 patch 都重解析);气泡与卡片的 `max-width:90%` 提取为全局 `--df-msg-max-width` 变量(原跨文件靠注释维系对齐,改一处忘一处即错位,变量化单一来源)。**深化(可维护性)**ToolCard.vue 再拆双 `<script>` 块——14 纯函数 + `ToolResult` 类型提模块顶层(只建一次,`setup` 聚焦 props/parsed`parseResult` `any``ToolResult|null`(防模板字段名打错编译期不报),`toolDisplayName` 去 i18n fallbackpath 几乎总有,属过度防御)。取舍:为可维护性/易迭代,不为微性能(省闭包可忽略)。
- **状态**:✅ 2026-06-13 落地(重构 Step 1-5 完成AiChat.vue 净减 ~650 行)
---
## 三、AI Chat Token 用量与模型记录 [2026-06-13]
> 主文档保留一句设计结论:**Token 分账本——对话 / 工作流节点 / 标题生成三处独立记账**。其余实现细节归档。
### 流式 token 落库走累加模式(非覆盖)
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
### Anthropic 流式 output_tokens 当累计值直接覆盖
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
### Token 展示默认关闭 + Settings 开关 [2026-06-13]
- **决策**AI 气泡底部 token 条默认不显示,加 `df-show-token-usage` 开关放 Settings 通用设置区,默认 `false`
- **原因**token 是计量信息非核心;用户对「头顶信号」敏感度不一,日常默认隐藏减干扰,需要时开。开关关闭时 store 不写 `lastTokenUsage`、DOM 不渲染,零视觉干扰。
- **状态**:✅ 2026-06-13
### 前端 token 双状态(单次 + 对话累计)[2026-06-13]
- **决策**store 维护 `lastTokenUsage`(最近一条回复)+ `convTokenTotal`(当前对话累计)两个状态,而非单一状态。
- **原因**:单状态切对话会错乱——实时值(当前回复产生的 tokenvs 历史总值(切回老对话应从 DB 读累计)混一起。双状态各司其职:切换时 `convTokenTotal` 从 summary 加载、`lastTokenUsage` 清空。
- **状态**:✅ 2026-06-13
### model 记录策略:取配置值 + 补填不覆盖 [2026-06-13]
- **决策**:记录的 model 取 `provider_config.default_model`(非 stream 响应解析);`save_conversation` insert 时写、update 时仅 `rec.model` 为 None 才补填。
- **原因**:① stream chunk 未必带 model 字段;`default_model` 是确定配置值且即请求所用(`request.model` 就用它发),配置名比响应里的具体版本号(如 `gpt-4o-2024-xx`)对用户更有意义。② model 字段 DB 早存在但老代码 insert 一直填 None补填兼容历史对话下次活动自动回填已有值不覆盖。
- **状态**:✅ 2026-06-13
### token 分账本:对话 / 工作流节点 / 标题生成 [2026-06-13]
> 此为设计结论,**已在主文档保留一句**:三处分属不同成本中心,混进 `ai_conversations` 会让对话 token 失真。
- **决策**:① 对话主流程 token`run_agentic_loop` 流式)落 `ai_conversations`;② 工作流 AI 节点 token 进 `NodeOutput.usage`(写 `node_executions.output_json`),不进对话表;③ 标题生成 token 刻意不记(`generate_title_via_llm``resp.usage` 丢弃)。
- **原因**:三处分属不同成本中心——工作流节点消耗属工作流执行成本,与对话 token 是两个账本,混进 `ai_conversations` 会让对话 token 失真标题生成是系统附带行为max 30 token量小计入对话总量会让用户困惑「只问一句怎么这么多 token」。非流式 `complete()` 的 token Provider 层已返回,仅消费侧按账本分流。
- **状态**:✅ 2026-06-13
### model UI 展示暂缓,留后续升级 [2026-06-13]
- **决策**model 已落库(`ai_conversations.model` + 前端 `AiConversationSummary.model` 字段就绪),但**暂不在对话旁展示**,留作后续升级项。
- **原因**:数据通路已打通(后端写值、前端字段在),展示是纯前端增量、随时可加;当前先把 token 用量展示做稳model 展示留到后续 UI 升级(如对话头部 / token 条旁)统一考虑,避免零散加。
- **状态**:📐 待实施UI 展示,数据已就绪)
### model 追溯:消息级 + 对话级多值C+B[2026-06-13]
- **决策**:消息级 `ChatMessage``model` 字段(每条 assistant 消息记生成它的 model+ 对话级加 `models` 字段JSON 数组,去重存对话用过的所有 model`model` 单值字段保留存首个(兼容已就绪的前端字段 + 补填不覆盖策略不动)。
- **演进**[2026-06-13 初版 📋] 现状为对话级单值 + 补填不覆盖,中途切换只留首个、消息级无字段无法追溯 → [2026-06-13 同日落地] 用户拍板 C+B。A对话级覆盖记最近未选——中途历史仍丢纯 C 不便快速看「用过哪些」,加 B对话级聚合互补。
- **原因**:「每一条对话都能有效记录」需消息级追溯(每条 assistant 消息知道谁生成);对话级 models 聚合便于不解析整条 messages 就知用过哪些(中途切换场景全覆盖)。`model` 单值保留是渐进兼容(前端 `AiConversationSummary.model` 已就绪,展示首个),不强删避免迁移破坏。
- **落地细节**:① `ChatMessage.model: Option<String>`serde default 兼容历史消息);② `run_agentic_loop` 构造 assistant 消息时 `msg.model = Some(provider_config.default_model)`;③ `save_conversation` update 路径 models 去重追加(读旧 JSON 数组 + 新值去重、insert 初始化 `[model]`;④ V6 迁移加 `models` 列;⑤ 前端 `AiMessage.model?` + `Summary.models?`switchConversation 解析历史消息 model。展示仍暂缓见上条
- **记录时机澄清 → [2026-06-13]**models 只追加**实际生成过内容**(本轮 `stream_llm` 跑过、产生 assistant 消息)的 save 调用,不记「配置但未实际用于生成」的 model。需求来自用户澄清「对话集应该不是切换过都留住吧只有实际发送过消息的才留」——首轮即 stop无 stream路径传 `Some(model)` 会把未生成的 model 误写进 models 数组。正确做法:`run_agentic_loop` 入口 `stop_flag` 路径(未 streamsave 传 `None`;仅 stream 后的退出路径(正常完成/stream 后 stop/审批暂停)传 `Some(model)`
- **状态**:✅ 2026-06-13编译/类型双过,未 tauri dev 实测) | ✅ 记录时机已落地2026-06-13入口 stop 路径 save 改传 `None`cargo check + vue-tsc 双过
---
## 四、AI Chat 对话与滚动 UX
> 滚动/错误友好化/空白屏修复/markdown 缓存为 UX 微调,归档。审批「删全屏 Modal 保行内」为设计决策,**已留在主文档**。
### 智能滚动:`isNearBottom` < 80px
- **决策**:仅当用户在底部 80px 内才自动滚动;上滑时不强制拉回。
- **原因**:用户回看历史时被强制拉回底部体验差。
- **状态**:✅ Sprint 8
- **演进** [2026-06-13]:上滑看历史时新消息/流式不滚 → 补「回到底部」浮动按钮。`showBackToBottom`(内容溢出 >100px 且不在底时显)+ `onMessagesScroll` 刷新 + `onContentChange`(在底则滚、上滑则只刷按钮不强制拉回)。沿用「不打断回看」原则,补一键回底入口。
### 错误友好化:`friendlyError` 正则映射
- **决策**404/401/timeout/network 正则映射为中文提示 + `isError` 红色气泡。
- **原因**:原始错误信息对用户不友好。
- **状态**:✅ Sprint 8
### 审批卡片显示参数内容 + 防双击 [2026-06-13]
- **决策**审批卡片pending_approval在按钮上方渲染工具参数键值对`toolArgsEntries` 遍历 args`formatArgValue` 截断超长)+ 风险提示reason`AiToolCallInfo``reason?` 字段AiApprovalRequired 回填。点击批准/拒绝后 store 乐观置 `status='running'` + 按钮 `:disabled="status!=='pending_approval'"`,等后端事件转 completed/rejectedIPC 失败回滚 pending_approval。
- **原因/取舍**:原审批卡片只有「批准/拒绝」按钮 + 工具名update_project/run_workflow用户**盲批**——参数不可见等于放行未知操作。双击无防护则连发 IPC后端 `pending_approvals.remove()` 有幂等不重复执行,但第二次报错弹窗)。乐观置 running 即时禁用按钮 + 回显参数,审批从「盲批」变「知情决策」。
- **状态**:✅ 2026-06-13
### 空白屏修复:防 FOUC
- **决策**`index.html` 加主题防闪烁脚本(渲染前置 `data-theme`+ `__APP_T0` 启动埋点 + body 背景色。
- **原因**Vite 启动慢122s期间 webview 白屏;前置主题脚本消除 FOUC。
- **状态**:✅ Sprint 7
### 流式 markdown 渲染加结果缓存 [2026-06-13]
- **决策**`renderMd``Map<text, html>` 缓存(限 200 项超出清空);`mdReady` 翻转marked+DOMPurify 加载完成)时清缓存重渲。
- **原因/取舍**`v-html="renderMd(...)"` 每次响应式触发(折叠/状态变化/新 delta都跑 marked.parse + DOMPurify.sanitize 全文。历史消息文本不变却重复全量解析O(n) × 触发次数)。缓存后历史消息命中 O(1);流式 currentText 中间态各缓存一项≤200 后清)。**未做**「流式中降级纯文本」——会让流式无格式、完成后突变格式,体验差;缓存方案保留流式格式且消除重复解析。
- **状态**:✅ 2026-06-13
---
## 五、AI Chat 对话管理 [Sprint 10]
### 侧边栏时间分组日历日桶today/yesterday/earlier
- **决策**:活跃对话按日历日分桶(今天/昨天/更早),归档对话单列可折叠区,而非滚动 24h 或纯时间倒序线。
- **原因**:用户心智「今天的对话」指当天而非过去 24 小时;归档是冷区,单列折叠减少对活跃区的视觉干扰。
- **状态**:✅ Sprint 10
### 归档分组默认折叠 + 持久化
- **决策**`archivedCollapsed` 默认 `true`(折叠),折叠态写入 `df-ai-ui` 持久化。
- **原因**:归档为冷区,默认折叠让活跃对话优先可见;持久化记住用户展开偏好,不每次重启都收回。
- **状态**:✅ Sprint 10
### 归档绕过 update_fieldset_archived 走原始 SQL
- **决策**:归档用专用 `set_archived` 方法直接 `UPDATE ai_conversations SET archived = ?1`,不复用 `update_field` 宏。
- **原因**`update_field` 对任何字段操作都强制连带 `SET updated_at = now`,归档是元数据切换不应改对话时间——否则归档后侧栏时间全跳成「刚刚」,破坏时间分组排序。归档与内容更新是两类操作,时间戳语义须区分。
- **状态**:✅ Sprint 10
### 恢复活跃对话加守卫 `!state.activeConversationId`
- **决策**`loadConversations` 自动恢复上次活跃对话时加守卫仅首次加载、ID 仍有效、当前无活跃对话才恢复)。
- **原因**:若用户已手动 new/switch恢复逻辑不应覆盖其当前意图守卫保证恢复只在「冷启动无状态」时介入。
- **状态**:✅ Sprint 10
---
## 六、AI Node工作流节点[任务 #44]
### AI Node 参数解析抽纯函数,单测不 mock LLM [任务 #44]
- **决策**`AiNode::execute` 内的参数解析config + 上游 inputs → `AiNodeParams`)剥离成独立纯函数 `parse_params(config, inputs)`execute 调用后再 `build_provider` + `complete``AiNodeParams``#[derive(Debug)]`unwrap_err 断言需要)。
- **原因/取舍**execute 原本 162 行硬编码 `build_provider`,直接单测会触真 HTTP——需 mock `LlmProvider` trait + 构造完整 `NodeContext`event_bus/node_status/NodeId重且脆弱。抽纯函数只接 `config` + `inputs`execute 实际用到的),参数解析/缺参报错/prompt 上游优先回退/默认值model 空→`gpt-4o-mini`、protocol→`openai_compat`)全可单测,零 mock 零网络。`complete` 调用正确性归 df-ai provider 层测试职责分层。副作用execute 瘦身parse 与 LLM 调用分离,可读性提升。
- **状态**:✅ 已落地2026-06-137 纯函数单测全过缺参×3 / prompt 取值×2 / 默认值 / 显式参数GLM 真调集成测试 `glm_live_complete``#[ignore]`+env var通过glm-4-flash 返回「通过」/usage 正确GUI DAG 触发实测转 → #54 跟踪)
### AI Node 参数边界值契约:空 model 走 provider 兜底 / prompt 双源 [任务 #44]
- **决策**config 无 model 时 `parse_params` 给空 `model` + `default_model="gpt-4o-mini"` 占位字段;`build_provider``default_model`(构造需非空),`CompletionRequest.model` 忠实传空串,由 provider `convert_request``if req.model.is_empty() { self.default_model }`兜底。schema `required``["base_url","api_key"]`,不含 `prompt`prompt 双源:上游 `inputs["prompt"]` > `config.prompt`)。
- **原因/取舍**:① `model`/`default_model` 双字段分离「忠实值」与「构造兜底」——provider 构造要非空 model空则后续无兜底锚点故 default_model 占位防 panic但 request 仍传真实 model让 provider 兜底单点收敛(两 provider convert_request 统一 `is_empty→default_model`),避免 AiNode 自猜默认与 provider 不一致。② prompt 双源支持「prompt 由上游节点产出」(如 read_file→ai 分析schema required 含 prompt 会误拦此合法配置。
- **状态**:✅ 已落地2026-06-13两 provider convert_request 兜底经 review 核实自洽schema required 改后 7 单测全过)
---
## 七、代码审查甄别2026-06-13 一次性审查流水)
> 全模块代码审查后的甄别落地。原则性结论(「真实 bug 修、简单清理做、规模不到位的优化先不动」)**已在主文档「需求澄清」保留**。以下是具体落地条目。
### df-evolve 骨架物理删除(连带三件套)
- **决策**:删 `KnowledgeStore` + `PatternExtractor` + `EvolveEngine` 三件全 TODO 空壳;同步删 df-nodes 5 个空节点docker/git/http/notify/subflow
- **原因/取舍**KnowledgeStore 被 EvolveEngine 唯一引用、PatternExtractor 同理——删前两者必连带删 EvolveEngine编译依赖原「删 2 个」甄别后扩为三件套。src-tauri Cargo.toml 声明依赖 df-evolve 但源码零 `use df_evolve`,整坨孤立骨架。`Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型保留(有实现 + 测试Tier 1 Service 复用)。
- **状态**:✅ 2026-06-13cargo check 通过)
### 列表查询加过滤(内存策略)
- **决策**`ai_conversation_list` 加 limit默认 50+ include_archived默认 falseAI 工具 `list_projects`/`list_tasks`/`list_ideas` 闭包加 `truncate(50)``build_system_prompt` 项目注入 `take(20)``list_ideas` 命令加 status 可选过滤。均用内存 filter/take 或现成 `query`,不加新 SQL 方法。
- **原因/取舍**dev 阶段数据量小,内存过滤零触碰通用 `impl_repo!` 宏(改宏风险高)。数据真到成千上万再加 Repo 的 `list_limited` SQL 方法。`list_ideas` 复用现成 `query("status", v)` 走白名单,零改 Repo。前端 API 加可选参数,旧无参调用兼容(后端默认值兜底)。
- **状态**:✅ 2026-06-13
### node_executions「全表 list」甄别为伪命题
- **决策**审查提出的「node_executions 全表 list 改按 execution_id 过滤」**不成立**——全仓仅 state.rs 注册 NodeExecutionRepo无任何 list/query 命令对外暴露只写不读cargo `dead_code` warning 印证)。
- **原因**:不存在「废弃全表 list」的对象。若未来前端要看某次工作流执行的节点明细需**新增** `list_node_executions(execution_id)` 命令,属新功能非清理。
- **状态**:📐 待需求驱动新增
### ALLOWED_COLUMNS 从全局共享演进为按表隔离
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
---
## 八、状态持久化UX 偏好部分)
### UI 布局localStorage + 模块级恢复
- **决策**面板布局panelOpen/maximized/sidebarOpen/archivedCollapsed`df-ai-ui``restoreUiState()` 放模块顶层state 单例定义后)执行一次,而非 `useAiStore()` 内部。
- **原因**`state` 是模块级单例,`useAiStore()` 被 App.vue/AiChat.vue/AiDetached.vue 多处调用——放内部会重复执行恢复、覆盖用户当次操作。模块级只跑一次才对。
- **状态**:✅ Sprint 10
> 注:窗口位置/大小用 tauri-plugin-window-state、detached/docked 不持久化两条**已在主文档保留**(属设计决策)。
---
## 九、i18n实现细节
### i18n 模块必须命名空间化导出
> 已移入 [经验记录.md](./经验记录.md)「踩坑」分组。
### 状态枚举 i18nconstants 存 keyview 包 $t
- **决策**`constants/project.ts``PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key`planning: 'projects.status.planning'``projectStatusLabel`/`taskStatusLabel` 返回 keyview 显示处包 `$t(projectStatusLabel(x))``PRIORITY_LABELS`P0/P1是代号非文案不动。
- **原因**constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 localeconstants 管映射结构。
- **状态**:✅ 已落地
---
**相关文档**
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格(当前真相源)
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训