重构:删 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/儿童每日打卡应用/ 与本项目无关,已排除。
41 KiB
41 KiB
功能决策记录 — 归档
从 功能决策记录.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+ emitAiCompleted,不阻塞前端收到完成事件。 - 原因/取舍:当前 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_configIPC,防止快速连续拖动滑块/按键时频繁重建 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
首次加载 / 切对话不触发收起
- 决策:
isFirstguard,首次 snapshot 赋值后直接 return。 - 原因:防切换对话或初次加载时把当前可见卡片误折叠。
- 状态:✅ Sprint 10
短结果保持展开(审查①)
- 决策:
rejected(拒绝原因)/write_file(写入路径)短结果用shouldKeepOpen(tc)强制展开,仅read_file/list_directory/通用 JSON 大体量结果折叠。 - 原因:短结果折叠无紧凑收益,反隐藏关键信息(拒绝原因/写入路径)。
- 状态:✅ Sprint 10
卡片宽度对齐气泡(审查②)
- 决策:工具卡片
max-width: 90%,与 AI 文本气泡(.ai-msg-bubblemax-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 内用parsedcomputed 缓存parseResult(原模板 6 次重复JSON.parse,每次 patch 都重解析);气泡与卡片的max-width:90%提取为全局--df-msg-max-width变量(原跨文件靠注释维系对齐,改一处忘一处即错位,变量化单一来源)。深化(可维护性):ToolCard.vue 再拆双<script>块——14 纯函数 +ToolResult类型提模块顶层(只建一次,setup聚焦 props/parsed),parseResultany→ToolResult|null(防模板字段名打错编译期不报),toolDisplayName去 i18n fallback(path 几乎总有,属过度防御)。取舍:为可维护性/易迭代,不为微性能(省闭包可忽略)。 - 状态:✅ 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(当前对话累计)两个状态,而非单一状态。 - 原因:单状态切对话会错乱——实时值(当前回复产生的 token)vs 历史总值(切回老对话应从 DB 读累计)混一起。双状态各司其职:切换时
convTokenTotal从 summary 加载、lastTokenUsage清空。 - 状态:✅ 2026-06-13
model 记录策略:取配置值 + 补填不覆盖 [2026-06-13]
- 决策:记录的 model 取
provider_config.default_model(非 stream 响应解析);save_conversationinsert 时写、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_conversationupdate 路径 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——需 mockLlmProvidertrait + 构造完整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忠实传空串,由 providerconvert_request(if req.model.is_empty() { self.default_model })兜底。schemarequired仅["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_limitedSQL 方法。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_codewarning 印证)。 - 原因:不存在「废弃全表 list」的对象。若未来前端要看某次工作流执行的节点明细,需新增
list_node_executions(execution_id)命令,属新功能非清理。 - 状态:📐 待需求驱动新增
ALLOWED_COLUMNS 从全局共享演进为按表隔离
已移入 经验记录.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「踩坑」分组。
状态枚举 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 管映射结构。
- 状态:✅ 已落地
相关文档: