修复: extract_key_info 截断标记统一 + 断路器 key 滑动窗口 + 桌面跨端 4 路由 + dead_code 预留标 allow

This commit is contained in:
lxy
2026-08-01 15:17:24 +08:00
parent db99e7107c
commit 7d33442199
4 changed files with 377 additions and 23 deletions
+2 -1
View File
@@ -410,10 +410,11 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX { } else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。 // 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。 // 按字符数截断保留头尾,保证压缩至少生效。
// 标记格式与纯文本单行分支对齐:含 `(截断)` 连续子串,便于上层断言/解析。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect(); let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect(); let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
*field = serde_json::Value::String(format!( *field = serde_json::Value::String(format!(
"{}...(截断,原始 {} 字符)...{}", "{}...(截断) 原始 {} 字符...{}",
head, s.chars().count(), tail head, s.chars().count(), tail
)); ));
truncated = true; truncated = true;
+110 -18
View File
@@ -196,12 +196,21 @@ pub const STALL_BREAKER_GOAL_REMIND: bool = true;
/// ///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls /// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 → /// 后取末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一为 key,
/// 入滚动窗口(CIRCUIT_BREAKER_WINDOW)统计,同一 key 在窗口内累计达此阈值 →
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。 /// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
/// ///
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。 /// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3; pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// L1 断路器滑动窗口大小:仅保留最近 N 条失败记录参与计数。
///
/// 治"长任务偶发失败误熔断":全 loop 累加会让早期偶发失败与后期同类失败叠加触发。
/// 滚动窗口让计数只反映最近的失败密度——长任务中途偶发 1~2 次同类失败不触发,
/// 真正的连续死循环(N 条全同 key)才触发。N=20:对齐 max_iterations 量级,既覆盖
/// 单轮并行失败爆发,又足够长以容忍偶发抖动。
pub const CIRCUIT_BREAKER_WINDOW: usize = 20;
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations /// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。 /// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
pub const CIRCUIT_BREAKER_ENABLED: bool = true; pub const CIRCUIT_BREAKER_ENABLED: bool = true;
@@ -828,11 +837,13 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false; let mut converged = false;
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。 // L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 // key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new(); // 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。 // G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1, // 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。 // 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
let mut stall_count: u32 = 0; let mut stall_count: u32 = 0;
@@ -1548,9 +1559,9 @@ pub(crate) async fn run_agentic_loop(
); );
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
// count_recent_failures 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 计数。 // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。
if CIRCUIT_BREAKER_ENABLED { if CIRCUIT_BREAKER_ENABLED {
let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_counts).await; let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_window).await;
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。 // 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
if max_count >= CIRCUIT_BREAKER_THRESHOLD { if max_count >= CIRCUIT_BREAKER_THRESHOLD {
tracing::warn!( tracing::warn!(
@@ -1694,12 +1705,21 @@ async fn heartbeat_loop(
} }
// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── // ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ──
// 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 累入 fail_counts。 // 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key,
// 返回 (max_count, sample_key):max_count=0 表示无失败,不触发熔断 // 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)
// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。
//
// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减):
// 1. key 归一:失败 envelope 内容(不同路径/参数)前 40 字符易把同类失败拆成多 key
// (治不住)或异类失败误聚成一类(误熔断)。改读结构化信号——
// is_failure_content 已结构化判成败,本函数按 tool_name + 错误类别(exit_code/错误关键词
// 哈希)归一 key,同一工具的同类错误稳定聚到同一 key。
// 2. 滚动窗口:全 loop 累加会让早期偶发失败与后期叠加触发误熔断。VecDeque 仅保留最近 N 条,
// 计数只反映最近失败密度——长任务中途偶发 1~2 次不触发,真正连续死循环才触发。
async fn count_recent_failures( async fn count_recent_failures(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
fail_counts: &mut std::collections::HashMap<String, u32>, fail_window: &mut std::collections::VecDeque<String>,
) -> (u32, String) { ) -> (u32, String) {
let messages = { let messages = {
let session = session_arc.lock().await; let session = session_arc.lock().await;
@@ -1708,23 +1728,95 @@ async fn count_recent_failures(
None => Vec::new(), None => Vec::new(),
} }
}; };
// 末尾连续 Tool 消息(本轮工具回填结果)。
let recent_tool_results: Vec<&ChatMessage> = messages let recent_tool_results: Vec<&ChatMessage> = messages
.iter().rev() .iter().rev()
.take_while(|m| matches!(m.role, MessageRole::Tool)) .take_while(|m| matches!(m.role, MessageRole::Tool))
.collect(); .collect();
for m in recent_tool_results { if recent_tool_results.is_empty() {
if is_failure_content(&m.content) { return (0u32, String::new());
let key: String = m.content.chars().take(40).collect(); }
*fail_counts.entry(key).or_insert(0) += 1; // tool_call_id → tool_name 反查表:遍历 Assistant 消息的 tool_calls(开销与消息数线性)。
// tool_result 消息只带 tool_call_id,工具名在其前驱 Assistant 头里。
let mut id_to_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for m in &messages {
if let MessageRole::Assistant = m.role {
if let Some(calls) = m.tool_calls.as_ref() {
for c in calls {
id_to_name.insert(c.id.as_str(), c.function.name.as_str());
}
}
} }
} }
fail_counts // 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。
.iter() for m in recent_tool_results.into_iter().rev() {
.max_by_key(|(_, &v)| v) if is_failure_content(&m.content) {
.map(|(k, &v)| (v, k.clone())) let tool_name = m
.tool_call_id
.as_deref()
.and_then(|id| id_to_name.get(id).copied())
.unwrap_or("unknown_tool");
let key = failure_key(tool_name, &m.content);
fail_window.push_back(key);
while fail_window.len() > CIRCUIT_BREAKER_WINDOW {
fail_window.pop_front();
}
}
}
// 窗口内同 key 频次最高者 = 熔断候选。
let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
for k in fail_window.iter() {
*freq.entry(k.as_str()).or_insert(0) += 1;
}
freq.into_iter()
.max_by_key(|(_, v)| *v)
.map(|(k, v)| (v, k.to_string()))
.unwrap_or((0u32, String::new())) .unwrap_or((0u32, String::new()))
} }
/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。
///
/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。
/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`:
/// 用 fxhash 风格简单 FNV-1a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key
/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。
/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数,
/// 此处兜底返回 `tool_name::unknown` 防御。
fn failure_key(tool_name: &str, content: &str) -> String {
let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
return format!("{}::unknown", tool_name);
};
if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) {
return format!("{}::exit={}", tool_name, exit);
}
// 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。
let bucket = match v.get("status").and_then(|s| s.as_str()) {
Some("failed") => "failed",
Some("error") => "error",
_ => "other",
};
let err_text = v
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("")
.trim();
if err_text.is_empty() {
return format!("{}::{}", tool_name, bucket);
}
let h = fnv1a_32(err_text.as_bytes());
format!("{}::{}:{:08x}", tool_name, bucket, h)
}
/// FNV-1a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。
fn fnv1a_32(bytes: &[u8]) -> u32 {
let mut hash: u32 = 0x811c9dc5;
for &b in bytes {
hash ^= b as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ── // ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ──
// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含 // 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含
// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容 // error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容
@@ -6,6 +6,16 @@
//! //!
//! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入 //! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入
//! `PerConvState.workflow_dag_summary`agentic loop 自动注入 system prompt。 //! `PerConvState.workflow_dag_summary`agentic loop 自动注入 system prompt。
//!
//! 预留说明(dead_code):本模块的 3 个函数当前无生产调用方——
//! T4 消费侧已落地(`agentic/mod.rs` 在构建 system prompt 时读
//! `PerConvState.workflow_dag_summary` 并注入),但生产侧尚无人写入该字段
//! `PerConvState::new` 初始化为 `None`,无代码调用 `build_dag_summary`)。
//! 即完整链路为「工作流启动器 → 调本模块产出摘要 → 写 workflow_dag_summary
//! → agentic loop 注入 system prompt」,中间「生产者」环节待接通。
//! 属为后续工作流上下文注入准备的预留,非真死代码,故 allow 抑制告警。
#![allow(dead_code)]
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
+255 -4
View File
@@ -62,9 +62,10 @@ use crate::state::AppState;
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取), // 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
// 故 import 不含 Manager(避免 unused)。 // 故 import 不含 Manager(避免 unused)。
use crate::commands::ai::{ use crate::commands::ai::{
ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
AiChatEvent, ApprovalKind, ConvSummary, PendingApproval, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind,
ConvSummary, PendingApproval,
}; };
// F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command], // F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command],
// 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。 // 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。
@@ -74,7 +75,8 @@ use crate::commands::ai::skills::SkillInfo;
// MentionSpanDto 来自 df_types::augmentation(df-types 已是 src-tauri 依赖,chat.rs:20 同源)。 // MentionSpanDto 来自 df_types::augmentation(df-types 已是 src-tauri 依赖,chat.rs:20 同源)。
use df_types::augmentation::MentionSpanDto; use df_types::augmentation::MentionSpanDto;
// F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。 // F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。
use df_ai::provider::ChatMessage; // ContentPart:ai_chat_force_send 的 parts 参数类型(多模态,远程透传同 ai_chat_send)。
use df_ai::provider::{ChatMessage, ContentPart};
// ============================================================ // ============================================================
// MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93) // MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93)
@@ -164,6 +166,33 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
route_send_message(&app, &state, command.args).await; route_send_message(&app, &state, command.args).await;
} }
// ── force_send:ai_chat_force_send(强制复位+发送,绕过 generating guard) ──
// 与 send_message 同语义但「强制」:内部原子复位目标 conv 旧生成态再占用。
// 故 R1 generating 检查在此禁用(否则与「强制」语义矛盾,用户无法挣脱卡死态)。
// 对齐桌面端 force_send IPC,补 miniapp 经 relay 调不到的路由缺口。
"force_send" => {
route_force_send(&app, &state, command.args).await;
}
// ── edit_message:ai_chat_edit(改末条 user 后重发) ──
// edit 内部自有 can_accept_request guard(生成中返 Err),桥接层不再加 R1
// (R1 仅 send_message 硬性双保险,edit/compress/clear 各自有内部 guard)。
"edit_message" => {
route_edit_message(&app, &state, command.args).await;
}
// ── compress_context:ai_chat_compress_context(LLM 压缩历史) ──
// 内部 is_compressing 防重入 guard,失败返 Err。对齐桌面端 IPC 路由缺口。
"compress_context" => {
route_compress_context(&app, &state, command.args).await;
}
// ── clear_context:ai_chat_clear_context(分段清历史) ──
// 内部按 PROTECT_COUNT 保留近条,空会话 noop + emit。对齐桌面端 IPC 路由缺口。
"clear_context" => {
route_clear_context(&app, &state, command.args).await;
}
// ── stop:ai_chat_stop(conversation_id?) ── // ── stop:ai_chat_stop(conversation_id?) ──
// 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431), // 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431),
// 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。 // 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。
@@ -741,6 +770,197 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
} }
} }
// ============================================================
// 桌面独占命令跨端补路由(force_send / edit_message / compress_context / clear_context)
// ============================================================
//
// 背景:此 4 命令桌面端 IPC 有(chat.rs ai_chat_force_send / ai_chat_edit /
// ai_chat_compress_context / ai_chat_clear_context),但 remote_bridge 路由表缺,
// miniapp 经 relay 调不到。本批仅补 4 路由,对齐 route_send_message 模式:
// - 直接 async 调对应 ai_chat_xxx(非 IPC invoke)
// - 错误处理对齐:Err<String> 转 AiError emit 回 miniapp(命令返 Err 静默则 miniapp 不知失败)
//
// R1 generating 检查的取舍:
// - force_send:**禁用 R1**(强制复位+发送是其核心语义,R1 拦截会剥夺「挣脱卡死态」能力)
// - edit / compress / clear:不加 R1(各自内部已有 guard —— can_accept_request /
// is_compressing / 空会话 noop;R1 仅 send_message 硬性双保险,不泛化)
/// `force_send` 路由 —— 强制复位目标 conv 旧生成态后发送(绕过 generating guard)。
///
/// 与 `route_send_message` 同参数面(message / conversation_id? / skill? / model_override?
/// / parts? / mention_spans?),区别:
/// - **禁用 R1 generating 检查** —— force_send 语义就是「无视当前生成态强制重发」,
/// ai_chat_force_send 内部原子「复位目标 conv 的 generating + 占用」(锁内瞬变无观察窗),
/// R1 拦截会让 force_send 永远打不出去(用户卡死时无法挣脱)。
/// - 仍广播 AiUserMessage —— force_send 会追加新 user 消息(同 send_message),
/// 桌面 useAiEvents 需据此补 user 气泡(否则只见孤立 assistant 响应)。
///
/// message 必填,缺失跳过。Err 转 AiError emit 回 miniapp(对齐 route_send_message)。
async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
let message = match args_get_string(&args, "message") {
Some(m) => m,
None => {
tracing::warn!("[remote_bridge] force_send 缺 message 参数,忽略");
return;
}
};
let conversation_id = args_get_string(&args, "conversation_id");
let model_override = args_get_string(&args, "model_override");
let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty());
let mention_spans = args_get_mention_spans(&args, "mention_spans");
// parts 多模态片段(对齐 ai_chat_force_send 第 6 参,远程透传同 ai_chat_send)。
let parts = args_get_parts(&args, "parts");
// 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage {
message: message.clone(),
conversation_id: conversation_id.clone(),
});
let result = ai_chat_force_send(
app.clone(),
state.clone(),
message,
None, // language:远程默认不传,内部 fallback "zh-CN"
skill, // skill:/ 联想选中技能名透传(技能正文注入)
model_override, // model_override:远程透传用户选择
parts, // parts:多模态片段透传(对齐 ai_chat_force_send)
mention_spans, // mention_spans:@ mention 区间透传
conversation_id.clone(),// conversation_id:强制复位+发送仅作用于目标 conv
)
.await;
if let Err(err_msg) = result {
tracing::warn!(
error = %err_msg,
conv_id = ?conversation_id,
"[remote_bridge] ai_chat_force_send 返回 Err,转 AiError emit 回 miniapp"
);
let _ = app.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown),
conversation_id,
},
);
}
}
/// `edit_message` 路由 —— 改末条 active user 消息 content 后重发(truncate 其后)。
///
/// 参数:conversation_id(必填) + new_message(必填) + language? + model_override?。
/// 内部自有 can_accept_request guard(生成中返 Err),不加 R1。
///
/// 不广播 AiUserMessage —— edit 是替换末条 user(非新增),前端经 AiChatEvent 流自维护
/// (广播会致双气泡)。conversation_id / new_message 必填,缺失跳过。
async fn route_edit_message(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
let (conversation_id, new_message) = match (
args_get_string(&args, "conversation_id"),
args_get_string(&args, "new_message"),
) {
(Some(id), Some(msg)) => (id, msg),
_ => {
tracing::warn!(
"[remote_bridge] edit_message 缺 conversation_id 或 new_message 参数,忽略"
);
return;
}
};
let language = args_get_string(&args, "language");
let model_override = args_get_string(&args, "model_override");
let result = ai_chat_edit(
app.clone(),
state.clone(),
conversation_id.clone(),
new_message,
language,
model_override,
)
.await;
if let Err(err_msg) = result {
tracing::warn!(
error = %err_msg,
conv_id = %conversation_id,
"[remote_bridge] ai_chat_edit 返回 Err,转 AiError emit 回 miniapp"
);
let _ = app.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown),
conversation_id: Some(conversation_id),
},
);
}
}
/// `compress_context` 路由 —— LLM 压缩目标 conv 历史(保留近 PROTECT_COUNT 条)。
///
/// 参数:conversation_id(必填) + language?。内部 is_compressing 防重入 guard(返 Err),
/// LLM 失败则消息状态完全不变。不加 R1(compress 期间 loop 仍活跃但属预期)。
/// conversation_id 必填,缺失跳过。Err 转 AiError emit 回 miniapp。
async fn route_compress_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
let conversation_id = match args_get_string(&args, "conversation_id") {
Some(id) => id,
None => {
tracing::warn!("[remote_bridge] compress_context 缺 conversation_id 参数,忽略");
return;
}
};
let language = args_get_string(&args, "language");
let result =
ai_chat_compress_context(app.clone(), state.clone(), conversation_id.clone(), language).await;
if let Err(err_msg) = result {
tracing::warn!(
error = %err_msg,
conv_id = %conversation_id,
"[remote_bridge] ai_chat_compress_context 返回 Err,转 AiError emit 回 miniapp"
);
let _ = app.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown),
conversation_id: Some(conversation_id),
},
);
}
}
/// `clear_context` 路由 —— 分段清目标 conv 历史(保留近 PROTECT_COUNT 条)。
///
/// 参数:conversation_id(必填)。空会话/全在保护区 noop + emit AiContextCleared。
/// 不加 R1(clear 是即时段落操作,无生成态依赖)。conversation_id 必填,缺失跳过。
/// Err 转 AiError emit 回 miniapp。
async fn route_clear_context(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
let conversation_id = match args_get_string(&args, "conversation_id") {
Some(id) => id,
None => {
tracing::warn!("[remote_bridge] clear_context 缺 conversation_id 参数,忽略");
return;
}
};
let result = ai_chat_clear_context(app.clone(), state.clone(), conversation_id.clone()).await;
if let Err(err_msg) = result {
tracing::warn!(
error = %err_msg,
conv_id = %conversation_id,
"[remote_bridge] ai_chat_clear_context 返回 Err,转 AiError emit 回 miniapp"
);
let _ = app.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: err_msg,
error_type: Some(crate::commands::ai::ErrorType::Unknown),
conversation_id: Some(conversation_id),
},
);
}
}
/// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑), /// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑),
/// 返 `None` 表示放行(可发)。 /// 返 `None` 表示放行(可发)。
/// ///
@@ -823,6 +1043,37 @@ fn args_get_mention_spans(args: &Value, key: &str) -> Option<Vec<MentionSpanDto>
} }
} }
/// 从 args 取 parts 字段(数组)反序列化为 `Vec<ContentPart>`(多模态片段)。
///
/// 用于 route_force_send 透传 miniapp 多模态片段到 ai_chat_force_send。对齐
/// ai_chat_force_send 第 6 参 `Option<Vec<ContentPart>>`(chat.rs:1539)。
///
/// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 parts 走 None 的旧路径,
/// 不因 parts 脏数据阻断 force_send 路由)。失败仅 log warn,不 panic。语义同 args_get_mention_spans。
fn args_get_parts(args: &Value, key: &str) -> Option<Vec<ContentPart>> {
match args.get(key) {
Some(Value::Array(_)) => match serde_json::from_value::<Vec<ContentPart>>(args[key].clone())
{
Ok(parts) => {
if parts.is_empty() {
None
} else {
Some(parts)
}
}
Err(e) => {
tracing::warn!(
error = %e,
"[remote_bridge] parts 反序列化失败,降级 None(不阻断 force_send)"
);
None
}
},
// 缺失或非数组 → None(向后兼容无多模态的远程调用)。
_ => None,
}
}
// ============================================================ // ============================================================
// 单元测试 // 单元测试
// ============================================================ // ============================================================