# 功能决策记录 — 归档 > 从 [功能决策记录-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` 直接改为 `ContextManager`,后者成为消息的唯一持有者(内含 `Vec` + 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>>`——全局并发默认 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>>` 双层包装而非裸 `Arc`——tokio Semaphore permits 构造时固定不可增减,替换内层 Arc 即重建,已持有旧 permit 不受影响。permit 放叶子调用点(最接近真实 HTTP 调用)而非 command 入口,限流粒度精准且不阻塞非 LLM 路径。 - **状态**:✅ 已落地(2026-06-13) ### per_conv 实为应用级单一信号量(非 per-conv map)[任务 #43 / Review 修正] - **决策**:`LlmConcurrency.per_conv` 字段命名暗示「单对话」并发,但实现是应用级**单一** `Semaphore`(非 `HashMap`)。当前不修实现,仅在 `state.rs` 结构体 doc 标注真实语义 + 未来重构路径。 - **原因/取舍**:`AiSession` 为 `Arc>` 单例,且 `generating` 互斥保证同一时刻仅一个对话的 `run_agentic_loop` 在跑。故 per_conv 实际退化为「单对话内并发」(主循环 stream_llm + 标题生成 + 知识提炼三者受限流约束)——命名虽宽泛但当前语义恰好正确,非 bug。改 HashMap 是过度设计(单例会话下多 map 项永不被并发访问)。风险留待未来:若支持多对话并发 loop,per_conv 需随之改 `HashMap` 才名副其实。揭示 `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, 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` 直接替换为 `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 再拆双 `