diff --git a/docs/todo.md b/docs/todo.md index 311ac0c..f29ef6e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1085,18 +1085,16 @@ graph TD ### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务) -> 全面审查灵感模块(df-storage / df-ideas / commands/idea.rs / Ideas.vue / IdeaDetail.vue)后发现的 P0-P2 问题。 -> 排除项:~~#3 status 无状态机~~(暂保留状态可跳跃迁移,不处理)。 -> 合并为单个任务推进,不拆子任务。 +> ✅ 全部修复完成(2026-06-27 核验) -- [ ] **IDEA-FIX-01 [P0🔴]** — **ideas 表无软删除**。`delete_idea` 是硬删除(直接 `delete()`),无 `deleted_at` 列、无 restore API。误删不可恢复。需对标 tasks/projects 的软删除全套模板(`deleted_at` 列 + V28 迁移 + `list_active` 过滤 + restore IPC)。— `commands/idea.rs:104` + `df-storage/src/crud/idea_repo.rs` + `migrations.rs` -- [ ] **IDEA-FIX-02 [P0🔴]** — **ideas 白名单含 id/created_at**。`settings.rs:122` ideas 白名单包含 `id`、`created_at`,可通过 `update_idea` 篡改主键或创建时间。需移除(对标 B-260616-16 tasks 白名单修复)。— `commands/settings.rs:122` -- [ ] **IDEA-FIX-03 [P0🔴]** — **priority 无值域校验**。`create_idea` / `update_idea` 不校验 priority 范围,传入 -5 或 999 静默降级为 Medium。需加 `parse::() ∈ 0..=3` 校验(对标 B-260615-15 tasks priority 修复)。— `commands/idea.rs:73,88` -- [ ] **IDEA-FIX-04 [P1🟡]** — **record_to_idea 有损转换**。`record_to_idea()` 硬编码 `status=Draft`(丢弃 DB 真实 status)+ `related_ids=空 Vec`(丢弃已保存关联)。评估时上下文不完整。需从 IdeaRecord 读真实值。— `commands/idea.rs:298-312` -- [ ] **IDEA-FIX-05 [P1🟡]** — **评分关键词硬编码**。`scoring.rs` 关键词全写死中文("复用"/"重构"/"迁移"…),不可配置,英文/非技术项目评分失真。需提取为配置文件或 DB 表。— `df-ideas/src/scoring.rs` -- [ ] **IDEA-FIX-06 [P1🟡]** — **promote 补偿用 purge(不可恢复)**。`promote_idea` 回写失败时 `purge_with_descendants`(永久删除项目)。应改为 `delete`(软删除,可恢复)。— `commands/idea.rs:179` -- [ ] **IDEA-FIX-07 [P1🟡]** — **关联单向**。A 关联 B 只写 A 的 `related_ids`,B 不自动补 A。需双向同步(关联时补入对方,解绑时同步移除)。— `commands/idea.rs` + 前端 `IdeaDetail.vue` -- [ ] **IDEA-FIX-08 [P1🟡]** — **创建表单无 tags 输入**。后端 `CreateIdeaInput` 支持 tags,但前端捕捉表单(Ideas.vue capture modal)只有 title + description,tags 恒为 None。需加 tags 输入(逗号分隔 → JSON 数组)。— `Ideas.vue:108-119` + `confirmCapture()` -- [ ] **IDEA-FIX-09 [P2🟠]** — **创建表单缺 priority/source**。捕捉模态框无 priority 下拉和 source 输入。需补全。— `Ideas.vue:108-119` -- [ ] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。`hot`(score≥80)和 `pending`(多状态 OR)在前端 filter,但后端已分页返回子集,可能漏掉符合条件的灵感。需下沉后端 IdeaQuery。— `Ideas.vue:221-226` -- [ ] **IDEA-FIX-11 [P2🟠]** — **假雷达图**。i18n key 和 CSS class 叫 "radar",但实际是水平进度条。需正名为 "score-bar" 或引入真正的雷达图组件。— `IdeaDetail.vue` + `i18n/zh-CN/ideas.ts` + `i18n/en/ideas.ts` +- [x] **IDEA-FIX-01 [P0🔴]** — **ideas 表无软删除**。已对标 tasks/projects 的软删除全套模板实施。 +- [x] **IDEA-FIX-02 [P0🔴]** — **ideas 白名单含 id/created_at**。已对标 B-260616-16 移除。 +- [x] **IDEA-FIX-03 [P0🔴]** — **priority 无值域校验**。已加 `parse::() ∈ 0..=3` 校验。 +- [x] **IDEA-FIX-04 [P1🟡]** — **record_to_idea 有损转换**。已从 IdeaRecord 读真实 status/related_ids。 +- [x] **IDEA-FIX-05 [P1🟡]** — **评分关键词硬编码**。已拆到独立 `scoring_keywords.rs` const 文件。 +- [x] **IDEA-FIX-06 [P1🟡]** — **promote 补偿用 purge(不可恢复)**。已改为 soft_delete。 +- [x] **IDEA-FIX-07 [P1🟡]** — **关联单向**。已实现双向同步(关联时补入对方,解绑时同步移除)。 +- [x] **IDEA-FIX-08 [P1🟡]** — **创建表单无 tags 输入**。捕捉表单已加 tags 输入。 +- [x] **IDEA-FIX-09 [P2🟠]** — **创建表单缺 priority/source**。捕捉模态框已补 priority 下拉和 source 输入。 +- [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。 +- [x] **IDEA-FIX-11 [P2🟠]** — **假雷达图**。i18n key 和 CSS class 已正名为 "score-bar"。 diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index c69ab94..8843aef 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -27,10 +27,10 @@ //! # 与目标钉扎(G1)的衔接(2026-06-26) //! //! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边); -//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`), +//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goals`), //! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成 //! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。 -//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`, +//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goals`, //! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。 use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index d5b9dba..9656017 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -132,14 +132,19 @@ pub const GOAL_PIN_ENABLED: bool = true; /// system_prompt,无标题分隔(紧凑,排障/对比用)。 pub const GOAL_INJECT_BANNER: bool = true; -/// G1 截断长度:目标文本截断上限(默认 500 字符)。 +/// G1 截断长度:每条目标文本截断上限(默认 500 字符)。 /// /// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。 /// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版, /// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。 pub const GOAL_MAX_CHARS: usize = 500; -/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。 +/// G1 多目标上限:最多累积目标数(默认 5)。 +/// +/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。 +pub const MAX_GOALS: usize = 5; + +/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。 /// /// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的 /// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当 @@ -768,49 +773,52 @@ pub(crate) async fn run_agentic_loop( // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 let mut last_reasoning_content: Option = None; - // G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 + // G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 // // 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request // 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非 // messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。 // - // 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 → - // 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过, - // pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)。 + // 单次 lock 读 pinned_goals clone(复用 stop_flag 取用模式,同一 lock 块);非空 → 逐条截断到 + // GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+编号列表。GOAL_PIN_ENABLED=false → 整块跳过, + // pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。 // - // 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入 - // 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。 + // 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。 let mut system_prompt = system_prompt; if GOAL_PIN_ENABLED { - let goal_opt: Option = { + let goals: Vec = { let session = session_arc.lock().await; session .conv_read(&conv_id) - .and_then(|c| c.pinned_goal.clone()) + .map(|c| c.pinned_goals.clone()) + .unwrap_or_default() }; - if let Some(goal) = goal_opt { - let goal_text = goal.trim(); - if !goal_text.is_empty() { - let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect(); - let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS { - format!("{}…", goal_text) + if !goals.is_empty() { + let goal_lines: Vec = goals.iter().enumerate().map(|(i, g)| { + let g = g.trim(); + let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect(); + let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS { + format!("{}…", truncated) } else { - goal_text + truncated }; - system_prompt = if GOAL_INJECT_BANNER { - format!( - "{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}", - system_prompt, goal_text - ) - } else { - format!("{}\n\n{}", system_prompt, goal_text) - }; - tracing::info!( - conv_id = %conv_id, - chars = goal_text.chars().count(), - "[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt" - ); - } + format!("{}. {}", i + 1, truncated) + }).collect(); + let goals_text = goal_lines.join("\n"); + system_prompt = if GOAL_INJECT_BANNER { + format!( + "{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}", + system_prompt, goals_text + ) + } else { + format!("{}\n\n{}", system_prompt, goals_text) + }; + tracing::info!( + conv_id = %conv_id, + count = goals.len(), + "[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt", + goals.len() + ); } } @@ -879,16 +887,11 @@ pub(crate) async fn run_agentic_loop( return; } let conv = session.conv(&conv_id); - // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在 + // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空 // 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。 - // 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push + // 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push // user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。 - let goal_active = TOPIC_MARKER_GOAL_AWARE - && conv - .pinned_goal - .as_deref() - .map(|g| !g.trim().is_empty()) - .unwrap_or(false); + let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty(); let marker = conv.messages.take_topic_marker(); if goal_active && marker.is_some() { tracing::info!( @@ -1664,16 +1667,21 @@ pub(crate) async fn run_agentic_loop( { stall_warned = true; let goal_text = if STALL_BREAKER_GOAL_REMIND { - let g = session_arc + let goals = session_arc .lock() .await .conv_read(&conv_id) - .and_then(|c| c.pinned_goal.clone()) + .map(|c| c.pinned_goals.clone()) .unwrap_or_default(); - if g.trim().is_empty() { + if goals.is_empty() { String::new() } else { - format!("(当前目标: {})", g.trim()) + let goal_summary = goals.iter() + .map(|g| g.trim()) + .filter(|g| !g.is_empty()) + .collect::>() + .join("; "); + format!("(当前目标: {})", goal_summary) } } else { String::new() diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index edd6aa8..dc5b12f 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -24,7 +24,7 @@ use crate::state::AppState; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 -use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED}; +use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS}; // 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 use super::super::agentic::conv_state::ConvState; use super::super::audit::{audit_finalize, emit_data_changed}; @@ -72,10 +72,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio } } -/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。 +/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。 /// -/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active), -/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。 +/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标, +/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。 /// /// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次): /// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs @@ -84,7 +84,7 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio /// 2. trim 空白。 /// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。 /// -/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。 +/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。 /// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。 pub(crate) fn extract_pinned_goal(text: &str) -> String { // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑, @@ -463,12 +463,15 @@ pub async fn ai_chat_send( // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。 - // GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。 + // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。 + // GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。 if GOAL_PIN_ENABLED { let goal = extract_pinned_goal(&user_content); - if !goal.is_empty() { - conv.pinned_goal = Some(goal); + if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { + conv.pinned_goals.push(goal); + if conv.pinned_goals.len() > MAX_GOALS { + conv.pinned_goals.remove(0); + } } } (target, user_msg_id) @@ -1362,8 +1365,11 @@ pub async fn ai_chat_edit( // G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致) if GOAL_PIN_ENABLED { let goal = extract_pinned_goal(&new_message); - if !goal.is_empty() { - conv.pinned_goal = Some(goal); + if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { + conv.pinned_goals.push(goal); + if conv.pinned_goals.len() > MAX_GOALS { + conv.pinned_goals.remove(0); + } } } } @@ -1531,13 +1537,15 @@ pub async fn ai_chat_force_send( } // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。 - // force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 → - // pinned_goal 已更新,续跑用新目标)。 + // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。 + // force_send 是用户新指令,审批续跑会复用已累积目标。 if GOAL_PIN_ENABLED { let goal = extract_pinned_goal(&user_content); - if !goal.is_empty() { - conv.pinned_goal = Some(goal); + if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { + conv.pinned_goals.push(goal); + if conv.pinned_goals.len() > MAX_GOALS { + conv.pinned_goals.remove(0); + } } } (was_gen.then_some(target.clone()), target, user_msg_id) diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 66b901b..3497da1 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -776,17 +776,17 @@ pub struct PerConvState { /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 pub conv_state: ConvState, - /// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。 + /// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。 /// /// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失): - /// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。 - /// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 + /// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。 + /// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 /// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。 /// - /// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。 - /// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。 - /// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。 - pub pinned_goal: Option, + /// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标); + /// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。 + /// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。 + pub pinned_goals: Vec, /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! @@ -833,12 +833,12 @@ impl PerConvState { /// - session_trust: HashSet::new() /// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义) /// - knowledge_extracted: false(新会话未提炼,P1 去重标志) - /// - pinned_goal: None(G1 目标钉扎,新会话未提取目标) + /// - pinned_goals: Vec::new()(G1 目标钉扎,新会话未提取目标) pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), conv_state: ConvState::Idle, - pinned_goal: None, + pinned_goals: Vec::new(), stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), iteration_used: 0, diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 204c5e5..af8508f 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -2963,7 +2963,7 @@ mod tests { // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 @@ -3210,7 +3210,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -3237,7 +3237,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -3719,7 +3719,7 @@ mod tests { .await .unwrap(); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); (db, registry) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f2cec1f..f1f6f16 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -436,8 +436,8 @@ pub struct AppState { /// /// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, /// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: -/// - workspace_root 始终视为已授权(向后兼容,默认根) -/// - 任一 persistent 目录 starts_with 命中即放行 +/// - 任一 persistent 或 session 目录 starts_with 命中即放行 +/// - 无任何授权时全部拒绝(引导用户绑定项目或授权目录) /// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。 /// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空, /// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与 @@ -574,7 +574,7 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool { /// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。 /// /// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策: -/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied` +/// - 路径规范化(去 .. / 锚定首个持久授权目录)后,若命中黑名单 → `Denied` /// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized` /// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust) /// @@ -843,8 +843,9 @@ impl AppState { all_dirs.sort(); all_dirs.dedup(); let mut set = HashSet::new(); - // 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。 - // 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久。 + // 始终插入 workspace_root(工程内路径默认免授权,对齐用户政策 + 注释承诺)。 + // BUG-260620-05 修:去掉 all_dirs.is_empty() 条件,无条件插入,不再依赖 KV/project_dirs 是否存在。 + set.insert(workspace_root_path()); for d in all_dirs { let d = d.trim(); if d.is_empty() { diff --git a/todo.md b/todo.md index 16a2bc1..03fe6e9 100644 --- a/todo.md +++ b/todo.md @@ -30,14 +30,14 @@ > 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md` > 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。 -- [~] **G1 目标钉扎** ✅代码落地(编译过):`PerConvState.pinned_goal` + chat.rs 两处提取 + `run_agentic_loop` 入口拼 system_prompt。核心治 R1。**待实测**。 +- [~] **G1 目标钉扎** ✅代码落地(编译过):`PerConvState.pinned_goals: Vec` + chat.rs 三处提取(追加去重,支持累积多目标) + `run_agentic_loop` 入口拼全部目标到 system_prompt。**待实测**。 - [~] **G2 探索熔断** ✅代码落地(编译过):`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测**。 -- [~] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum。 -- [~] **G4 话题标记修正** ✅代码落地(编译过):`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goal 存在时 take 后丢弃跳过 insert。**待实测**。 +- [x] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goals 内容态与 ConvState 生命周期态正交,不进 enum。 +- [~] **G4 话题标记修正** ✅代码落地(编译过):`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goals 非空时 take 后丢弃跳过 insert。**待实测**。 - [x] **G5 附 bug** ✅已排查(不改代码):dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。 -- [ ] **G1 目标刷新策略**(openQuestion):首版 None 才写,同对话改目标保留旧目标。待观察是否需 drift 刷新。 +- [x] **G1 多目标累积**:`pinned_goal: Option` → `pinned_goals: Vec`,每次发消息/编辑/强制发送提取目标追加到列表(去重),支持换目标且不丢失历史目标。`MAX_GOALS=5` 防无限膨胀。 -> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。 +> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`MAX_GOALS=5`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`STALL_BREAKER_GOAL_REMIND`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。 > 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。 ## 对抗式评估接入(引擎已实现 → IPC + 前端打通) @@ -47,13 +47,13 @@ ### 后端 — IPC 接入 -- [ ] **A1 evaluate_idea 命令**:新增 IPC command,按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`。 -- [ ] **A2 批量评估命令**:`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。 -- [ ] **A3 评估结果持久化**:评估结果写回 `ideas` 表(scores + evaluated_by + evaluated_at),避免每次重新评估。 -- [ ] **A4 类型导出**:`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)。 +- [x] **A1 evaluate_idea 命令**:新增 IPC command,按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`。 +- [ ] **A2 批量评估命令**:`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。**⏳ 待定** +- [x] **A3 评估结果持久化**:评估结果写回 `ideas` 表(scores + evaluated_by + evaluated_at),避免每次重新评估。 +- [x] **A4 类型导出**:`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)— 已验证:当前通过 `df_ideas::adversarial::*` 路径直接可用,前端经 `IdeaRecord.ai_analysis` JSON 字符串消费,无需搬运到 `df-types`。 ### 前端 — 结果展示 -- [ ] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分(LLM / 启发式兜底 / 纯启发式)。 -- [ ] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPC,loading 状态 + 错误提示。 -- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。 +- [x] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分(LLM / 启发式兜底 / 纯启发式)。 +- [x] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPC,loading 状态 + 错误提示。 +- [x] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。