新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -1,6 +1,6 @@
//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理
//!
//! 由原单文件 ai.rs(2663 行)按职责拆分为 11 个子模块。
//! 由原单文件 ai.rs(2663 行)按职责拆分为 15 个子模块。
//!
//! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数
@@ -15,6 +15,9 @@
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//! - [`augmentation`] — 上下文增强
//! - [`http`] — HTTP 调用封装
//! - [`secret`] — 密钥/凭证管理
//!
//! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端
//! - AiTextDelta: 流式文本片段
@@ -23,6 +26,7 @@
//! - AiCompleted/AiError: 完成/错误
pub mod agentic;
pub mod augmentation;
pub mod audit;
pub mod commands;
pub mod compress;
@@ -62,9 +66,12 @@ pub use self::commands::*;
#[allow(unused_imports)]
pub use self::audit::restore_pending_approvals;
#[allow(unused_imports)]
pub use self::knowledge_inject::{spawn_embedding_for_knowledge, trigger_extraction_now};
pub use self::knowledge_inject::{retry_failed_embeddings, spawn_embedding_for_knowledge, trigger_extraction_now};
#[allow(unused_imports)]
pub use self::tool_registry::build_ai_tool_registry;
// Input Augmentation 层(核心设计2):ResolverRegistry 构建 helper(state.rs init 调用)
#[allow(unused_imports)]
pub use self::augmentation::build_resolver_registry;
// ============================================================
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
@@ -151,7 +158,7 @@ pub enum AiChatEvent {
/// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
///
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
/// 复用审批挂起架构(PendingApproval.path_auth 标记路径授权挂起),
/// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并),
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
AiDirAuthRequired {
@@ -183,7 +190,9 @@ pub enum AiChatEvent {
///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval
/// 即使同时在流式)> `generating`Streaming> 都不满足Idle
#[allow(dead_code)] // SW-260618-21 b:预留读视图(SW-02 类终态化复用·当前 0 消费者·保留扩展点)
// SW-260618-21 b:预留读视图(0 消费者)。path_auth 审批链阶段1 接入后有消费者
// (try_continue_agent_loop / ai_chat_stop 经 session_state 替代手写 has_pending),不再 dead_code。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionState {
/// 空闲:既未生成、也无挂起审批
Idle,
@@ -305,21 +314,20 @@ pub struct AiSession {
pub active_conversation_id: Option<String>,
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub active_conv_created_at: Option<String>,
/// 挂起的审批。
/// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)
///
/// **结构**:单层 `HashMap<tool_call_id, PendingApproval>`,按 `tool_call_id`
/// 路由审批结果(`tool_call_id` 是路由键)。`PendingApproval.conversation_id`
/// 是业务语义哪个对话产生的审批而非路由键——IPC 端按 `tool_call_id` 精确命中。
/// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap,
/// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`);
/// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单),
/// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。
/// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。
///
/// **现状**`ai_pending_tool_calls` 等读取路径按 `conversation_id` 做 O(n) 线性
/// 过滤。在典型场景(单对话、少量并发审批)下 n 极小O(n) 可接受,无需二级索引
///
/// **未来**:若多会话并发、审批量显著增大,可考虑加二级索引
/// `conversation_id → Vec<tool_call_id>`)把按会话过滤降到 O(1)。
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)
pub pending_approvals: HashMap<String, PendingApproval>,
/// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。
///
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic.rs loop +
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic/mod.rs loop +
/// GeneratingGuard + try_continue + commands.rs IPC 写路径 + audit.rs process_tool_calls +
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
@@ -348,15 +356,15 @@ impl AiSession {
/// F-260616-09 B 批4:per_conv 化后接 conv_id 参数(顶层 generating 已删除);
/// pending 判断改为按 conv_id 过滤(决策 e 真并发下各 conv 独立)。
///
/// # 待替换调用点(本次仅新增视图,不替换调用点)
/// path_auth 审批链阶段1(本批)接入:`try_continue_agent_loop`(agentic/mod.rs) +
/// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending,
/// 收敛两处状态机判定到单一入口。
///
/// - `agentic.rs` — agent loop 入口/恢复处对 generating + 审批的组合判断
/// - `commands.rs` — `ai_pending_tool_calls` 等读取前的状态门控
/// - `commands.rs` — 审批提交/会话复位路径的状态判断
///
/// 上述三处替换由 P0 任务承接,本方法先就位供其切换。
#[allow(dead_code)] // SW-260618-21 b:预留(P0 终态化任务承接·当前 0 调用·保留扩展点)
/// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原
/// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。
pub fn session_state(&self, conv_id: &str) -> SessionState {
// 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals,
// kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。
let has_pending = self.pending_approvals.values()
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
if has_pending {
@@ -371,9 +379,9 @@ impl AiSession {
// ============================================================
// F-260616-09 B 阶段批1 PerConvState 访问器(设计 §2.1.2)
//
// 批1 仅定义访问器,**不迁移调用方**(批2 承接)。当前 0 调用方,行为完全不变。
// 批2 迁移:所有 `session.messages` / `session.generating` 等读写改走
// `session.conv(conv_id).*`(写)/ `session.conv_read(conv_id)`(读)
// 批4 已迁移完成:所有 `session.messages` / `session.generating` 等读写已全部改走
// `session.conv(conv_id).*`(写)/ `session.conv_read(conv_id)`(读),
// per_conv 现为唯一真相源,顶层单例字段已删除
// ============================================================
/// 取或惰性创建指定会话的可变状态(设计 §2.1.2)。
@@ -410,6 +418,11 @@ mod tests_f09_per_conv {
assert!(s.model_override.is_none(), "model_override 初值应为 None");
assert!(s.session_trust.is_empty(), "session_trust 初值应为空 HashSet");
assert!(s.created_at.is_none(), "created_at 初值应为 None");
// P1 修复(提炼重复触发):knowledge_extracted 初值应为 false(新会话未提炼)
assert!(
!s.knowledge_extracted,
"knowledge_extracted 初值应为 false(新会话未提炼)"
);
// stop_flag 初值 false(与 AiSession::new 对齐)
assert!(
!s.stop_flag.load(std::sync::atomic::Ordering::SeqCst),
@@ -471,6 +484,95 @@ mod tests_f09_per_conv {
"已创建的 conv_id conv_read() 应返 Some"
);
}
// ============================================================
// P1 修复(提炼重复触发): knowledge_extracted 去重标志生命周期测试
//
// 锁定标志三态语义:
// 1) 新会话初值 false → 自动提炼可触发
// 2) 成功提炼后置 true → maybe_spawn_extraction 入口判重跳过
// 3) trigger_extraction_now 清位 → 允许手动重提炼
// ============================================================
/// 新会话 knowledge_extracted 默认 false(自动提炼可触发)
#[test]
fn test_knowledge_extracted_default_false() {
let session = AiSession::new();
// 未创建的 conv_id → conv_read None → maybe_spawn_extraction 视为 false(可触发)
assert!(
session
.conv_read("new-conv")
.map(|c| c.knowledge_extracted)
.unwrap_or(false)
== false,
"新会话 knowledge_extracted 应为 false"
);
}
/// 成功提炼后置位 true(自动路径去重标志)
#[test]
fn test_knowledge_extracted_set_after_extraction() {
let mut session = AiSession::new();
// 模拟提炼成功后置位(extract_knowledge_from_conversation Ok(>0) → maybe_spawn_extraction 置位)
session.conv("conv-done").knowledge_extracted = true;
assert!(
session.conv_read("conv-done").unwrap().knowledge_extracted,
"提炼成功后 knowledge_extracted 应为 true"
);
}
/// maybe_spawn_extraction 入口判重语义:已置位 → 视为已提炼(跳过)
#[test]
fn test_knowledge_extracted_dedup_guard() {
let mut session = AiSession::new();
session.conv("conv-dedup").knowledge_extracted = true;
// 模拟 maybe_spawn_extraction 入口判重读(already_extracted)
let already_extracted = session
.conv_read("conv-dedup")
.map(|c| c.knowledge_extracted)
.unwrap_or(false);
assert!(
already_extracted,
"已置位会话入口判重应为 true(跳过自动提炼)"
);
}
/// trigger_extraction_now 清位语义:手动路径强制清位,允许重提炼
#[test]
fn test_knowledge_extracted_manual_clear() {
let mut session = AiSession::new();
session.active_conversation_id = Some("conv-manual".to_string());
session.conv("conv-manual").knowledge_extracted = true;
// 模拟 trigger_extraction_now 入口清位逻辑:
// active_conversation_id clone(脱离 immutable borrow)后再 mutable borrow conv()
let cid = session.active_conversation_id.clone();
if let Some(cid) = cid.as_deref() {
session.conv(cid).knowledge_extracted = false;
}
assert!(
!session
.conv_read("conv-manual")
.unwrap()
.knowledge_extracted,
"手动触发后 knowledge_extracted 应被清位(允许重提炼)"
);
}
/// 不同会话标志互相独立(P1 去重按 conv_id 切分)
#[test]
fn test_knowledge_extracted_per_conv_independent() {
let mut session = AiSession::new();
session.conv("conv-a").knowledge_extracted = true;
// conv-b 未提炼,标志应独立为 false
assert!(
session.conv("conv-a").knowledge_extracted,
"conv-a 已提炼标志 true"
);
assert!(
!session.conv("conv-b").knowledge_extracted,
"conv-b 未提炼标志 false(各 conv 独立)"
);
}
}
// ============================================================
@@ -484,18 +586,19 @@ mod tests_f09_per_conv {
// agent_language/model_override/session_trust 保留原样),不迁移现有调用方(批2 承接)
// - per_conv HashMap 初始空,旧调用路径继续读写 AiSession 顶层单例字段,完全无感
//
// 共存期:批1 后顶层单例字段(唯一真相源)与 per_conv(全空,无写入)并存,行为不变。
// 批2 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*,
// 迁移完成后顶层单例字段废弃删除(批3)。
// 共存期(已结束):批1 后顶层单例字段(唯一真相源)与 per_conv(全空,无写入)并存,行为不变。
// 批2-批4 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*,
// 迁移完成后顶层单例字段废弃删除(批3)。现 per_conv 为唯一真相源。
// ============================================================
/// 单会话级状态(F-260616-09 B 阶段批1 纯新增,设计 §2.1.2)。
///
/// 持有当前会话(按 conversation_id 切分)私有的可变状态。批1 仅定义 + 惰性创建访问器,
/// **不迁移** AiSession 顶层单例字段(共存期顶层字段仍是唯一真相源)。
/// 持有当前会话(按 conversation_id 切分)私有的可变状态。批4 已迁移完成,本结构现为
/// **唯一真相源**:所有调用方(agentic loop / IPC / audit / conversation / title /
/// knowledge_inject 等约 60+ 处)均经 `session.conv(conv_id).*` / `session.conv_read(conv_id)`
/// 读写,AiSession 顶层单例字段已全部删除。
///
/// 字段初值逐字对齐 [`AiSession::new`](#method.new),保证批2 迁移调用方时行为不变
#[allow(dead_code)] // F-09 B 批1 共存期:0 调用方(批2 迁移承接),字段待迁移后消费
/// 字段初值逐字对齐 [`AiSession::new`](#method.new)。
pub struct PerConvState {
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
pub messages: ContextManager,
@@ -513,8 +616,24 @@ pub struct PerConvState {
pub model_override: Option<String>,
/// 会话级信任(Session Trust):随会话销毁,不落库
pub session_trust: HashSet<TrustKey>,
/// 会话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
/// 会话创建时间(懒创建:首条消息落库前仅存内存,预留 upsert 时用作 created_at)
///
/// 预留字段:构造期写入但当前 upsert 路径未读取(cargo 报 never read),标 allow 保留;
/// 待 upsert 接入会话创建时间回填时消费。符合零调用方≠垃圾(预留)。
#[allow(dead_code)]
pub created_at: Option<String>,
/// 知识提炼去重标志(P1 修复-提炼重复触发):本会话已成功提炼(至少 insert 1 条 candidate)后置位。
///
/// 背景:审批续跑 try_continue → 重 spawn run_agentic_loop → 正常完成块 →
/// maybe_spawn_extraction 二次触发,无去重致同知识点重复 candidate 刷屏。
///
/// 守卫语义:
/// - maybe_spawn_extraction 入口判重:已置位则跳过 + warn(防重复触发刷 candidate)
/// - extract_knowledge_from_conversation 成功(inserted ≥ 1)后由调用方置位
/// - trigger_extraction_now(手动按钮)强制清位:允许用户手动重提炼(语义=手动覆盖自动去重)
///
/// 随会话销毁(per_conv 内存,不落库):会话切换/删除后该标志消失,新会话从 false 开始。
pub knowledge_extracted: bool,
}
impl PerConvState {
@@ -530,6 +649,7 @@ impl PerConvState {
/// - model_override: None
/// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
@@ -541,6 +661,7 @@ impl PerConvState {
model_override: None,
session_trust: HashSet::new(),
created_at: None,
knowledge_extracted: false,
}
}
}
@@ -552,6 +673,11 @@ impl Default for PerConvState {
}
/// 待审批的工具调用
///
/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk),
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
/// 各只消费自己 kind,入口校验防误调)。
#[derive(Debug, Clone)]
pub struct PendingApproval {
pub tool_call_id: String,
@@ -560,21 +686,48 @@ pub struct PendingApproval {
pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool,
/// AE-2025-03路径 Bwrite_file 的行级 unified diff旧文件 vs 新内容)
/// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None
/// recovered 审批(启动重建)无 diff文件可能已变重读无意义
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
pub diff: Option<String>,
/// F-260619-03 Phase B: 路径授权挂起标记(Some = 路径授权挂起,None = 普通 RiskLevel 审批)。
/// 携带待授权目录(规范化父目录),ai_authorize_dir IPC 据此决定写入 session/persistent
pub path_auth: Option<PathAuthRequest>,
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)
pub kind: ApprovalKind,
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。
///
/// 触发:同 tool_call_id 在审计表已有一条 executed/failed/rejected 落定记录(即该
/// tc_id 已被执行/审批过一次),LLM 又用同 id 重试(process_tool_calls 再次 insert pending)
/// 插入时查审计表落定记录推算 retry_count;≥1 时跳过执行,emit AiToolCallCompleted 带
/// "已跳过重试"提示,防 LLM 死循环重试同卡死工具(超时/权限错等)。
///
/// 兜底/回退:flag 关 → 不查审计表、retry_count 恒 0,等价原行为(单次审批执行)。
//
// dead_code 说明:当前断路决策在 insert 点(detect_retry_count 查审计表),不读存储的
// retry_count;字段保留作审计/未来"允许多次重试阈值"扩展(u32 暂仅 0/1),对齐 Risk{diff}
// 变体同款 allow 处理。
#[allow(dead_code)]
pub retry_count: u32,
}
/// F-260619-03 Phase B: 路径授权挂起请求(PendingApproval.path_auth 标记)。
/// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。
///
/// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
#[derive(Debug, Clone)]
pub enum ApprovalKind {
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
Path(PathAuthRequest),
/// 普通 RiskLevel 审批(Med/High 风险工具)。
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
Risk { diff: Option<String> },
}
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
#[derive(Debug, Clone)]
pub struct PathAuthRequest {
/// 待授权目录(规范化父目录,对齐 session_trust 目录粒度)。
pub dir: std::path::PathBuf,
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to
/// 若分别属于不同未授权目录,都需挂起授权(原仅收首个致 to 路径漏校)。单路径工具通常 1 项。
pub dirs: Vec<std::path::PathBuf>,
/// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。
pub raw_paths: Vec<String>,
}