Files
DevFlow/docs/02-架构设计/功能决策记录-归档.md
绝尘 cf017f81e2 新增: 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/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

41 KiB
Raw Blame History

功能决策记录 — 归档

功能决策记录.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 调用点 acquirerun_agentic_loop 内 stream_llm 前、generate_title_via_llmextract_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 标注真实语义 + 未来重构路径。
  • 原因/取舍AiSessionArc<Mutex<AiSession>> 单例,且 generating 互斥保证同一时刻仅一个对话的 run_agentic_loop 在跑。故 per_conv 实际退化为「单对话内并发」(主循环 stream_llm + 标题生成 + 知识提炼三者受限流约束)——命名虽宽泛但当前语义恰好正确,非 bug。改 HashMap 是过度设计(单例会话下多 map 项永不被并发访问)。风险留待未来:若支持多对话并发 loopper_conv 需随之改 HashMap<conv_id, Semaphore> 才名副其实。揭示 LlmConcurrencyAiSession 单例的隐式耦合——本次 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..] clonedrain 修改自身。删除原会 self.messages.drain(0..trim_end)trim_to_budget 方法。
  • 原因/取舍:原 mutate 实现违背「裁剪仅影响发送视图」契约——drainall_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.messagesVec<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 savemaybe_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 显隐)与 expandedToolsread_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_* 返回裸数组取 .lengthcreate 取 .nameproject/.titleidea/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/isContentExpandedemits: toggle/expand-content/approve+ ToolCardList.vue(列表容器,持有折叠态 expandedCards/expandedToolsdefineExposecollapseInactive。AiChat.vue 仅 import + ref 持有列表 + 转发 @approvestore.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/parsedparseResult anyToolResult|null(防模板字段名打错编译期不报),toolDisplayName 去 i18n fallbackpath 几乎总有,属过度防御)。取舍:为可维护性/易迭代,不为微性能(省闭包可忽略)。
  • 状态 2026-06-13 落地(重构 Step 1-5 完成AiChat.vue 净减 ~650 行)

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

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

流式 token 落库走累加模式(非覆盖)

已移入 经验记录.md「约定」分组。

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

已移入 经验记录.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 失真。

  • 决策:① 对话主流程 tokenrun_agentic_loop 流式)落 ai_conversations;② 工作流 AI 节点 token 进 NodeOutput.usage(写 node_executions.output_json),不进对话表;③ 标题生成 token 刻意不记(generate_title_via_llmresp.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]

  • 决策:消息级 ChatMessagemodel 字段(每条 assistant 消息记生成它的 model+ 对话级加 models 字段JSON 数组,去重存对话用过的所有 modelmodel 单值字段保留存首个(兼容已就绪的前端字段 + 补填不覆盖策略不动)。
  • 演进[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 改传 Nonecargo 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 遍历 argsformatArgValue 截断超长)+ 风险提示reasonAiToolCallInforeason? 字段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]

  • 决策renderMdMap<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 + completeAiNodeParams#[derive(Debug)]unwrap_err 断言需要)。
  • 原因/取舍execute 原本 162 行硬编码 build_provider,直接单测会触真 HTTP——需 mock LlmProvider trait + 构造完整 NodeContextevent_bus/node_status/NodeId重且脆弱。抽纯函数只接 config + inputsexecute 实际用到的),参数解析/缺参报错/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_providerdefault_model(构造需非空),CompletionRequest.model 忠实传空串,由 provider convert_requestif req.model.is_empty() { self.default_model }兜底。schema required["base_url","api_key"],不含 promptprompt 双源:上游 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「约定」分组。


八、状态持久化UX 偏好部分)

UI 布局localStorage + 模块级恢复

  • 决策面板布局panelOpen/maximized/sidebarOpen/archivedCollapseddf-ai-uirestoreUiState() 放模块顶层state 单例定义后)执行一次,而非 useAiStore() 内部。
  • 原因state 是模块级单例,useAiStore() 被 App.vue/AiChat.vue/AiDetached.vue 多处调用——放内部会重复执行恢复、覆盖用户当次操作。模块级只跑一次才对。
  • 状态 Sprint 10

注:窗口位置/大小用 tauri-plugin-window-state、detached/docked 不持久化两条已在主文档保留(属设计决策)。


九、i18n实现细节

i18n 模块必须命名空间化导出

已移入 经验记录.md「踩坑」分组。

状态枚举 i18nconstants 存 keyview 包 $t

  • 决策constants/project.tsPROJECT_STATUS_LABELS/TASK_STATUS_LABELS 值从中文文案改存 i18n keyplanning: 'projects.status.planning'projectStatusLabel/taskStatusLabel 返回 keyview 显示处包 $t(projectStatusLabel(x))PRIORITY_LABELSP0/P1是代号非文案不动。
  • 原因constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 localeconstants 管映射结构。
  • 状态 已落地

相关文档