2050 lines
106 KiB
Rust
2050 lines
106 KiB
Rust
//! 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, ¬e);
|
||
}
|
||
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_flag,stream_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");
|
||
}
|
||
}
|