squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
408 lines
41 KiB
Markdown
408 lines
41 KiB
Markdown
# 功能决策记录 — 归档
|
||
|
||
> 从 [功能决策记录-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 排查教训
|