Files
DevFlow/src-tauri/src/commands/ai/mod.rs
T

1195 lines
60 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 行)按职责拆分为 15 个子模块。
//!
//! 模块布局:
//! - [`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 公共函数)
//! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//! - [`augmentation`] — 上下文增强
//! - [`http`] — HTTP 调用封装
//! - [`secret`] — 密钥/凭证管理
//!
//! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端
//! - AiTextDelta: 流式文本片段
//! - AiToolCallStarted/Completed: 工具调用生命周期
//! - AiApprovalRequired: 需要人工审批
//! - AiCompleted/AiError: 完成/错误
pub mod agentic;
pub mod augmentation;
pub mod audit;
pub mod code_intel;
pub mod command_stream;
pub mod commands;
pub mod compress;
pub mod conversation;
pub mod event_bus;
pub mod http;
pub mod fetch_url;
pub mod fetch_search;
pub mod download_file;
pub(crate) mod generate_image;
pub mod get_app_config;
pub mod knowledge_inject;
pub mod prompt;
pub mod provider_pool;
pub mod remote_bridge;
pub mod secret;
pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
// 声明式工具注册集合(tool_registry 拆分第一步 · 试点容器)。
// 试点:list_projects 已迁声明式 declare_tool! 宏,其余 47 工具仍在 tool_registry.rs。
pub mod tools;
use agentic::conv_state::{ConvState, ConvStateStore};
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;
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
use df_ai::provider::ChatMessage;
// ============================================================
// 重导出 — 保路径不变(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::{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::* 拿到)
// ============================================================
/// 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")]
#[allow(clippy::large_enum_variant, dead_code)]
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> },
/// run_command 实时流式输出(治执行中黑盒)。
///
/// execute_with_heartbeat / ai_approve 调用 run_command 前,经 task-local sink 注入
/// (AppHandle + tool_call_id + conv_id)。run_command handler 读 task-local,改走 shell
/// `execute_streaming`,每读一行 stdout/stderr 即 emit 本事件(双写 app.emit + ai_event_bus)。
/// 前端 listen 此事件可在工具卡 Started→Completed 间实时展示进度(编译/构建输出)。
///
/// 字段:
/// - `id`:tool_call_id(对齐 Started/Completed,前端按 id 路由到对应工具卡)
/// - `stream`:"stdout" | "stderr"(来源流,前端可差异化着色)
/// - `line`:单行内容(已去换行,行级粒度 emit)
/// - `conversation_id`:多对话路由
AiCommandOutput { id: String, stream: String, line: String, 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,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
/// token 分项显示(2026-08-02):前端 in=miss+cache=hit 分计费展示。0=非 cache provider。
prompt_cache_hit_tokens: u32,
/// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。
prompt_cache_miss_tokens: u32,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型。
reasoning_tokens: u32,
/// G4.3(2026-08-05):本轮 token 用量是否估算值(agentic 运行时兜底
/// round_usage.prompt_tokens==0 → estimated_prompt 处打标)。true=prompt 非真实
/// (provider 未报,前端 MessageList 据此标「估算」),false=真实值。仅影响展示,
/// 不参与 TokenAccumulator/save 累加路径。
is_estimated: bool,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>,
conversation_id: Option<String>,
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
pinned_goals: Vec<GoalEntry>,
},
/// 错误
///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
AiError {
error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
///
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
///
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
/// → **孤立响应气泡**。此事件填补该缺口。
///
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
AiUserMessage {
message: String,
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-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
///
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
/// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并),
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
AiDirAuthRequired {
id: String,
tool: String,
path: String,
dir: String,
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 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。
///
/// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷,
/// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。
/// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。
AiManualCompressed { conversation_id: Option<String>, summary: String },
/// F-15 阶段2 自动 LLM 压缩完成(agentic loop 触发,治 Task#1):对桌面端静默——
/// 桌面 useAiContext 仅复位 isCompressing(防按钮卡死兜底),不弹 toast 不 switchConversation。
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, summary: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
/// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。
///
/// 字段语义:
/// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示)
/// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入
/// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选
/// - `conversation_id`:多对话路由(与其他事件一致)
///
/// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。
/// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。
/// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。
AiHelpRequired {
reason: String,
context: String,
options: Vec<String>,
conversation_id: Option<String>,
},
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
///
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 双职责:
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
///
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
AiConversationList {
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
conversations: Vec<ConvSummary>,
},
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
///
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
AiMessageHistory {
/// 对话 ID
conversation_id: String,
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
messages: Vec<ChatMessage>,
},
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
///
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
///
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 generating bool 已退役,emit 无条件执行
/// (enum 单一真相源,无 off 降级分支)。前端按 conv_state 事件流派生展示态。
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
/// 供前端 union discriminator 直接使用。
AiConvStateChanged {
conv_state: ConvState,
conversation_id: Option<String>,
},
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
///
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
AiSkillList {
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
skills: Vec<crate::commands::ai::skills::SkillInfo>,
},
/// 跨端模型列表同步(2026-08-05):device 响应 miniapp `list_models` 命令,
/// 把活跃 provider 的 enabled 模型列表推回 miniapp 供模型选择器渲染。
///
/// 触发:remote_bridge `list_models` 路由 → `get_active_provider`(prompt.rs)读活跃
/// provider → 过滤 `model_configs` 中 `enabled == true` → 映射 `ModelInfo { model_id, label }`
/// → `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 模型选择器(列出可选模型 + 当前默认),对齐桌面端模型选择体验;
/// 选择结果经 `send_message` / `regenerate` 的 `model_override` 透传回 device(route 已透传)。
AiModelList {
/// 活跃 provider id(读 session.active_provider_id,兜底 provider.id)
provider_id: String,
/// 活跃 provider 默认模型(provider.default_model,选择器高亮/兜底)
default_model: String,
/// enabled 模型列表(已过滤 model_configs 中 enabled == true)
models: Vec<ModelInfo>,
},
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
///
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
///
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
/// #[tauri::command] 返回值,必然 impl Serialize)。
AiEntityList {
/// 项目全量列表(list_active,deleted_at IS NULL)
projects: Vec<df_storage::models::ProjectRecord>,
/// 任务全量列表(list_active 等价,未删任务)
tasks: Vec<df_storage::models::TaskRecord>,
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
ideas: Vec<df_storage::models::IdeaRecord>,
},
/// 多 Agent 并行执行:Plan 创建(Coordinator.decompose 完成后 emit)。
/// 前端 PlanProgress 据此展示 DAG 层结构。
AiPlanCreated {
plan_id: String,
/// 按 layer 分组的子任务列表(layer 0 在前)
layers: Vec<PlanLayerInfo>,
conversation_id: Option<String>,
},
/// 多 Agent 并行执行:SubTask 状态变更。
AiSubTaskStatusChanged {
subtask_id: String,
plan_id: String,
status: String,
persona_id: Option<String>,
conversation_id: Option<String>,
},
/// 多 Agent 并行执行:Plan 合并完成。
AiMergeCompleted {
plan_id: String,
/// 合并后的最终产出(前端展示为单条可展开消息)
merged_output: String,
/// 检测到的冲突列表(空=无冲突)
conflicts: Vec<ConflictInfo>,
conversation_id: Option<String>,
},
/// 多 Agent 并行执行:冲突已解决。
AiConflictResolved {
conflict_id: String,
plan_id: String,
resolution: String,
resolved_by: String,
conversation_id: Option<String>,
},
}
/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。
///
/// 与 `df_ai_core::model::ModelConfig` 区别:仅含选择器渲染必需字段(model_id + 可选 label),
/// 不携带 modalities/capabilities/权重等路由维度(省带宽,miniapp 仅需选模型)。label 为用户
/// 自定义别名(可选),缺失时前端回退显示 model_id。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
/// 模型名(如 "glm-4-flash",对齐 ModelConfig.model_id)
pub model_id: String,
/// 用户自定义别名(可选;缺失时前端回退显示 model_id)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
/// Plan 层信息(AiPlanCreated 事件的载荷)
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlanLayerInfo {
/// 本层的子任务列表
pub items: Vec<SubTaskInfo>,
}
/// SubTask 信息(事件载荷)
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubTaskInfo {
pub id: String,
pub persona_id: Option<String>,
pub intent: String,
pub status: String,
}
/// 冲突信息(AiMergeCompleted 事件的载荷)
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConflictInfo {
pub id: String,
pub file_path: String,
pub conflict_type: String,
pub subtask_a: Option<String>,
pub subtask_b: Option<String>,
}
// ============================================================
// F-#95 跨端会话列表同步:会话摘要结构
// ============================================================
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
///
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
///
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvSummary {
pub id: String,
pub title: String,
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
pub last_message: Option<String>,
/// 最近更新时间(ms 数值)
pub updated_at: Option<i64>,
/// 创建时间(ms 数值,可选展示)
pub created_at: Option<i64>,
}
// ============================================================
// 会话状态(放 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)。
// 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,
/// 流式生成中: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 => String::new(),
};
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(_) => String::new(),
}
}
/// 判定 run_command 的命令串是否「破坏性」(rm/del/format/... 等不可逆操作)。
///
/// 用于会话信任放行门控:破坏性命令**永不**走 session_trust auto 放行,即使同会话
/// 已批准过同目录的 run_command,也必须每轮走正常审批(避免「一次批准同目录 →
/// rm -rf 任意文件被自动放行」的安全漏洞)。
///
/// 判定规则:取命令**首 token** 精确匹配,避免前缀法误伤 delta/delve 等以 del 开头的
/// 合法命令。黑名单只覆盖「不可逆破坏」类;curl/wget/scp 等下载拷贝类的风险(数据外泄/
/// 恶意脚本)由 run_command 的 High risk 审批(用户每轮看完整命令)覆盖,不进黑名单,
/// 避免误伤频繁合法下载。
/// 局限:不解析 sudo/doas/env 等提权前缀(`sudo rm` 首 token 是 sudo 不命中)——本层是
/// 「尽力」补充,主防线仍是 High risk 审批。
pub fn is_destructive_command(cmd: &str) -> bool {
const DESTRUCTIVE_VERBS: &[&str] = &[
"rm", "rmdir", "del", "erase", "format", "remove-item", "dd", "mkfs",
];
let first = cmd.trim().split_whitespace().next().unwrap_or("").to_lowercase();
DESTRUCTIVE_VERBS.iter().any(|v| first == *v)
}
/// 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(),
}
}
/// AI 会话内状态(Mutex 保护)
///
/// 会话级状态全部迁入 [`per_conv`](Self::per_conv) HashMap(按 conversation_id 切分),
/// 顶层单例会话级字段已全部退役删除,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>,
/// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。
///
/// 决策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` 是业务语义(哪个对话产生)。
///
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
pub pending_approvals: HashMap<String, PendingApproval>,
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
pub per_conv: HashMap<String, PerConvState>,
/// 已删除对话 ID 集合(delete 在 loop 运行中幽灵复活根治)。
///
/// `ai_conversation_delete` 删除时记录被删 conv_id;`save_conversation` 入口检测到已删除则
/// 跳过写入,防 loop 在「push 后、save 前」删除发生 → save 用 [`conv()`](Self::conv) 惰性重建
/// 空 per_conv + `Ok(None)` 分支重新 INSERT 空壳复活 + insert_batch 写回消息。
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
/// (单条 ~40B,删除量级下可忽略)。
pub deleted_convs: HashSet<String>,
/// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行
pub save_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
pub namespace_store: df_ai::namespace_store::NamespaceStore,
}
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(),
deleted_convs: HashSet::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
/// 推导指定会话读侧状态视图(只读,不写任何字段)。
///
/// 优先级:该 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 独立)。
///
/// path_auth 审批链阶段1(本批)接入:`try_continue_agent_loop`(agentic/mod.rs) +
/// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending,
/// 收敛两处状态机判定到单一入口。
///
/// B-Phase3:conv_state 读侧切 ConvStateStore(单源收敛)。
pub fn session_state(&self, conv_id: &str, conv_states: &ConvStateStore) -> 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 conv_states.is_active(conv_id) {
SessionState::Streaming
} else {
SessionState::Idle
}
}
// ============================================================
// F-260616-09 B 阶段批1 PerConvState 访问器(设计 §2.1.2)
//
// 批4 已迁移完成:所有 `session.messages` / `session.generating` 等读写已全部改走
// `session.conv(conv_id).*`(写)/ `session.conv_read(conv_id)`(读),
// per_conv 现为唯一真相源,顶层单例字段已删除。
// ============================================================
/// 取或惰性创建指定会话的可变状态(设计 §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)
}
/// 取或惰性创建某会话的 save 串行锁(返回 Arc 供 save 全程持有)。
pub fn save_lock(&mut self, conv_id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.save_locks
.entry(conv_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
}
#[cfg(test)]
mod tests_f09_per_conv {
use super::*;
/// 验证 PerConvState::new() 各字段初值正确(对齐 AiSession::new 基线)。
#[test]
fn test_per_conv_state_new() {
let s = PerConvState::new();
// 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。
// B-Phase3:conv_state 已迁 ConvStateStore,PerConvState 不再持有。
// conv_state 由 guard.new(Idle→Generating)经 conv_states.transition 迁移。
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");
// 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),
"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");
// 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。
// B-Phase3:conv_state 已迁 ConvStateStore。
// conv_state 初值由 guard/入口保证,PerConvState 不再持有。
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"
);
}
// ============================================================
// 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 独立)"
);
}
}
// ============================================================
// 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-批4 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*,
// 迁移完成后顶层单例字段已废弃删除(批3)。现 per_conv 为唯一真相源。
// ============================================================
/// 单会话级状态(F-260616-09 B 阶段批1 纯新增,设计 §2.1.2)。
///
/// 持有当前会话(按 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)。
///
/// # 字段正交性(G1 目标钉扎 / G3 状态机收敛衔接,2026-06-26)
///
/// 本 struct 同时持有两类正交状态:
/// - **生命周期态**(`conv_state: ConvState`):loop 是否在跑(Idle/Generating/Stopping/...),
/// 归 `ConvState` enum 管(写收敛见 [`ConvState::transition_to`])。
/// - **内容态**(`pinned_goal` 等):用户想达成什么 / 会话进度等内容信息,挂本 struct 兄弟字段。
///
/// 两者**正交共存**于同一 struct,不互相进变体:目标**绝不**塞进 `ConvState` enum(否则
/// 5 态会膨胀成 `GeneratingWithGoalA`/`GeneratingWithGoalB` 爆炸组合,违反 conv-state-convergence
/// 分支「轻量状态机不引入框架」原则,见 agentic/conv_state.rs 文件头)。目标钉扎字段随会话
/// 销毁不落库(对齐 `knowledge_extracted` 语义,PerConvState 无 serde derive)。
pub struct PerConvState {
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
pub messages: ContextManager,
// B-Phase3:conv_state 字段已迁到 ConvStateStore(无锁真相源),此字段已删。
/// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。
///
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
/// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部(system_prompt 是 loop 不变量)。
///
/// 2026-07-03 改:提取从「用户消息规则」→「工具调用推理」(infer_goal_from_tool_calls),
/// 每轮 LLM 回复后从工具名+路径推理目标追加(去重),支持多条/轮 + active→completed 状态流转。
pub pinned_goals: Vec<GoalEntry>,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
pub notify: Arc<tokio::sync::Notify>,
/// 并发 loop epoch/owner token(F1 根治,2026-08-05):每 spawn 新 loop 前 `fetch_add(1)`。
///
/// loop 入口捕获自己的 epoch(`run_agentic_loop` 的 `loop_epoch` 形参),退出 / Guard 复位 /
/// emit AiCompleted 时只认自己 epoch(owner)——`loop_epoch.load() == my_epoch` 才是 owner,
/// 否则是旧 loop(已被 force_send/新 loop 接管),不 reset 新 loop 状态、不 emit 重复完成事件。
///
/// 治 F1:force_send 原先锁内 `stop_flag=true→false` 微秒窗口,旧 loop 阻塞在 stream_llm
/// select!(不读 stop_flag)醒来 flag 已 false → 旧 loop 没停 → 同 conv 新旧两 loop 并行。
/// epoch 让旧 loop 在新 loop 启动瞬间即失效(owner 判定失败),即便旧 loop 还在跑也不动新状态。
pub loop_epoch: Arc<std::sync::atomic::AtomicU64>,
/// 存活心跳(F2 根治,2026-08-05):loop 活跃期间由 `heartbeat_loop` 每 20s 更新(ms 时间戳)。
///
/// `ai_chat_stop` 3s 兜底据此判断 loop 是否真死:超阈值(60s)无心跳 = 真死才强制复位 +
/// emit AiCompleted;loop 还在跑(长工具执行/流式/压缩,心跳持续)则豁免,不误迁 Idle、
/// 不产生「假完成 + 旧 loop 仍活」的双完成竞态。
pub last_heartbeat: Arc<std::sync::atomic::AtomicU64>,
/// 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)。
///
/// 预留字段:构造期写入但当前 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,
/// T5 WorkingContext 常驻上下文(结构化目标/决策/步骤/脏标记)
pub working_context: df_ai::context_helpers::WorkingContext,
/// T4 关联的工作流 ID(由前端/workflow 引擎在启动时设置)
pub workflow_id: Option<String>,
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
pub workflow_dag_summary: Option<String>,
}
/// 单个目标记录(含状态追踪)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalEntry {
pub text: String,
#[serde(default = "default_goal_status")]
pub status: GoalStatus,
}
/// 目标状态。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalStatus {
/// 正在进行中(当前 LLM 正在处理)
Active,
/// 已完成(LLM 已进入下一任务)
Completed,
}
fn default_goal_status() -> GoalStatus {
GoalStatus::Active
}
impl GoalEntry {
pub fn new(text: String) -> Self {
GoalEntry { text, status: GoalStatus::Active }
}
}
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 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
/// - pinned_goals: Vec::new()(G1 目标钉扎,新会话未提取目标)
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
pinned_goals: Vec::new(),
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
loop_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)),
last_heartbeat: Arc::new(std::sync::atomic::AtomicU64::new(0)),
iteration_used: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),
created_at: None,
knowledge_extracted: false,
working_context: df_ai::context_helpers::WorkingContext::new(),
workflow_id: None,
workflow_dag_summary: None,
}
}
}
impl Default for PerConvState {
fn default() -> Self {
Self::new()
}
}
/// 待审批的工具调用
///
/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk),
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
/// 各只消费自己 kind,入口校验防误调)。
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
/// 审批创建时间(用于超时取消判定)。运行期内存态,不序列化(重启恢复的积压审批按恢复时间计)。
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
#[serde(default)]
pub created_at: Option<std::time::SystemTime>,
/// 阶段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,
}
/// 阶段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, Serialize, Deserialize)]
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, Serialize, Deserialize)]
pub struct PathAuthRequest {
/// 待授权目录列表(规范化父目录,对齐 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>,
}
/// 工具调用草稿(流式收集时的临时结构)
#[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 跟踪进度。"
}))
}