# 功能决策记录 — 归档

> 从 [功能决策记录-2026-06-14.md](./功能决策记录-2026-06-14.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 给 ContextManager，token 缓存永远滞后一轮或每次重建（双重复制）。类型替换让 push 即刻更新 token 计数、零额外 clone、单一数据源。代价是 `save_conversation` 等需全量的场景要显式调 `all_messages_clone()`（多一行但语义明确）。13 处操作点中 6 处签名不变（push/clear）、4 处微调（clone→all_messages_clone/iter）、2 处需重写。
- **状态**：✅ 已落地（2026-06-13，cargo check + vue-tsc 通过，待 tauri dev 实测）

### Token 计数方案：字符粗估零依赖 [任务 #43]

- **决策**：用 `chars_count × 0.35` 粗估单条消息 token 数（~2.8 字符/token），每条消息 +4 token 固定开销（role 标记），每个 tool_call +30 token（JSON 结构）。不引入 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 一轮执行中会多次 push（assistant 回复 → 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/O（read_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`（两 Arc，cheap）作为参数串到底，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 项永不被并发访问）。风险留待未来：若支持多对话并发 loop，per_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_end（O(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/iter），13 处中 6 处零改动，改动面最小。核心改造点 run_agentic_loop 由 `session.messages.clone()` 全量塞入改为 `build_for_request(sys_tokens)`：调用方用 `TokenEstimator::estimate_text(&system_prompt)` 估算 system prompt token，ContextManager 只接收数值不接触 prompt 文本。
- **状态**：✅ ai.rs 已落地（2026-06-13，cargo 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 循环逐个串行 execute，N 个独立 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 保持同步 await，title 改 `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 fallback（path 几乎总有，属过度防御）。取舍：为可维护性/易迭代，不为微性能（省闭包可忽略）。
- **状态**：✅ 2026-06-13 落地（重构 Step 1-5 完成，AiChat.vue 净减 ~650 行）

---

## 三、AI Chat Token 用量与模型记录 [2026-06-13]

> 主文档保留一句设计结论：**Token 分账本——对话 / 工作流节点 / 标题生成三处独立记账**。其余实现细节归档。

### 流式 token 落库走累加模式（非覆盖）

> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「约定」分组。

### Anthropic 流式 output_tokens 当累计值直接覆盖

> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.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`（当前对话累计）两个状态，而非单一状态。
- **原因**：单状态切对话会错乱——实时值（当前回复产生的 token）vs 历史总值（切回老对话应从 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` 路径（未 stream）save 传 `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/rejected，IPC 失败回滚 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_field（set_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-13，7 纯函数单测全过：缺参×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-13（cargo check 通过）

### 列表查询加过滤（内存策略）

- **决策**：`ai_conversation_list` 加 limit（默认 50）+ include_archived（默认 false）；AI 工具 `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 从全局共享演进为按表隔离

> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.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 模块必须命名空间化导出

> 已移入 [经验记录-2026-06-14.md](./经验记录-2026-06-14.md)「踩坑」分组。

### 状态枚举 i18n：constants 存 key，view 包 $t

- **决策**：`constants/project.ts` 的 `PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key（`planning: 'projects.status.planning'`）；`projectStatusLabel`/`taskStatusLabel` 返回 key；view 显示处包 `$t(projectStatusLabel(x))`。`PRIORITY_LABELS`（P0/P1）是代号非文案，不动。
- **原因**：constants 是纯数据/结构层，不应含展示文案（违反分层）；文案归 locale，constants 管映射结构。
- **状态**：✅ 已落地

---

**相关文档**：
- [功能决策记录](./功能决策记录-2026-06-14.md) — 需求规格 + 设计决策规格（当前真相源）
- [经验记录](./经验记录-2026-06-14.md) — 踩坑/约定/技巧/bug 排查教训
