Files
DevFlow/src-tauri/src/commands/ai/mod.rs
绝尘 d899c58682 重构: F-09 batch4 启用多会话上线(决策e真并发·F-09完整)
- IPC签名加conv_id: ai_is_generating/ai_chat_send/ai_chat_force_send/ai_chat_stop + 内部移除active一致性校验(后台conv可操作)
- 删switch readonly分支 + 删ai_conversation_create强制结束旧loop(决策e,切走旧loop不退出)
- 删双写桥接+顶层字段: messages/generating/stop_flag/notify/iteration_used/agent_language/model_override/session_trust全删,per_conv唯一真相源 + 删双写代码(全模块)+ 删fallback读 + pending_approvals retain目标conv
- 前端: api/ai.ts+useAiSend/useAiWindow传conv_id
保留: session_state(SW预留标allow)/readonly(前端不读);零调用方预留按用户指导保留
主代兜底: cargo check --workspace 0 + vue-tsc 0 + test 98 + grep顶层删除/per_conv唯一印证
F-09完整(batch1 PerConvState+batch2迁移+batch5限流调整+batch8恢复+batch4上线);双会话人工验收留用户
2026-06-19 03:03:48 +08:00

634 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理
//!
//! 由原单文件 ai.rs(2663 行)按职责拆分为 11 个子模块。
//!
//! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数
//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / DEFAULT_MAX_AGENT_ITERATIONS
//! - [`stream_recv`] — stream_llm 流式接收
//! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens
//! - [`title`] — 对话标题生成
//! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理
//! - [`skills`] — 本机 Claude 技能扫描
//! - [`prompt`] — 系统提示词构建
//! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑)
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//!
//! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端
//! - AiTextDelta: 流式文本片段
//! - AiToolCallStarted/Completed: 工具调用生命周期
//! - AiApprovalRequired: 需要人工审批
//! - AiCompleted/AiError: 完成/错误
pub mod agentic;
pub mod audit;
pub mod commands;
pub mod compress;
pub mod conversation;
pub mod knowledge_inject;
pub mod prompt;
pub mod provider_pool;
pub mod secret;
pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use serde::{Deserialize, Serialize};
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
use df_ai::context::ContextManager;
use df_ai::context::ContextConfig;
// ============================================================
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
// ============================================================
// commands 子模块(glob 重导出) — `#[tauri::command]` 宏生成的命令函数 +
// 内部符号(__cmd__xxx / __tauri_command_name_xxx)同源同模块定义,
// glob 把它们全部拉到 commands::ai 路径,使 generate_handler! 能解析到。
// 静默 unused_imports:glob 重导出用于跨模块路径解析,本文件不引用这些符号。
#[allow(unused_imports)]
pub use self::commands::*;
// 子模块的非命令 pub 项,逐个保路径
#[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};
#[allow(unused_imports)]
pub use self::tool_registry::build_ai_tool_registry;
// ============================================================
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。
///
/// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`,
/// 旧前端消费方忽略未填值;新点尽量精确填并在注释标注映射理由。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// 鉴权失败:API key 缺失/无效/钥匙串损坏(resolve_provider_secret / ensure_resolved_key 失败)
Auth,
/// 网络错误:连接失败/DNS 解析失败/流中途断(SSE 传输断)
Network,
/// 超时:连接超时/idle timeout(120s 无 chunk)
Timeout,
/// Provider 配置错误:base_url/model/provider_type 错或无可用 provider(配置丢失/全删)
ProviderConfig,
/// 未分类(达最大轮次/provider 流式错误事件等难精确归类的旧点)
Unknown,
}
/// AI 聊天事件(推送到前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum AiChatEvent {
/// 流式文本片段
AiTextDelta { delta: String, conversation_id: Option<String> },
/// 工具调用开始
AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> },
/// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// 会话级信任自动放行AE-2025-04 Session Trust
/// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
/// Started → AutoApprovedtoast 提示用户已自动放行)→ Completed。
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
/// 需要人工审批
/// diffAE-2025-03路径 B仅 write_file 注入(旧文件 vs 新内容行级 unified diff
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
/// 审批结果
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
/// AI 响应完成
///
/// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1——
/// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化
/// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。
AiCompleted {
total_tokens: u32,
prompt_tokens: u32,
completion_tokens: u32,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>,
conversation_id: Option<String>,
},
/// 错误
///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
AiError {
error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛F-260616-03转暂停态询问用户继续/停止)
///
/// 触发时 generating 仍为 true仿审批等待前端展示独立操作卡
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> },
/// 流式调用自动重试提示F-260616-07流前失败重试中
///
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
AiCompressed { conversation_id: Option<String>, summary: String },
}
// ============================================================
// 会话状态(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 会话读侧状态视图(只读枚举,由 [`AiSession::session_state`] 推导)
///
/// 这是 **只读视图**,不替代 `AiSession` 上 `generating` / `pending_approvals` /
/// `stop_flag` 等字段定义——字段仍是唯一真相源,调用方写状态时继续写字段,
/// 读状态时统一走 `session_state()` 收敛判断逻辑(避免散布的 `if generating` /
/// `if !pending_approvals.is_empty()` 三路组合在各调用点各自实现)。
///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval
/// 即使同时在流式)> `generating`Streaming> 都不满足Idle
#[allow(dead_code)] // SW-260618-21 b:预留读视图(SW-02 类终态化复用·当前 0 消费者·保留扩展点)
pub enum SessionState {
/// 空闲:既未生成、也无挂起审批
Idle,
/// 流式生成中generating 为 true 且无挂起审批
Streaming,
/// 等待审批pending_approvals 非空
AwaitingApproval,
}
// ============================================================
// AE-2025-04 会话级信任Session Trust
//
// 核心:信任上下文相关。同会话批准过同类操作(同工具+同目录)后自动放行;
// 换会话new/switch清空重审session_trust 随会话销毁,不落库)。
//
// TrustKey 按 操作 + 目录 粒度非文件级——文件级粒度过细LLM 每写一个文件都要重审,
// 信任面太窄失去意义;目录级让"批准过同目录写入"覆盖该目录后续写入,对齐"写→跑→看→改"
// 闭环最高频场景。首批write_file + run_command。
//
// FR-S1 安全边界TrustKey 只含 tool + dir**绝不**含 api_key / 文件内容 / 命令串等敏感
// 参数。dir 经 resolve_workspace_path/canonicalize 规范化(去 .. / symlink 逃逸)。
// ============================================================
/// 会话级信任键:按「操作类型 + 目录」粒度唯一标识一类已批准操作。
///
/// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化)
/// - `Execute { dir }`run_command 在 `dir` 工作目录下
///
/// 命中即自动放行,跳过 pending 审批 + 二次确认AE-05不写 audit 仍记一条
/// `decided_by=auto_trust` 的审计记录留痕。
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum TrustKey {
Write { dir: String },
Execute { dir: String },
}
/// 由「工具名 + args」推算 TrustKey若该工具不在首批信任名单内返 None走原审批流程
///
/// 目录归一化策略(与 tool_registry.rs 各 handler 的路径锚定一致):
/// - write_file取 args.pathresolve_workspace_path → 父目录;解析失败回退 workspace_root
/// - run_command取 args.working_dir缺省回退 workspace_root不做越界校验
/// run_command handler 自身仅 validate_path 不限定 workspace信任目录用 LLM 传入或
/// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐)
///
/// FR-S1不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。
pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
match tool {
"write_file" => {
let path = args.get("path").and_then(|v| v.as_str())?;
let dir = dir_of_path_normalized(path);
Some(TrustKey::Write { dir })
}
"run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d),
None => workspace_root_str(),
};
Some(TrustKey::Execute { dir })
}
_ => None,
}
}
/// write_file 路径 → 父目录规范化字符串(作 HashSet key 用)。
///
/// resolve_workspace_path 失败(路径越界/含 .. )回退 workspace_root —— 失败的工具调用本身
/// 不会被执行handler 内校验拦截),但 trust_key 计算不应 panic失败回退保守默认。
fn dir_of_path_normalized(path: &str) -> String {
match self::tool_registry::resolve_workspace_path_pub(path) {
Ok(resolved) => {
let parent = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy())
}
Err(_) => workspace_root_str(),
}
}
/// run_command / write_file 共用的目录 key 规范化:
/// 尝试 canonicalize去 symlink / .. / 大小写归一),失败回退原字面量 trim。
fn normalize_dir_key(dir: &str) -> String {
let p = std::path::Path::new(dir);
match p.canonicalize() {
Ok(canon) => canon.to_string_lossy().to_string(),
Err(_) => dir.trim_end_matches(['/', '\\']).to_string(),
}
}
/// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。
///
/// 与 tool_registry.rs workspace_root() 同源CARGO_MANIFEST_DIR 上两级canonicalize 后
/// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。
fn workspace_root_str() -> String {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
root.canonicalize()
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| root.to_string_lossy().to_string())
}
// 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用
use std::path::PathBuf;
/// AI 会话内状态Mutex 保护)
///
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
/// HashMap(按 conversation_id 切分),顶层单例字段(messages/generating/stop_flag/notify/
/// iteration_used/agent_language/model_override/session_trust)已退役删除。批2 双写桥接代码
/// (IPC 写路径的顶层双写行 + 各文件 fallback 读)在批4 全部清理完毕,per_conv 是唯一真相源。
pub struct AiSession {
/// 当前提供商 ID
pub active_provider_id: Option<String>,
/// 当前活跃对话 ID(应用级路由键:当前展示的 conv;切走即变,不影响后台 loop)
pub active_conversation_id: Option<String>,
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub active_conv_created_at: Option<String>,
/// 挂起的审批。
///
/// **结构**:单层 `HashMap<tool_call_id, PendingApproval>`,按 `tool_call_id`
/// 路由审批结果(`tool_call_id` 是路由键)。`PendingApproval.conversation_id`
/// 是业务语义哪个对话产生的审批而非路由键——IPC 端按 `tool_call_id` 精确命中。
///
/// **现状**`ai_pending_tool_calls` 等读取路径按 `conversation_id` 做 O(n) 线性
/// 过滤。在典型场景(单对话、少量并发审批)下 n 极小O(n) 可接受,无需二级索引。
///
/// **未来**:若多会话并发、审批量显著增大,可考虑加二级索引
/// `conversation_id → Vec<tool_call_id>`)把按会话过滤降到 O(1)。
pub pending_approvals: HashMap<String, PendingApproval>,
/// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。
///
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic.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)`。顶层单例会话级字段已全部删除。
pub per_conv: HashMap<String, PerConvState>,
}
impl AiSession {
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
}
}
/// 推导指定会话读侧状态视图(只读,不写任何字段)。
///
/// 优先级:该 conv 有 pending 审批 → [`SessionState::AwaitingApproval`]
/// 否则 per_conv.generating → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。
///
/// 收敛点:调用方读「某会话处于什么阶段」时统一走本方法,替代散布的
/// `if pending / if generating` 三路组合。
///
/// F-260616-09 B 批4:per_conv 化后接 conv_id 参数(顶层 generating 已删除);
/// pending 判断改为按 conv_id 过滤(决策 e 真并发下各 conv 独立)。
///
/// # 待替换调用点(本次仅新增视图,不替换调用点)
///
/// - `agentic.rs` — agent loop 入口/恢复处对 generating + 审批的组合判断
/// - `commands.rs` — `ai_pending_tool_calls` 等读取前的状态门控
/// - `commands.rs` — 审批提交/会话复位路径的状态判断
///
/// 上述三处替换由 P0 任务承接,本方法先就位供其切换。
#[allow(dead_code)] // SW-260618-21 b:预留(P0 终态化任务承接·当前 0 调用·保留扩展点)
pub fn session_state(&self, conv_id: &str) -> SessionState {
let has_pending = self.pending_approvals.values()
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
if has_pending {
SessionState::AwaitingApproval
} else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) {
SessionState::Streaming
} else {
SessionState::Idle
}
}
// ============================================================
// 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)`(读)。
// ============================================================
/// 取或惰性创建指定会话的可变状态(设计 §2.1.2)。
///
/// 不存在则 `entry().or_insert_with(PerConvState::new)` 创建一份默认状态,
/// 初值对齐 [`AiSession::new`] / [`PerConvState::new`]。
/// 已存在则返回同一实例引用(同 conv_id 复用)。
pub fn conv(&mut self, conv_id: &str) -> &mut PerConvState {
self.per_conv
.entry(conv_id.to_string())
.or_insert_with(PerConvState::new)
}
/// 只读取指定会话状态(不创建)。
///
/// 未创建的 conv_id 返 `None`,已创建返 `Some(&PerConvState)`。用于读取路径(如
/// 列举/查询)避免误触发惰性创建。
pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> {
self.per_conv.get(conv_id)
}
}
#[cfg(test)]
mod tests_f09_per_conv {
use super::*;
/// 验证 PerConvState::new() 各字段初值正确(对齐 AiSession::new 基线)。
#[test]
fn test_per_conv_state_new() {
let s = PerConvState::new();
assert!(!s.generating, "generating 初值应为 false");
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
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");
// stop_flag 初值 false(与 AiSession::new 对齐)
assert!(
!s.stop_flag.load(std::sync::atomic::Ordering::SeqCst),
"stop_flag 初值应为 false"
);
// notify 是 Arc<Notify>,无可见状态,仅验证不 panic(构造即视为对齐)
// messages 是 ContextManager,内部状态;仅验证能取到引用(初值对齐由 ContextManager::new 保证)
let _messages_ref: &ContextManager = &s.messages;
}
/// 验证 conv() 首次创建 + 二次复用同一实例。
#[test]
fn test_conv_lazy_create() {
let mut session = AiSession::new();
assert!(session.per_conv.is_empty(), "新建 session 的 per_conv 应为空");
// 首次 conv() 创建
let ptr_first = {
let s = session.conv("conv-a");
assert!(!s.generating);
assert_eq!(s.iteration_used, 0);
s as *const PerConvState
};
assert_eq!(session.per_conv.len(), 1, "首次 conv() 后 per_conv 应有 1 项");
// 二次 conv(同 conv_id) 返回同一实例(指针相等)
let ptr_second = session.conv("conv-a") as *const PerConvState;
assert_eq!(
ptr_first, ptr_second,
"同 conv_id 二次 conv() 应返回同一实例"
);
assert_eq!(session.per_conv.len(), 1, "二次 conv(同 id) 不应新增项");
// 不同 conv_id 创建独立实例
let _s_b = session.conv("conv-b");
assert_eq!(session.per_conv.len(), 2, "不同 conv_id 应新建独立实例");
}
/// 验证 conv_read() 未创建的 conv_id 返 None(只读,不惰性创建)。
#[test]
fn test_conv_read_none() {
let session = AiSession::new();
// 未创建的 conv_id
assert!(
session.conv_read("never-created").is_none(),
"未创建的 conv_id conv_read() 应返 None"
);
// conv_read 不触发创建(per_conv 仍空)
assert!(
session.per_conv.is_empty(),
"conv_read 不应触发惰性创建"
);
// 已创建的 conv_id 返 Some(只读访问)
let mut session = session;
let _ = session.conv("exists");
assert!(
session.conv_read("exists").is_some(),
"已创建的 conv_id conv_read() 应返 Some"
);
}
}
// ============================================================
// F-260616-09 B 阶段 多会话并发架构 — PerConvState(批1 纯新增)
//
// 设计文档: docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md §2.1.2
//
// 批1 范围(纯新增无行为变化):
// - 仅新增 PerConvState struct + new() + AiSession.per_conv 字段 + conv()/conv_read() 访问器
// - **不迁移**现有单例字段(messages/generating/stop_flag/notify/iteration_used/
// agent_language/model_override/session_trust 保留原样),不迁移现有调用方(批2 承接)
// - per_conv HashMap 初始空,旧调用路径继续读写 AiSession 顶层单例字段,完全无感
//
// 共存期:批1 后顶层单例字段(唯一真相源)与 per_conv(全空,无写入)并存,行为不变。
// 批2 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*,
// 迁移完成后顶层单例字段废弃删除(批3)。
// ============================================================
/// 单会话级状态(F-260616-09 B 阶段批1 纯新增,设计 §2.1.2)。
///
/// 持有当前会话(按 conversation_id 切分)私有的可变状态。批1 仅定义 + 惰性创建访问器,
/// **不迁移** AiSession 顶层单例字段(共存期顶层字段仍是唯一真相源)。
///
/// 字段初值逐字对齐 [`AiSession::new`](#method.new),保证批2 迁移调用方时行为不变。
#[allow(dead_code)] // F-09 B 批1 共存期:0 调用方(批2 迁移承接),字段待迁移后消费
pub struct PerConvState {
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
pub messages: ContextManager,
/// 是否正在生成
pub generating: bool,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
pub notify: Arc<tokio::sync::Notify>,
/// agentic loop 累计已跑 iteration 计数(语义同 AiSession.iteration_used)
pub iteration_used: usize,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
pub model_override: Option<String>,
/// 会话级信任(Session Trust):随会话销毁,不落库
pub session_trust: HashSet<TrustKey>,
/// 会话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub created_at: Option<String>,
}
impl PerConvState {
/// 新建默认会话级状态。
///
/// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。
/// - messages: ContextManager::new(ContextConfig::default())
/// - generating: false
/// - stop_flag: Arc::new(AtomicBool::new(false))
/// - notify: Arc::new(tokio::sync::Notify::new())
/// - iteration_used: 0
/// - agent_language: None
/// - model_override: None
/// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
generating: false,
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),
created_at: None,
}
}
}
impl Default for PerConvState {
fn default() -> Self {
Self::new()
}
}
/// 待审批的工具调用
#[derive(Debug, Clone)]
pub struct PendingApproval {
pub tool_call_id: String,
pub tool_name: String,
pub arguments: serde_json::Value,
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>,
}
/// 工具调用草稿(流式收集时的临时结构)
#[derive(Debug, Clone, Default)]
pub(crate) struct ToolCallDraft {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) args: String,
}
// ============================================================
// B-260617-01 决策 a(方案 a2)run_workflow 工具经审批后的真实执行入口
//
// 背景run_workflow handler(tool_registry.rs)仅持 db:Arc<Database>,无 AppHandle/State
// (registry/event_bus/workflows Repo),经 ai_tools.execute 必然 Err。ai_approve 识别 run_workflow
// 后改调本函数(同 invoke('run_workflow') IPC 入口 workflow.rs::run_workflow,持有完整 State),
// 真正触发工作流引擎执行 + 联动任务推进,把 execution_id 回填 tool_result 让 LLM 收成功结果停止重试。
//
// 选后端分支(a2)而非前端拦截(a1):ai_approve 已持 state+app,后端统一更简,无需新增前端 IPC +
// 不把工作流知识泄漏到前端。本函数仅在 ai_approve 内被调用(单一调用方)。
// ============================================================
/// 把 run_workflow 工具调用的 AI args 转调工作流执行函数,返回 execution_id(供回填 tool_result)。
///
/// 从 args 取 task_id + target_status(两者必填),传空 DagDef(由 workflow.rs 按 target_status
/// 自动选推进链模板,见 task_workflow_templates::template_for),config 传空 Object。
/// 任一参数缺失返 Err(回填 failed tool_result,LLM 据此修参重发)。
///
/// 不在此推进任务状态 —— workflow.rs::run_workflow 内部已按 task_id+target_status 自动联动
/// (成功推进到 target / 失败按 regression_target 退回),此处不重复。
pub(crate) async fn execute_run_workflow_for_tool(
app: &tauri::AppHandle,
state: &crate::state::AppState,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let task_id = args
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("缺少 task_id 参数"))?
.to_string();
let target_status = args
.get("target_status")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("缺少 target_status 参数"))?
.to_string();
// 空 DagDef + target_status:workflow.rs:84-92 自动按 target_status 选推进链模板
// (AiNode 自审 / HumanNode 核对闸门),无需本函数感知模板知识。
let dag = df_workflow::dag_def::DagDef::new();
let config = serde_json::Value::Object(serde_json::Map::new());
let name = format!("AI 推进任务 {}{}", task_id, target_status);
// 转调工作流执行核心(B-260617-01:run_workflow_inner 是 run_workflow 命令的共用核心,
// 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开
// tauri::State 在非命令上下文不可构造的限制)。
// CR-52: 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。
// 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。
let execution_id = crate::commands::workflow::run_workflow_inner(
app,
state,
name,
dag,
config,
Some(task_id.clone()),
Some(target_status.clone()),
"ai",
)
.await
.map_err(|e| anyhow::anyhow!("工作流执行失败: {}", e))?;
// 回填 tool_result:返回 execution_id + 联动语义说明,让 LLM 知工作流已触发(后台异步执行,
// 完成后自动推进任务状态),不再重试同 tool_call。
Ok(serde_json::json!({
"task_id": task_id,
"target_status": target_status,
"execution_id": execution_id,
"status": "running",
"note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。"
}))
}