修复: 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
+255 -4
View File
@@ -62,9 +62,10 @@ use crate::state::AppState;
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
// 故 import 不含 Manager(避免 unused)。
use crate::commands::ai::{
ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_conversation_rename,
ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message,
AiChatEvent, ApprovalKind, ConvSummary, PendingApproval,
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
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],
// 经 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 同源)。
use df_types::augmentation::MentionSpanDto;
// 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)
@@ -164,6 +166,33 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
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?) ──
// 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431),
// 与其它命令的 (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 在跑),
/// 返 `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,
}
}
// ============================================================
// 单元测试
// ============================================================