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

2050 lines
106 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.
//! chat 域 IPC — 发送 / 重新生成 / 编辑 / 强制发送 / 停止 / 审批 / 上下文分段与压缩 / 循环控制
//!
//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 chat 域 13 个 IPC +
//! chat 专用 helper(`finalize_pending_placeholders`)与 `PendingToolCallInfo`。
//! 其他域(provider/conversation/skills/config)仍留在 `commands/mod.rs`,后续批拆分。
//!
//! re-export:由 `commands/mod.rs` 经 `pub use self::chat::*;` 拉到 `commands::*`,
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。
use std::sync::atomic::Ordering;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
use df_storage::models::AiProviderRecord;
use df_types::augmentation::{MentionRef, MentionSpanDto};
use df_types::types::new_id;
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};
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。
use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt;
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批,等待确认")就地替换为终态文本。
///
/// 背景(SW-260618-02):审批占位文本在 stop/clear/create/delete/force_send 等
/// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。
/// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。
///
/// 调用时机:在 `pending_approvals.clear()` / `retain` 之前调用(遍历即将被丢弃的条目)。
/// 在 messages 已 clear 的命令(如 ai_chat_clear)里该方法为 no-op
/// (replace_tool_result_content 找不到 tool_result 返回 false),不报错、无副作用。
///
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
///
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
let entries: Vec<(String, Option<String>)> = session
.pending_approvals
.values()
.map(|a| (a.tool_call_id.clone(), a.conversation_id.clone()))
.collect();
for (id, approval_conv) in entries {
// replace_tool_result_content 反向遍历,找不到对应 tool_call_id 时返回 false,无副作用
let target = approval_conv.as_deref().filter(|s| !s.is_empty()).unwrap_or(conv_id);
if !target.is_empty() {
session.conv(target).messages.replace_tool_result_content(&id, final_text);
}
}
}
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现仅测试用例使用。
#[cfg(test)]
pub(crate) fn extract_pinned_goal(text: &str) -> String {
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < n {
if chars[i] == '[' {
// 探测 [kind:...],命中则跳过整段(含 ])。
if let Some(close) = find_mention_close(&chars, i, KINDS) {
i = close + 1;
continue;
}
}
out.push(chars[i]);
i += 1;
}
let cleaned = out.trim();
// 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好)
cleaned.chars().take(GOAL_MAX_CHARS).collect()
}
/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。
///
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
#[allow(dead_code)]
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
let after = open + 1;
for k in kinds {
let kc: Vec<char> = k.chars().collect();
if chars.len() < after + kc.len() {
continue;
}
let slice = &chars[after..after + kc.len()];
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
continue;
}
let mut j = after + kc.len();
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j >= chars.len() || (chars[j] != ':' && chars[j] != '') {
continue;
}
j += 1;
while j < chars.len() {
if chars[j] == ']' {
return Some(j);
}
j += 1;
}
return None; // 未闭合 ] → 不当 mention
}
None
}
/// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。
///
/// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此,
/// 解决「@ 与 / 两条增强通道分别注入」的结构性问题(plan mighty-wibbling-papert.md 核心设计)。
///
/// - `skill`(若 `Some`)转 [`MentionRef::Skill { name }`],由 SkillResolver 走剥 frontmatter 的读取
/// (取代旧实现直接 `read_skill_content` 灌全文含 frontmatter)。
/// - `spans` 按 `kind` 转 [`MentionRef`]:project/task/idea 用 `ref_id` + `label`,skill 用 `label` 作 name。
/// - locality 取当前激活 provider(远程 provider path 仅 basename / 本地全路径,防裸泄)。
/// - 单条 resolve 失败由 `resolve_all` 收集进 errors 不中断整批(整批注入最大化保留可用上下文)。
///
/// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。
///
/// 注:regenerate / edit 路径当前传 `skill=&None, spans=&None`(无新注入诉求),
/// 若后续需从历史末条 user 消息的 mentionSpans resolve,改这两处调用即可。
async fn resolve_and_inject(
state: &AppState,
provider: &AiProviderRecord,
skill: &Option<String>,
spans: &Option<Vec<MentionSpanDto>>,
lang: &str,
) -> String {
let mut refs: Vec<MentionRef> = Vec::new();
if let Some(name) = skill.as_ref().filter(|s| !s.trim().is_empty()) {
refs.push(MentionRef::Skill { name: name.clone() });
}
if let Some(spans) = spans.as_ref() {
for span in spans {
if let Some(rf) = span_to_mention_ref(span) {
refs.push(rf);
}
}
}
if refs.is_empty() {
return String::new();
}
let loc = sanitize::locality_of(provider);
let (augs, _errors) = state.resolvers.resolve_all(&refs, loc).await;
build_augmentation_segment(&augs, lang)
}
/// 单条 [`MentionSpanDto`] → [`MentionRef`] 转换(kind 驱动)。
///
/// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。
/// skill kind 用 `label` 作 name(skill 以 name 为唯一键,DTO 的 ref_id 也即 name,这里取 label
/// 保证展示一致;二者应等价)。
fn span_to_mention_ref(span: &MentionSpanDto) -> Option<MentionRef> {
match span.kind.as_str() {
"project" => Some(MentionRef::Project {
id: span.ref_id.clone(),
name: span.label.clone(),
}),
"task" => Some(MentionRef::Task {
id: span.ref_id.clone(),
name: span.label.clone(),
}),
"idea" => Some(MentionRef::Idea {
id: span.ref_id.clone(),
name: span.label.clone(),
}),
"skill" => Some(MentionRef::Skill {
name: span.label.clone(),
}),
_ => None,
}
}
/// 从 mention_spans 提取 (被@的项目 id 列表, 被@的任务 id 列表)。
///
/// 用于 [`build_system_prompt_with_excluded`] 的去重入参:被@的 project/task 已有 augmentation
/// 精准投影,需从全局清单中排除以免同一实体两次入 prompt(skill/idea 不在清单内,无需排除)。
///
/// - `None` / 空 spans → 两个空 vec(行为退化为无去重的全貌清单,等同旧 build_system_prompt)。
/// - 仅收集 kind == "project" / "task" 的 `ref_id`(idea/skill 不进项目/任务清单)。
/// - 不去重(同实体被@多次理论上不会出现,即便出现 excluded 列表多次命中也只是 continue,无副作用)。
fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<String>, Vec<String>) {
let mut proj = Vec::new();
let mut task = Vec::new();
if let Some(spans) = spans.as_ref() {
for span in spans {
match span.kind.as_str() {
"project" => proj.push(span.ref_id.clone()),
"task" => task.push(span.ref_id.clone()),
_ => {}
}
}
}
(proj, task)
}
// ============================================================
// 发送 / 审批 / 控制
// ============================================================
/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」)
///
/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的
/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑
/// (历史末尾是该 user 消息,LLM 据此再生成)。
///
/// 与 ai_chat_send 的区别:不 push 新 user 消息(用户消息已在历史末尾),仅清旧 AI 回复后
/// 复用同一 agentic loop。生成中拦截,与 send 一致防并发双发。
#[tauri::command]
pub async fn ai_regenerate(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
// conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
// ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("AI 正在生成中,请等待完成".to_string());
}
{
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conversation_id);
// generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// 重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0;
// 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
conv.model_override = model_override.clone();
let popped = conv.messages.pop_last_assistant_round();
if !popped {
// 末尾无 AI 回复可弹。失败重试场景(流式中途失败,这轮 assistant 未持久化)
// 末尾是触发它的 user 消息 → 直接从该 user 重跑(等价重发),不报错。
// 仅空对话/末尾非 user 异常态才真无回复可重生成。
let last_is_user = conv
.messages
.all_messages_clone()
.last()
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.unwrap_or(false);
if !last_is_user {
// generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
return Err("没有可重新生成的回复".to_string());
}
}
}
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
// 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
let mut system_prompt = {
let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
};
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
}
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
});
Ok("ok".to_string())
}
/// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值)
///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
/// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming
/// 可能仍为 true(或反之),预检放行后撞后端 `ai_chat_send` 的 generating 拦截。
/// 发送前调此命令取后端真值,据之与本地 streaming 对齐,消除状态不同步。
#[tauri::command]
pub async fn ai_is_generating(
state: State<'_, AppState>,
// F-260616-09 B 批4:加 conv_id 参数(决策 e 真并发上线),精确查指定 conv 的 generating,
// 不依赖 active_conversation_id 单值(否则多会话下查 A 的生成态会读到 B 的值)。
// conv_id 为空(前端未传/分离窗口无活跃)时 fallback active conv 兼容旧调用方。
conversation_id: Option<String>,
) -> Result<bool, String> {
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。target 解析仍需 active_conversation_id
// (单值读),仅此处轻量 lock 取 active,ConvState 读完全脱离 session lock。
let target = {
let session = state.ai_session.lock().await;
conversation_id.filter(|s| !s.is_empty())
.or_else(|| session.active_conversation_id.clone())
.unwrap_or_default()
};
if target.is_empty() {
// 无目标 conv 且无 active:无任何 conv 在跑,返 false。
return Ok(false);
}
// L2 读侧:统一读 conv_state.is_active()(Generating/Compressed)。
// B-Phase2:切 conv_states(无锁),语义等价(同一 ConvState 值)。
Ok(state.conv_states.is_active(&target))
}
/// 发送消息并获取流式 AI 响应
///
/// 非阻塞:立即返回 "ok",通过 ai-chat-event 事件流式推送
#[tauri::command]
pub async fn ai_chat_send(
app: AppHandle,
state: State<'_, AppState>,
message: String,
language: Option<String>,
skill: Option<String>,
model_override: Option<String>,
// F-260616-09 B 批4:加 conversation_id 参数(决策 e 真并发上线),操作指定 conv 的 per_conv,
// 不依赖 active_conversation_id 单值。前端传 activeConversationId 或目标 conv_id。
// None/空 → 沿用旧逻辑(取 active,首次发送懒创建)。
conversation_id: Option<String>,
// F-260614-05 Phase 2c: 多模态 parts(前端 invoke 透传,serde 反序列化为 ContentPart 数组)。
// None/空 → 等价 user()(纯文本,向后兼容);Some(含 Image 片) → user_parts() + provider vision 端点。
// FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面
// (不进 NodeContext.config/NodeOutput/tool_call schema/IPC 返回值/日志/错误),
// 仅经 ai_chat_send 落 session.messages + 经 provider 请求(波20 truncate_parts 防 DB 撑爆)。
parts: Option<Vec<ContentPart>>,
// Input Augmentation:用户消息内 @ mention 区间元数据(chip 精确替换 + 后端 resolve 注入)。
// None/空 → 无 @ mention(向后兼容,仅 / 技能走 skill 参数);非空 → 按 kind 投影成 Augmentation。
mention_spans: Option<Vec<MentionSpanDto>>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线):
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
// active(旧调用方兼容);若 active 也为 None(首次发送),懒创建新 conv id。
// - per_conv 是唯一真相源,删除顶层双写(批2 桥接已退役)。
let (conv_id, _user_message_id) = {
let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty())
.or_else(|| session.active_conversation_id.clone());
let target = match target {
Some(id) => id,
None => {
// 首次发送懒创建新 conv id(不立即落库,避免空对话残留;
// 实际记录由 save_conversation 在生成内容后 upsert 写入)。
let id = new_id();
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now_millis());
id
}
};
// active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。
// 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。
if session.active_conversation_id.as_deref() != Some(target.as_str()) {
session.active_conversation_id = Some(target.clone());
// 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。
if session.active_conv_created_at.is_none() {
session.active_conv_created_at = Some(now_millis());
}
}
// 入口拦截(双轨收口批1):拦 Stopping/Generating/Compressed,放行 Idle+Error。
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。原 c.generating 漏拦 Stopping 态,
// can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
if !state.conv_states.can_accept_request(&target) {
return Err("AI 正在生成中,请等待完成".to_string());
}
let conv = session.conv(&target);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验
// (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。
conv.model_override = model_override.clone();
// F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname}
// 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。
// 非空 message 原样落库。
let user_content = if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else {
message.clone()
};
// F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
// user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
} else {
conv.messages.push(ChatMessage::user(&user_content));
}
// F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id();
(target, user_msg_id)
};
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:从 mention_spans 提取被@的 project/task id,传给 prompt 构建器从全局清单中排除
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
}
// conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
// 知识注入:检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开)
// 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt]
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
// 在后台任务中执行流式调用
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
});
Ok("ok".to_string())
}
/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
app: AppHandle,
state: State<'_, AppState>,
tool_call_id: String,
approved: bool,
) -> Result<String, String> {
let __lock_t534 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let approval = match session.pending_approvals.remove(&tool_call_id) {
Some(a) => a,
None => {
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
// 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
// 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
Ok(Some(rec)) => {
if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
// BUG-260618-11 + 关闭/拒绝无效根因修复(2026-08):
// 内存无此 pending(remove 返 None)且审计表已落定(completed/rejected/failed)时,
// 说明该审批**已处理但前端状态未收敛**——典型路径:对话 pending_approvals JSON 列
// 残留陈旧条目(recovered 后内存与 DB 不同步 / 跨会话恢复竞态),前端 ApprovalPopup
// /ToolCard 显示挂起卡,用户点拒绝 → 本分支原 return Ok 不 emit 任何事件 →
// 主窗口 pendingApprovals 永不清(useAiEvents 仅 AiApprovalResult 事件驱动清)→
// 卡死"关不了"。修复:即便已处理也 emit AiApprovalResult{approved:false},
// 前端 useAiEvents:556 filter p.id !== event.id 收敛清掉(幂等,重复 emit 无害)。
// conv_id 取审计记录的 conversation_id(无则 None,前端按 id 过滤不依赖 conv_id)。
let ev = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: rec.conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
return Ok(format!("已处理({})", rec.status));
}
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
}
Ok(None) => { String::new() /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
Err(e) => {
tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e);
return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e));
}
};
return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
}
};
// 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
// 若前端误调 ai_approve 消费 kind==Path 挂起(应走 ai_authorize_dir)→ Err 防误调,
// 避免把路径授权挂起当 Risk 审批执行(会跳过白名单写入,语义错乱)。
// 兜底/回退:误调返回 Err 前不回滚 insert(前端拿 Err 自行刷新 pending,等价原「未找到」语义)。
if !matches!(approval.kind, ApprovalKind::Risk { .. }) {
return Err(format!(
"tool_call_id={} 非普通审批(路径授权请用 ai_authorize_dir)",
tool_call_id
));
}
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
let _recovered = approval.recovered;
if !approved {
// 替换占位 tool_result 为拒绝结果
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
// (审计仍记 rejected),避免写错 conv。
if let Some(ref cid) = approval.conversation_id {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
// (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
let reject_payload = serde_json::json!({
"status": "rejected",
"reason": "user_denied",
"message": "用户拒绝了此操作"
}).to_string();
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &reject_payload);
}
let conv_id = approval.conversation_id.clone();
let ev = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
drop(session);
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// 审计:拒绝(决策者=human)
audit_finalize(&state, &tool_call_id, "rejected", None).await;
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
let start_iter = {
let __lock_t610 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = conv_id.as_deref()
.and_then(|c| session.conv_read(c))
.map(|c| c.iteration_used)
.unwrap_or(0);
let __hold610 = __lock_t610.elapsed();
if __hold610 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
}
v
};
// 所有待审批处理完毕后恢复 agentic 循环
// F-09 B 批2:传 conv_id(approval.conversation_id),try_continue 按 conv 过滤 pending/读 per_conv。
let cont_conv_id = conv_id.clone().unwrap_or_default();
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
return Ok("rejected".to_string());
}
// 执行工具(通过真实 repo 调用)
let args = approval.arguments.clone();
let id = tool_call_id.clone();
let conv_id = approval.conversation_id.clone();
// AE-2025-04 会话级信任:用户人工批准 write_file / run_command 时,把该工具+目录写入
// session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。
// 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
// F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 completed)。
if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) {
let inserted = if let Some(ref cid) = conv_id {
session.conv(cid).session_trust.insert(key.clone())
} else {
false
};
if inserted {
tracing::info!(
tool = %approval.tool_name,
key = ?key,
"[AE-2025-04] 人工审批通过,记录会话信任: 下次同类操作自动放行"
);
}
}
let __hold534 = __lock_t534.elapsed();
if __hold534 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
}
drop(session); // 释放锁后再执行
// B-260617-01 决策 a(方案 a2 后端分支)run_workflow 特殊处理。
// run_workflow handler(tool_registry.rs:544)仅持 db:Arc<Database>,无 AppHandle/State
// (registry/event_bus/workflows Repo),经 ai_tools.execute 调用必然 Err。原行为:审批通过 →
// handler Err → tool_result=错误提示 → LLM 重试 → 又进审批 → 死循环(工具未标 no_retry)。
// 修复:识别 run_workflow → 不走 ai_tools.execute → 直接调工作流执行函数(持有完整 State,
// 同 invoke('run_workflow') IPC 入口)→ 结果回填 tool_result(让 LLM 收成功结果停止重试)。
// 选 a2 而非 a1(前端拦截)ai_approve 已持 state+app,后端统一更简,无需新增前端 IPC +
// 不把工作流知识泄漏到前端。失败(tool 未注册/参数缺失)按原 Err 路径处理(failed tool_result)。
//
// 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args).await
} else {
// F-260620 对齐 ai_authorize_dir(:658):ai_approve 执行同样包 60s 超时。
// 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue
// → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 同型故障,
// 之前只给 ai_authorize_dir 加超时,ai_approve 漏)。
// run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。非 run_command 不注入。
let sink = if approval.tool_name == "run_command" {
Some(super::super::command_stream::CommandSink::new(
app.clone(), id.clone(), conv_id.clone(),
))
} else { None };
let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone()));
match tokio::time::timeout(
std::time::Duration::from_secs(60),
exec_fut,
).await {
Ok(r) => r,
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })),
}
};
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result {
Ok(val) => ("completed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 审计:人工审批后无论成败回填(决策者=human)
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
// AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
state.reload_allowed_dirs().await;
}
}
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
let __lock_t704 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id,
approved: true,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
let __hold704 = __lock_t704.elapsed();
if __hold704 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704);
}
drop(session);
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
// create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
// 故补全点放此(工具已 completed,可拿到 result.id + args.source)。
// message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
// assistant_with_tools 消息持久驻留 messages,审批时仍可读。
// 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
if audit_status == "completed" {
let message_id = {
let __lock_t735 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = conv_id.as_deref().and_then(|cid| {
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
});
let __hold735 = __lock_t735.elapsed();
if __hold735 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
}
v
};
super::super::audit::maybe_fill_idea_source(
&state.db,
&approval.tool_name,
&approval.arguments,
&result_val,
message_id.as_deref(),
).await;
}
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源;无 conv_id 时 start_iter=0。
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
let __lock_t761 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold761 = __lock_t761.elapsed();
if __hold761 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
}
v
};
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
Ok("completed".to_string())
}
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
///
/// 仅做词法判断(取前两字符 `<drive>:`),不做 canonicalize(避免 IO 卡死——这正是本批
/// 防卡死预检的目标)。UNC 路径 `\\server\share` 返 None(跨盘语义不适用)。
fn drive_letter(path: &str) -> Option<String> {
let bytes = path.as_bytes();
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
let drive = (bytes[0] as char).to_ascii_uppercase();
return Some(format!("{}:", drive));
}
None
}
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
fn workspace_drive() -> Option<String> {
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
}
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
///
/// 仅对涉及文件移动/删除的工具(delete_file / rename_file)有意义——这些工具的目标位
/// (.trash 或 rename 的 to)锚定 workspace_root,源在另一盘时走 copy+remove 降级。
/// 其他文件工具(read_file/write_file)无跨盘移动语义,不提示。
fn cross_drive_hint(tool_name: &str, raw_paths: &[String]) -> Option<String> {
// 仅文件移动/删除类工具涉及跨盘(源在 A 盘 → 目标 .trash/to 在 workspace 盘)
if !matches!(tool_name, "delete_file" | "rename_file" | "move_file") {
return None;
}
let ws_drive = workspace_drive()?;
let cross_drives: Vec<&String> = raw_paths.iter()
.filter(|p| drive_letter(p).map_or(false, |d| d != ws_drive))
.collect();
if cross_drives.is_empty() {
return None;
}
Some(format!(
"⚠ 跨盘提示:路径 {:?} 与工作区({})不同盘,工具内部走 copy+remove 降级(非原子,大文件较慢,失败可能留残,已内置回滚)",
cross_drives, ws_drive
))
}
/// 把跨盘提示追加到 tool_result(保留原结果,仅追加提示行)。
///
/// - String 结果:追加 `\n<提示>`。
/// - Object 结果:追加 `cross_drive_note` 字段(不覆盖原字段)。
/// - 其他类型:包成 Object 的 `result` + `cross_drive_note` 双字段。
fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json::Value {
match result {
serde_json::Value::String(s) => serde_json::Value::String(format!("{}\n{}", s, note)),
serde_json::Value::Object(mut map) => {
map.insert("cross_drive_note".to_string(), serde_json::Value::String(note.to_string()));
serde_json::Value::Object(map)
}
other => serde_json::json!({
"result": other,
"cross_drive_note": note,
}),
}
}
/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
///
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
///
/// `decision`(三授权档 + 拒):
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
///
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。
#[tauri::command]
pub async fn ai_authorize_dir(
app: AppHandle,
state: State<'_, AppState>,
tool_call_id: String,
decision: String,
) -> Result<String, String> {
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
// 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。
tracing::debug!(
tool_call_id = %tool_call_id,
decision = %decision,
"[AI-AUTHZ] ai_authorize_dir 入口"
);
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
let approval = {
let __lock_t864 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let v = match session.pending_approvals.remove(&tool_call_id) {
Some(a) => a,
None => {
// 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。
drop(session);
// 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复):
// path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留
// 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端
// pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时
// emit AiApprovalResult{approved:false} 让前端收敛(幂等)。
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => {
let ev = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: rec.conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
return Ok(format!("已处理({})", rec.status));
}
_ => {}
}
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
}
};
let __hold864 = __lock_t864.elapsed();
if __hold864 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
}
v
};
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
// 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
// 兜底/回退:Err 前 re-insert 回 pending_approvals 保持挂起态(原「未找到」会丢挂起致卡死,
// re-insert 保守兜底防误调导致 pending 丢失 + loop 永挂)。
let req = match approval.kind.clone() {
ApprovalKind::Path(req) => req,
ApprovalKind::Risk { .. } => {
// 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
let __lock_t878 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.pending_approvals.insert(tool_call_id.clone(), approval);
let __hold878 = __lock_t878.elapsed();
if __hold878 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
}
return Err(format!(
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
tool_call_id
));
}
};
// L1 补丁:req.dirs 含所有未授权父目录(rename_file from/to 不同目录时多项)。
// once/always 遍历全部写入白名单,确保工具执行时 handler 内 resolve 全放行。
let dir_strs: Vec<String> = req.dirs.iter()
.map(|d| d.to_string_lossy().to_string())
.collect();
let conv_id = approval.conversation_id.clone();
let args = approval.arguments.clone();
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
if decision == "deny" {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
let err_msg = serde_json::json!({
"status": "rejected",
"reason": "path_auth_denied",
"message": "用户拒绝路径授权"
}).to_string();
{
let __lock_t903 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
}
let __hold903 = __lock_t903.elapsed();
if __hold903 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
}
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
let __lock_t930 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold930 = __lock_t930.elapsed();
if __hold930 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
}
v
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
return Ok("rejected".to_string());
}
// ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
// deny 已在上方早 return,此处仅 once/session/always。
match decision.as_str() {
"once" => {
for d in &dir_strs {
state.add_once_allowed_dir(d.clone()).await;
}
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
}
"session" => {
for d in &dir_strs {
state.add_session_allowed_dir(d.clone()).await;
}
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
}
"always" => {
for d in &dir_strs {
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
// 降级:写入会话临时授权,保本路径本会话可用
state.add_session_allowed_dir(d.clone()).await;
}
}
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
}
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
}
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// F-260620 防工具卡死 ai_authorize_dir(致 per_conv.generating 永真 + 前端看门狗 130s 超时后
// 静默吞消息):list_directory 等大目录工具可能 read_dir+metadata 海量文件卡住,execute 不返回
// → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → generating 永真。包 60s 超时(对齐
// run_command),超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args).await
} else {
match tokio::time::timeout(
std::time::Duration::from_secs(60),
state.ai_tools.execute(&approval.tool_name, args.clone()),
).await {
Ok(r) => r,
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)),
}
};
tracing::debug!(
ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "completed" } else { "failed" },
"[AI-AUTHZ] execute 后"
);
let (audit_status, mut result_val) = match &exec_result {
Ok(val) => ("completed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
// 检测授权路径(req.raw_paths 规范化前原值)与 workspace_root 是否跨盘(Windows 不同盘符,
// 如 C盘授权路径 → E盘 workspace)。跨盘时文件工具(delete_file→.trash / rename_file from→to)
// 内部走 copy+remove 降级(非原子,大文件慢),提示 LLM 知晓成本 + 失败留残风险。
// 提示追加到 result_val(tool_result),不阻断执行(降级已能处理,仅告知)。
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
result_val = append_cross_drive_note(result_val, &note);
}
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
state.reload_allowed_dirs().await;
}
}
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
state.clear_once_allowed_dirs().await;
// 替换占位 tool_result 为真实结果
{
let __lock_t1018 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
}
let __hold1018 = __lock_t1018.elapsed();
if __hold1018 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
}
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: true,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
let __lock_t1044 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold1044 = __lock_t1044.elapsed();
if __hold1044 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
}
v
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
Ok(audit_status.to_string())
}
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
///
/// 阶段4:`kind` 字段透传(`risk` / `path`),前端 switchConversation 恢复 pendingApprovals 时
/// 按 kind 渲染——risk 类显 approve/reject,path 类显 once/always/deny(对齐阶段3b 统一审批模型)。
#[derive(Debug, Serialize)]
pub struct PendingToolCallInfo {
pub tool_call_id: String,
pub conversation_id: Option<String>,
/// 阶段4:审批类型,前端按 kind 渲染不同决策按钮。
/// 'risk' = 普通 RiskLevel 审批(approve/reject);'path' = 路径授权(once/always/deny)。
#[serde(rename = "kind")]
pub kind: &'static str,
}
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
#[tauri::command]
pub async fn ai_pending_tool_calls(
state: State<'_, AppState>,
conv_id: String,
) -> Result<Vec<PendingToolCallInfo>, String> {
let __lock_t1071 = std::time::Instant::now();
let session = state.ai_session.lock().await;
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
let list = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
.map(|a| PendingToolCallInfo {
tool_call_id: a.tool_call_id.clone(),
conversation_id: a.conversation_id.clone(),
kind: match a.kind {
ApprovalKind::Risk { .. } => "risk",
ApprovalKind::Path(_) => "path",
},
})
.collect();
let __hold1071 = __lock_t1071.elapsed();
if __hold1071 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
}
Ok(list)
}
/// 清空对话历史
#[tauri::command]
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
let __lock_t1095 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
let active_id = session.active_conversation_id.clone();
// SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear,
// 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。
// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写;
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
finalize_pending_placeholders(&mut *session, &active_id.clone().unwrap_or_default(), "会话已清除");
if let Some(ref id) = active_id {
session.conv(id).messages.clear();
}
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref());
let __hold1095 = __lock_t1095.elapsed();
if __hold1095 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
}
drop(session);
// 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7)
// F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None)
// 旧 clear_messages(置 messages='[]')保留调用:备份列同步清空,防 reload fallback 读旧脏数据。
if let Some(id) = active_id {
state
.ai_conversations
.clear_messages(&id)
.await
.map_err(err_str)?;
state
.ai_messages
.delete_range(&id, 0, None)
.await
.map_err(err_str)?;
}
Ok(())
}
/// 手动上下文分段(F-15 阶段2):归档保护区外消息,不删 DB。
///
/// 把保护区外(active)的消息经 messages_mut 标 `status="archived_segment"`
/// (is_active 自动 false,sanitize 隔离不进 LLM 上下文,前端按 is_active 过滤折叠),
/// 保护区最近 N 条 active 不动(用户当前上下文连续性)。落库持久化新 status,不删 DB。
///
/// 空会话/无 active 可分段消息 → emit AiContextCleared 后 noop 返回 Ok(不报错)。
///
/// 与 ai_chat_clear 区别:clear 真删 DB 全清;本命令只软分段(归档),保留可追溯历史。
#[tauri::command]
pub async fn ai_chat_clear_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可分段);messages 操作走 per_conv。
let conv_id = {
let __lock_t1145 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// 保护区:保留最近 PROTECT_COUNT 条 active。
const PROTECT_COUNT: usize = 6;
let conv = session.conv(&conversation_id);
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
// 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
if protect_start == 0 {
drop(session);
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
// msg-split-phase1:改既有消息 status,需标记全量重写(append-only 增量路径不覆盖旧行)。
// 借用结束后单独调 mark_needs_full_rewrite(借用冲突:循环持 &mut 切片时不能再持 &mut self)。
let mut dirtied = false;
for t in conv.messages.messages_mut()[..protect_start].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::ArchivedSegment);
dirtied = true;
}
}
if dirtied {
conv.messages.mark_needs_full_rewrite();
}
let __hold1145 = __lock_t1145.elapsed();
if __hold1145 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
}
drop(session);
conversation_id
};
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(())
}
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
///
/// 流程(失败不阻塞,消息状态不变):
/// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变
///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command]
pub async fn ai_chat_compress_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
// F-260616-09 B 批2:messages 操作改 per_conv(conv_id 来源:IPC 参数 conversation_id,已与 active 校验)。
let (conv_id, active_msgs, compress_end) = {
let __lock_t1205 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):移除 active 一致性校验(真并发下后台 conv 也应可压缩)。
let conv = session.conv(&conversation_id);
if conv.messages.is_compressing() {
return Err("压缩正在进行中".to_string());
}
// emit 开始 + 置位防重入(成对释放,见下方所有出口)
let ev = AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
conv.messages.set_compressing(true);
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
if protect_start == 0 {
// 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok
conv.messages.set_compressing(false);
let cid = session.active_conversation_id.clone();
drop(session);
// 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: cid,
summary: String::new(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
let active_msgs: Vec<ChatMessage> = conv
.messages
.messages_mut()[..protect_start]
.iter()
.filter(|t| t.message.is_active())
.map(|t| t.message.clone())
.collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
let __hold1205 = __lock_t1205.elapsed();
if __hold1205 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
}
(cid, active_msgs, protect_start)
};
if active_msgs.is_empty() {
// 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
let __lock_t1250 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1250 = __lock_t1250.elapsed();
if __hold1250 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
}
drop(session);
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id),
summary: String::new(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环,
// FR-S1 api_key 绝不进 payload/日志/错误信息)
let provider = match super::super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
let __lock_t1269 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1269 = __lock_t1269.elapsed();
if __hold1269 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
}
drop(session);
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Auth),
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Err(format!("压缩失败: {}", e));
}
};
let llm_concurrency = state.llm_concurrency.clone();
let summary = match super::super::compress::compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
&lang,
&conv_id,
&llm_concurrency,
).await {
Ok(s) => s.nl_summary,
Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1295 = __lock_t1295.elapsed();
if __hold1295 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
}
drop(session);
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Unknown),
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Err(format!("压缩失败: {}", e));
}
};
// LLM 成功 → 标 compressed(扣 token) + 摘要插首位
{
let __lock_t1312 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary));
conv.messages.set_compressing(false);
let __hold1312 = __lock_t1312.elapsed();
if __hold1312 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
}
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id),
summary,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(())
}
/// 编辑最后一条 user 消息并重新生成(UX-09)
///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删,
/// 保留 DB 可追溯但不进 LLM 上下文,前端视图过滤)→ save 落库 → spawn run_agentic_loop 重跑。
///
/// 约束:① 只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)② generating 中拦截 ③ 活跃对话一致。
/// 与 ai_regenerate 的区别:不 pop 旧 AI 回复(改标 truncated 保留),且编辑 user 内容后重跑。
#[tauri::command]
pub async fn ai_chat_edit(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
new_message: String,
language: Option<String>,
model_override: Option<String>,
) -> Result<String, String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 替换末条 user content + truncate 其后
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("AI 正在生成中,请等待完成".to_string());
}
{
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conversation_id);
// ① 替换末条 active user 消息 content(无 active user → Err)
if conv.messages.replace_last_active_user_content(&new_message).is_err() {
return Err("没有可编辑的用户消息".to_string());
}
// ② 其后所有消息标 truncated(无后续也 OK,返回 0)
let _ = conv.messages.truncate_after_user_message(&new_message)
.map_err(|_| "定位被编辑消息失败".to_string())?;
// 占用生成态 + stop_flag
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
conv.model_override = model_override.clone();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
}
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
// helper 读到的末条 active user 即编辑后的新文本,语义等价。
let mut system_prompt = {
let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
};
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
}
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(
session_arc,
tools_arc,
db,
app_handle,
provider_config,
system_prompt,
conv_id,
knowledge_config,
llm_concurrency,
max_iterations,
max_retries,
0,
model_override,
)
.await;
});
Ok("ok".to_string())
}
/// 强制发送消息(B-260616-02: L2 发送韧性;BUG-260617-04: 原子复位+占用消除双锁竞态)
///
/// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时,
/// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。
///
/// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送
/// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被
/// reject → 前端以为已强制发送但后端拒绝,状态不一致(BUG-260617-04)。
///
/// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口;
/// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit
/// 的"锁内占用+锁外 spawn"模式),不走 ai_chat_send 门控(避免再次过 generating 检查)。
#[tauri::command]
pub async fn ai_chat_force_send(
app: AppHandle,
state: State<'_, AppState>,
message: String,
language: Option<String>,
skill: Option<String>,
model_override: Option<String>,
// F-260614-05 Phase 2c: 透传 parts(强制发送同款多模态支持)。
// FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面,语义同 ai_chat_send。
parts: Option<Vec<ContentPart>>,
// Input Augmentation:@ mention 区间元数据(同 ai_chat_send,语义一致)。
mention_spans: Option<Vec<MentionSpanDto>>,
// F-260616-09 B 批4(决策 e 真并发上线):加 conversation_id 参数,强制复位+发送仅作用于目标 conv。
// None/空 → fallback active(旧调用方兼容)。
conversation_id: Option<String>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不占用 generating)
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
//
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
let __lock_t1473 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty())
.or_else(|| session.active_conversation_id.clone());
let target = match target {
Some(id) => id,
None => {
let id = new_id();
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now_millis());
id
}
};
if session.active_conversation_id.as_deref() != Some(target.as_str()) {
session.active_conversation_id = Some(target.clone());
if session.active_conv_created_at.is_none() {
session.active_conv_created_at = Some(now_millis());
}
}
// ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
// was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
let was_gen = state.conv_states.is_active(&target);
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "已取消");
// 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
{
let conv = session.conv(&target);
// B-Phase3:conv_state 写切 ConvStateStore 单源。
if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) {
tracing::warn!(
conv_id = %target,
error = %e,
"[ai] conv_states 强制发送→Idle 非法(不阻断强制发送)"
);
}
conv.stop_flag.store(true, Ordering::SeqCst);
}
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位)
let conv = session.conv(&target);
// 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。
conv.iteration_used = 0;
conv.model_override = model_override.clone();
// user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send
let user_content = if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else {
message.clone()
};
// parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
} else {
conv.messages.push(ChatMessage::user(&user_content));
}
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
let __hold1473 = __lock_t1473.elapsed();
if __hold1473 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
}
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
};
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
}
// conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
// 后台 spawn agentic loop(照 ai_chat_send:229-242 / ai_chat_edit:695-721 同款)
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
});
Ok("ok".to_string())
}
/// 停止当前 AI 生成
///
/// 两种场景:
/// - loop 正在流式生成:置 stop_flagstream_llm / 循环检查点尽快退出并 emit AiCompleted
/// - 有挂起审批(loop 已 return 等待中):stop_flag 无人读取,直接清审批 + 复位 generating
/// 否则停止按钮表面无反应、会话卡在 generating=true
#[tauri::command]
pub async fn ai_chat_stop(
state: State<'_, AppState>,
app: AppHandle,
// F-260616-09 B 批4(决策 e 真并发上线):加 conversation_id 参数,停止仅作用于目标 conv。
// None/空 → fallback active(旧调用方兼容)。
conversation_id: Option<String>,
) -> Result<(), String> {
let __lock_t1611 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active。
let target = conversation_id.filter(|s| !s.is_empty())
.or_else(|| session.active_conversation_id.clone())
.unwrap_or_default();
if target.is_empty() {
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session);
return Ok(());
}
// B-Phase3:读侧切无锁 conv_states(单源收敛)。
let is_gen = state.conv_states.is_active(&target);
if !is_gen {
return Ok(());
}
// 目标 conv 是否有待审批(仅本 conv 的)。
let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
if has_pending {
// 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
// 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "会话已停止");
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
let conv = session.conv(&target);
// B-Phase3:conv_state 写切 ConvStateStore 单源(per_conv 字段已删)。
{
let conv_states = app.state::<AppState>().conv_states.clone();
if let Err(e) = conv_states.transition(&target, ConvState::Idle) {
tracing::warn!(
conv_id = %target,
error = %e,
"[ai] conv_states ai_chat_stop 审批分支→Idle 非法(不阻断 emit AiCompleted)"
);
}
}
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let pinned_goals = conv.pinned_goals.clone();
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session);
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
conversation_id: Some(target),
pinned_goals,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 流式生成中:置位让目标 conv 的 loop 自行收尾
let notify = {
let conv = session.conv(&target);
conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.clone()
};
// B-260615-14:置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
notify.notify_one();
let conv_id = Some(target);
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
drop(session);
// B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
// 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检目标 conv generating
// 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。
// 正常路径(loop 活自行复位)此时 generating 已 false,无操作退出。
let session_arc = state.ai_session.clone();
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let __lock_t1686 = std::time::Instant::now();
let mut session = session_arc.lock().await;
let target_owned = conv_id.clone().unwrap_or_default();
// B-Phase3:读侧切无锁 conv_states。
let still_gen = if target_owned.is_empty() {
false
} else {
app.state::<AppState>().conv_states.is_active(&target_owned)
};
if still_gen {
// B-Phase3:conv_state 写切 ConvStateStore 单源。
{
let conv_states = app.state::<AppState>().conv_states.clone();
if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) {
tracing::warn!(
conv_id = %target_owned,
error = %e,
"[ai] conv_states 3秒超时→Idle 非法(不阻断 emit AiCompleted)"
);
}
}
let pinned_goals = session.conv(&target_owned).pinned_goals.clone();
let __hold1686 = __lock_t1686.elapsed();
if __hold1686 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
}
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
pinned_goals,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
} else {
let __hold1686 = __lock_t1686.elapsed();
if __hold1686 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
}
}
});
Ok(())
}
/// 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态用户点「继续」)
///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出)
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loop。iteration 从 0 重计(天然再跑
/// max_iterations 轮,决策 a),F-260616-11 落地后:重置 session.iteration_used=0 + 传
/// start_iteration=0(达 max 续跑重计);审批续跑(ai_approve)则累计传 session.iteration_used,
/// 两路径区分见 run_agentic_loop 达 max 分支注释。
///
/// 校验复用 ai_approve 模式:generating 必须为 true(暂停态)+ active_conversation_id 一致性
/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b:用户主动授权 = 同意烧 token)。
#[tauri::command]
pub async fn ai_continue_loop(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
{
let __lock_t1744 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
// B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) {
let __hold1744 = __lock_t1744.elapsed();
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
return Err("AI 未在暂停态,无需继续".to_string());
}
let conv = session.conv(&conversation_id);
// 复位停止信号:暂停态可能因上一轮 stop_flag 残留为 true,续跑 loop 入口会立即退出走完成流程
conv.stop_flag.store(false, Ordering::SeqCst);
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
conv.iteration_used = 0;
let __hold1744 = __lock_t1744.elapsed();
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
}
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
try_continue_agent_loop(&app, &state, &conversation_id, 0).await;
Ok("ok".to_string())
}
/// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」)
///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。
///
/// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic/mod.rs
/// 达上限分支),此处仅复位生成态 + emit AiCompleted 通知前端收尾。校验复用 ai_approve
/// 模式(生成态 + active_conversation_id 一致性)。
#[tauri::command]
pub async fn ai_stop_loop(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
{
let __lock_t1782 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可停止)。
// B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) {
let __hold1782 = __lock_t1782.elapsed();
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
return Err("AI 未在暂停态,无需停止".to_string());
}
let conv = session.conv(&conversation_id);
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
conv.stop_flag.store(true, Ordering::SeqCst);
// B-Phase3:conv_state 写切 ConvStateStore 单源。
if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Idle) {
tracing::warn!(
conv_id = %conversation_id,
error = %e,
"[ai] conv_states ai_stop_loop→Idle 非法(不阻断 emit AiCompleted)"
);
}
pinned_goals = conv.pinned_goals.clone();
let __hold1782 = __lock_t1782.elapsed();
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
}
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(conversation_id),
pinned_goals,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok("ok".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// ============================================================
// G1 extract_pinned_goal 单测
// ============================================================
#[test]
fn test_extract_plain_goal() {
let goal = extract_pinned_goal("实现代码审查功能");
assert_eq!(goal, "实现代码审查功能");
}
#[test]
fn test_extract_strip_mention_suffix() {
let goal = extract_pinned_goal("分析灵感模块不足 [项目: DevFlow]");
assert_eq!(goal, "分析灵感模块不足");
}
#[test]
fn test_extract_strip_mention_prefix() {
let goal = extract_pinned_goal("[任务: T-123] 修复登录页白屏");
assert_eq!(goal, "修复登录页白屏");
}
#[test]
fn test_extract_empty_input() {
let goal = extract_pinned_goal("");
assert!(goal.is_empty());
}
#[test]
fn test_extract_only_mention() {
let goal = extract_pinned_goal("[项目: DevFlow]");
assert!(goal.is_empty());
}
#[test]
fn test_extract_english_kind() {
let goal = extract_pinned_goal("Fix login bug [task: T-789]");
assert_eq!(goal, "Fix login bug");
}
#[test]
fn test_extract_fullwidth_colon() {
let goal = extract_pinned_goal("重构模块 [项目:DevFlow]");
assert_eq!(goal, "重构模块");
}
#[test]
fn test_extract_truncate_long() {
let long = "a".repeat(GOAL_MAX_CHARS + 100);
let goal = extract_pinned_goal(&long);
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
}
// ============================================================
// 低价值目标过滤
// ============================================================
#[test]
fn test_extract_mention_unclosed() {
let goal = extract_pinned_goal("测试 [项目: DevFlow");
assert_eq!(goal, "测试 [项目: DevFlow");
}
}