新增: 小程序端跨端AI对话
This commit is contained in:
@@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize};
|
||||
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
|
||||
use df_ai::context::ContextManager;
|
||||
use df_ai::context::ContextConfig;
|
||||
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
|
||||
use df_ai::provider::ChatMessage;
|
||||
|
||||
// ============================================================
|
||||
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
|
||||
@@ -145,6 +147,24 @@ pub enum AiChatEvent {
|
||||
error_type: Option<ErrorType>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
|
||||
///
|
||||
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
|
||||
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
|
||||
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
|
||||
///
|
||||
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
|
||||
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
|
||||
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
|
||||
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
|
||||
/// → **孤立响应气泡**。此事件填补该缺口。
|
||||
///
|
||||
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
|
||||
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
|
||||
AiUserMessage {
|
||||
message: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Agent 循环新一轮(前端需新建 assistant 消息)
|
||||
AiAgentRound { round: u32, conversation_id: Option<String> },
|
||||
/// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」)
|
||||
@@ -200,6 +220,36 @@ pub enum AiChatEvent {
|
||||
options: Vec<String>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
|
||||
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 双职责:
|
||||
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
|
||||
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
|
||||
///
|
||||
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
|
||||
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
|
||||
AiConversationList {
|
||||
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
|
||||
conversations: Vec<ConvSummary>,
|
||||
},
|
||||
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
|
||||
///
|
||||
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
|
||||
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
|
||||
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
|
||||
AiMessageHistory {
|
||||
/// 对话 ID
|
||||
conversation_id: String,
|
||||
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
|
||||
messages: Vec<ChatMessage>,
|
||||
},
|
||||
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
|
||||
///
|
||||
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
|
||||
@@ -214,6 +264,61 @@ pub enum AiChatEvent {
|
||||
conv_state: ConvState,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
|
||||
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
|
||||
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
|
||||
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
|
||||
AiSkillList {
|
||||
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
|
||||
skills: Vec<crate::commands::ai::skills::SkillInfo>,
|
||||
},
|
||||
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
|
||||
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
|
||||
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
|
||||
///
|
||||
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
|
||||
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
|
||||
/// #[tauri::command] 返回值,必然 impl Serialize)。
|
||||
AiEntityList {
|
||||
/// 项目全量列表(list_active,deleted_at IS NULL)
|
||||
projects: Vec<df_storage::models::ProjectRecord>,
|
||||
/// 任务全量列表(list_active 等价,未删任务)
|
||||
tasks: Vec<df_storage::models::TaskRecord>,
|
||||
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
|
||||
ideas: Vec<df_storage::models::IdeaRecord>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95 跨端会话列表同步:会话摘要结构
|
||||
// ============================================================
|
||||
|
||||
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
|
||||
///
|
||||
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
|
||||
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
|
||||
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
|
||||
///
|
||||
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
|
||||
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConvSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
|
||||
pub last_message: Option<String>,
|
||||
/// 最近更新时间(ms 数值)
|
||||
pub updated_at: Option<i64>,
|
||||
/// 创建时间(ms 数值,可选展示)
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
|
||||
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
|
||||
//! | `switch_conversation` | (无,MVP 不处理) | — |
|
||||
//! | `list_conversations` | (无,直接读库+事件推送) | — |
|
||||
//! | `load_messages` | (无,直接读库+事件推送) | conversation_id |
|
||||
//! | `rename_conversation` | `ai_conversation_rename` | conversation_id, title |
|
||||
//! | `sync_pending` | (无,读 session 重发审批事件) | conversation_id |
|
||||
//! | (未知 cmd) | (兜底臂:log+忽略) | — |
|
||||
//!
|
||||
//! ## R1 兜底(硬性,不可省,§3.3 + D6)
|
||||
@@ -38,8 +42,9 @@
|
||||
//! 回调)留待联调轮做,不在本批范围。
|
||||
//!
|
||||
//! dead_code:本模块入口 `handle_remote_command` 及其内部链(route_send_message /
|
||||
//! check_generating_reject / MiniCommand::from_payload / args_get_*)零调用方 ——
|
||||
//! 联调轮 setup 注册为 tunnel on_command 回调即消除(预留保留,非死代码)。
|
||||
//! check_generating_reject / MiniCommand::from_payload / args_get_*)已接 tunnel
|
||||
//! on_command 回调(lib.rs:150 setup 注册),非死代码。保留 `#![allow(dead_code)]`
|
||||
//! 仅为防 route_* 等部分内部函数未被全用时的告警(预留保留)。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
@@ -57,9 +62,19 @@ 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_continue_loop,
|
||||
ai_regenerate, ai_stop_loop, AiChatEvent,
|
||||
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,
|
||||
};
|
||||
// F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command],
|
||||
// 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。
|
||||
use crate::commands::{idea::list_ideas, project::list_projects, task::list_tasks};
|
||||
// F-#95 跨端技能/mention:SkillInfo(commands/ai/skills.rs) + MentionSpanDto(df-types augmentation)。
|
||||
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;
|
||||
// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。
|
||||
use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED;
|
||||
|
||||
@@ -82,10 +97,17 @@ pub struct MiniCommand {
|
||||
/// Tauri command 名(如 "send_message" / "stop" / "approve" ...)
|
||||
pub cmd: String,
|
||||
/// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底)
|
||||
#[serde(default)]
|
||||
/// BUG-260623-05:`#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object
|
||||
#[serde(default = "default_empty_object")]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
/// MiniCommand.args 缺省值:空 Object。
|
||||
/// `#[serde(default)]` 对 serde_json::Value 给 Null(非 Object),不符各臂 from_value 兜底语义。
|
||||
fn default_empty_object() -> Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
impl MiniCommand {
|
||||
/// 从原始 payload 反序列化为 MiniCommand。
|
||||
///
|
||||
@@ -242,6 +264,82 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
||||
);
|
||||
}
|
||||
|
||||
// ── list_conversations:F-#95 会话列表同步(device→miniapp 推列表) ──
|
||||
// 无 Tauri command 对应 —— 直接读 state.ai_conversations.list_all() 映射 ConvSummary,
|
||||
// 经 ai_event_bus.publish_event(AiChatEvent::AiConversationList) 跨端推回 miniapp。
|
||||
// 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收响应即 device 活)。
|
||||
"list_conversations" => {
|
||||
route_list_conversations(&state).await;
|
||||
}
|
||||
|
||||
// ── load_messages:F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表) ──
|
||||
// 无 Tauri command 对应(桌面 switch 是纯前端 + 本地 store 加载) —— 直接读
|
||||
// ai_messages.list_by_conversation 映射 ChatMessage(对齐 ai_conversation_switch 读路径),
|
||||
// 经 publish_event(AiMessageHistory) 跨端推回 miniapp。替代 switch_conversation(后端忽略)。
|
||||
"load_messages" => {
|
||||
match args_get_string(&command.args, "conversation_id") {
|
||||
Some(id) => route_load_messages(&state, id).await,
|
||||
None => tracing::warn!(
|
||||
"[remote_bridge] load_messages 缺 conversation_id 参数,忽略"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── rename_conversation:ai_conversation_rename(conversation_id, title) + 推刷新列表 ──
|
||||
// miniapp 对齐桌面端改会话名。成功后 route_list_conversations 推 AiConversationList,
|
||||
// miniapp(及桌面端)列表 reactive 刷新见新标题。返回值忽略,结果经列表事件回流。
|
||||
"rename_conversation" => {
|
||||
match (
|
||||
args_get_string(&command.args, "conversation_id"),
|
||||
args_get_string(&command.args, "title"),
|
||||
) {
|
||||
(Some(id), Some(title)) => {
|
||||
match ai_conversation_rename(state.clone(), id, title).await {
|
||||
Ok(()) => {
|
||||
// 推刷新列表(miniapp 据此更新会话标题)
|
||||
route_list_conversations(&state).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] rename_conversation 失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] rename_conversation 缺 conversation_id 或 title 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ──
|
||||
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired,
|
||||
// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,
|
||||
// device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件,
|
||||
// miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。
|
||||
"sync_pending" => {
|
||||
match args_get_string(&command.args, "conversation_id") {
|
||||
Some(id) => route_sync_pending(&state, id).await,
|
||||
None => tracing::warn!(
|
||||
"[remote_bridge] sync_pending 缺 conversation_id 参数,忽略"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── list_skills:F-#95 跨端技能联想(device→miniapp 推本机 Claude 技能列表) ──
|
||||
// 调 ai_list_skills(零参,直接读进程内缓存) → publish_event(AiSkillList) 跨端透传。
|
||||
// 对齐 route_list_conversations 模式(非 app.emit,publish 才跨端透传)。
|
||||
"list_skills" => {
|
||||
route_list_skills(&state).await;
|
||||
}
|
||||
|
||||
// ── list_entities:F-#95 跨端实体联想(device→miniapp 推项目/任务/灵感列表) ──
|
||||
// 调 list_projects/list_tasks/list_ideas(query/project_id/status 均传 None 走全量等价路径)
|
||||
// → 合并打包 publish_event(AiEntityList) 跨端透传,供 miniapp @ 联想浮层渲染。
|
||||
"list_entities" => {
|
||||
route_list_entities(&state).await;
|
||||
}
|
||||
|
||||
// ── 兜底臂:未知 cmd,log+忽略(不崩溃) ──
|
||||
// 对齐 §1.2 风险缓解「桥接层 match 末尾加兜底臂,非法命令不崩溃只记录」。
|
||||
unknown => {
|
||||
@@ -253,6 +351,282 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95:会话列表同步路由(device→miniapp 推列表)
|
||||
// ============================================================
|
||||
|
||||
/// `list_conversations` 路由 —— 读本地会话库摘要,跨端推回 miniapp。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. `state.ai_conversations.list_all()` → `Vec<AiConversationRecord>`
|
||||
/// 2. 映射 `ConvSummary`(id/title/title 兜底空串,last_message 暂 None,
|
||||
/// updated_at/created_at ms 字符串 parse 为 i64)
|
||||
/// 3. 按 updated_at 倒序(最新在前,对齐桌面端列表展示习惯)
|
||||
/// 4. `state.ai_event_bus.publish_event(AiConversationList)` 跨端透传
|
||||
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
|
||||
///
|
||||
/// 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收到此事件 = device 活)。
|
||||
///
|
||||
/// 错误兜底:list_all / parse 失败 log warn 不崩溃(空库/脏数据不影响 miniapp 连接态)。
|
||||
async fn route_list_conversations(state: &State<'_, AppState>) {
|
||||
// 读全量会话记录(list_all 内部 ORDER BY created_at DESC,但展示口径按 updated_at 重排)。
|
||||
let records = match state.ai_conversations.list_all().await {
|
||||
Ok(rs) => rs,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_conversations 读会话库失败,推送空列表");
|
||||
// 失败也推一次空列表(miniapp 可据此判 device 在线 + 清空本地过期缓存)。
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList {
|
||||
conversations: Vec::new(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 映射摘要 + parse 时间戳。updated_at 缺失/脏 → None(排序兜底 0)。
|
||||
let mut summaries: Vec<ConvSummary> = records
|
||||
.into_iter()
|
||||
.map(|r| ConvSummary {
|
||||
id: r.id,
|
||||
title: r.title.unwrap_or_default(),
|
||||
// MVP 暂不取末条消息(需解析 messages JSON,留后续批);miniapp lastMessage 渲染留空。
|
||||
last_message: None,
|
||||
updated_at: r.updated_at.parse::<i64>().ok(),
|
||||
created_at: r.created_at.parse::<i64>().ok(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 按 updated_at 倒序(None 兜底 0 排末)。stable 排序保 None 项相对顺序。
|
||||
summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at.unwrap_or(0)));
|
||||
|
||||
tracing::info!(
|
||||
count = summaries.len(),
|
||||
"[remote_bridge] list_conversations 推送会话摘要列表(跨端)"
|
||||
);
|
||||
|
||||
// 跨端发布(非 app.emit)。publish_event 内部 to_value → publish 透传 tunnel subscriber。
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList {
|
||||
conversations: summaries,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95 扩展:历史消息同步路由(device→miniapp 推消息列表)
|
||||
// ============================================================
|
||||
|
||||
/// `load_messages` 路由 —— 按 conversation_id 加载历史消息,跨端推回 miniapp。
|
||||
///
|
||||
/// 对齐 `ai_conversation_switch`(conversation.rs:259-277)读路径:
|
||||
/// 1. `state.ai_messages.list_by_conversation(id)` → `Vec<AiMessageRecord>`
|
||||
/// 2. 映射 `Vec<ChatMessage>`(record_to_message);表空 fallback 旧 messages JSON 列(老库兼容)
|
||||
/// 3. `state.ai_event_bus.publish_event(AiMessageHistory)` 跨端透传
|
||||
///
|
||||
/// 兜底:读/解析失败 log warn 不崩溃(推送空消息列表,miniapp 可据此判 device 在线)。
|
||||
async fn route_load_messages(state: &State<'_, AppState>, conversation_id: String) {
|
||||
let records = match state.ai_messages.list_by_conversation(&conversation_id).await {
|
||||
Ok(rs) => rs,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
conv_id = %conversation_id,
|
||||
"[remote_bridge] load_messages 读消息失败,推送空列表"
|
||||
);
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory {
|
||||
conversation_id,
|
||||
messages: Vec::new(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 表空 → fallback 旧 messages JSON 列(对齐 ai_conversation_switch:263-277 老库兼容)。
|
||||
let messages: Vec<ChatMessage> = if !records.is_empty() {
|
||||
records.iter().map(record_to_message).collect()
|
||||
} else {
|
||||
match state.ai_conversations.get_by_id(&conversation_id).await {
|
||||
Ok(Some(rec)) if rec.messages != "[]" && !rec.messages.is_empty() => {
|
||||
tracing::warn!(
|
||||
conv_id = %conversation_id,
|
||||
"[remote_bridge] load_messages ai_messages 表空,回退旧 messages JSON 列(老库未迁移)"
|
||||
);
|
||||
serde_json::from_str(&rec.messages).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
conv_id = %conversation_id,
|
||||
count = messages.len(),
|
||||
"[remote_bridge] load_messages 推送历史消息(跨端)"
|
||||
);
|
||||
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory {
|
||||
conversation_id,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 重连审批恢复:sync_pending 路由(device→miniapp 重推 conv 挂起审批)
|
||||
// ============================================================
|
||||
|
||||
/// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。
|
||||
///
|
||||
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件,
|
||||
/// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读
|
||||
/// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent
|
||||
/// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。
|
||||
///
|
||||
/// reason 泛化为「等待审批(重连恢复)」:原 reason 由 build_approval_reason 实时构造(需 risk_level
|
||||
/// + db 查询),PendingApproval 不存 risk_level/reason;重连恢复场景 tool_name 已在卡上,泛化可接受。
|
||||
///
|
||||
/// 错误兜底:读 session 失败 log warn 不崩溃(推 0 条,miniapp pending 维持清空态)。
|
||||
async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String) {
|
||||
let approvals: Vec<PendingApproval> = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|p| p.conversation_id.as_deref() == Some(conversation_id.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
conv_id = %conversation_id,
|
||||
count = approvals.len(),
|
||||
"[remote_bridge] sync_pending 重推挂起审批(跨端)"
|
||||
);
|
||||
|
||||
for p in approvals {
|
||||
let conv_id = Some(conversation_id.clone());
|
||||
let ev = match &p.kind {
|
||||
ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired {
|
||||
id: p.tool_call_id,
|
||||
name: p.tool_name,
|
||||
args: p.arguments,
|
||||
reason: "等待审批(重连恢复)".to_string(),
|
||||
diff: diff.clone(),
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired {
|
||||
id: p.tool_call_id,
|
||||
tool: p.tool_name,
|
||||
path: req.raw_paths.first().cloned().unwrap_or_default(),
|
||||
dir: req
|
||||
.dirs
|
||||
.first()
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
};
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95:技能列表同步路由(device→miniapp 推本机 Claude 技能)
|
||||
// ============================================================
|
||||
|
||||
/// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘)
|
||||
/// → `Vec<SkillInfo>`(已按 skills>commands>plugins 优先级去重)
|
||||
/// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传
|
||||
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
|
||||
///
|
||||
/// 职责:miniapp 输入框 `/` 触发技能联想浮层,对齐桌面端 `/` 联想体验。
|
||||
///
|
||||
/// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底)
|
||||
/// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。
|
||||
///
|
||||
/// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调,
|
||||
/// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。
|
||||
async fn route_list_skills(state: &State<'_, AppState>) {
|
||||
let skills: Vec<SkillInfo> = match ai_list_skills().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表");
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList {
|
||||
skills: Vec::new(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
count = skills.len(),
|
||||
"[remote_bridge] list_skills 推送本机技能列表(跨端)"
|
||||
);
|
||||
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList { skills });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95:实体列表同步路由(device→miniapp 推项目/任务/灵感)
|
||||
// ============================================================
|
||||
|
||||
/// `list_entities` 路由 —— 读本地项目/任务/灵感全量列表,跨端推回 miniapp。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 并行顺序调三 Tauri command(query/project_id/status 均传 None 走 list_active/list_all
|
||||
/// 全量等价路径):list_projects(state, None) / list_tasks(state, None, None) /
|
||||
/// list_ideas(state, None, None)
|
||||
/// 2. 合并打包成 `AiEntityList { projects, tasks, ideas }` 经 publish_event 跨端透传
|
||||
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
|
||||
///
|
||||
/// 职责:miniapp 输入框 `@` 触发实体联想浮层(选项目/任务/灵感),对齐桌面端 @ 体验。
|
||||
/// 全量字段(对齐桌面端 src/api/types.ts);带宽敏感裁剪留后续批,本批不动。
|
||||
///
|
||||
/// 错误兜底:任一 command 失败 → 该实体降级为空 Vec(不阻断其余实体),最终 publish_event
|
||||
/// 推含成功的实体(可能部分空)。对齐 route_list_conversations 失败推空列表语义(部分失败
|
||||
/// 不致整个路由崩溃,miniapp 拿到部分列表仍可用)。
|
||||
///
|
||||
/// 注:三 Tauri command 第二参是 `State<'_, AppState>`,本路由收 `&State`,调时传
|
||||
/// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。
|
||||
/// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。
|
||||
async fn route_list_entities(state: &State<'_, AppState>) {
|
||||
// list_projects(None query) → list_active 全量(deleted_at IS NULL + created_at DESC)。
|
||||
let projects = match list_projects(state.clone(), None).await {
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// list_tasks(None project_id, None query) → 全量未删任务(等价改造前 list_active)。
|
||||
let tasks = match list_tasks(state.clone(), None, None).await {
|
||||
Ok(ts) => ts,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_entities list_tasks 失败,降级空 Vec");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// list_ideas(None status, None query) → list_by_query 空 query(等价 list_all,created_at DESC)。
|
||||
let ideas = match list_ideas(state.clone(), None, None).await {
|
||||
Ok(is) => is,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_entities list_ideas 失败,降级空 Vec");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
projects = projects.len(),
|
||||
tasks = tasks.len(),
|
||||
ideas = ideas.len(),
|
||||
"[remote_bridge] list_entities 推送项目/任务/灵感列表(跨端)"
|
||||
);
|
||||
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiEntityList {
|
||||
projects,
|
||||
tasks,
|
||||
ideas,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// R1 兜底:send_message 路由前置 generating 检查
|
||||
// ============================================================
|
||||
@@ -282,6 +656,11 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
};
|
||||
let conversation_id = args_get_string(&args, "conversation_id");
|
||||
let model_override = args_get_string(&args, "model_override");
|
||||
// F-#95 跨端技能/mention 透传:miniapp 选技能(/ 联想) + @ mention 区间透传 ai_chat_send,
|
||||
// 让后端注入技能正文 + 解析 mention(对齐桌面端 ai_chat_send chat.rs:300-320 调用)。
|
||||
// skill:技能名(Option<String>,空字符串规整为 None);mention_spans:@ 区间(失败兜底 None)。
|
||||
let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty());
|
||||
let mention_spans = args_get_mention_spans(&args, "mention_spans");
|
||||
|
||||
// ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ──
|
||||
// 目标 conv:入参 conversation_id 优先 → active conv 兜底(与 ai_is_generating 同口径)。
|
||||
@@ -305,24 +684,35 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 放行:调 ai_chat_send(其余可选参数 language/skill/parts/mention_spans 不经远程,
|
||||
// miniapp MVP 仅透传 message/conversation_id/model_override,其余走默认 None) ──
|
||||
// ── 放行:调 ai_chat_send(其余可选参数 language/parts 不经远程;skill + mention_spans
|
||||
// 经远程透传以支持 miniapp 的 / 技能联想 + @ mention 体验,对齐桌面端 ai_chat_send) ──
|
||||
//
|
||||
// F 核验(2026-06-22):ai_chat_send 内部已有 generating guard(chat.rs:354-358)。
|
||||
// R1 前置检查是双保险(任务原文要求"无论 ai_chat_send 内部是否有 guard"都加)。
|
||||
// 这里额外把 ai_chat_send 返回的 Err<String> 转 AiError emit 回 miniapp ——
|
||||
// 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错,
|
||||
// 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。
|
||||
|
||||
// F-260622-02 跨端用户消息同步:调 ai_chat_send 前广播 AiUserMessage 到 ai_event_bus,
|
||||
// 让桌面 useAiEvents 收到并补 user 气泡(否则桌面只看到孤立的 assistant 响应气泡)。
|
||||
// 用 publish_event(跨端透传,与 route_list_conversations:335 同通路),非 app.emit
|
||||
// (emit 仅桌面前端,publish 经 EventBus→tunnel subscriber,桌面端自身也是订阅者能收到)。
|
||||
// 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。
|
||||
// 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。
|
||||
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage {
|
||||
message: message.clone(),
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
let result = ai_chat_send(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
message,
|
||||
None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN"
|
||||
None, // skill:远程不传技能名(技能调用经本地命令栏)
|
||||
skill, // skill:miniapp / 联想选中技能名透传(技能正文注入)
|
||||
model_override, // model_override:远程透传用户选择
|
||||
conversation_id.clone(),// conversation_id:远程透传目标 conv
|
||||
None, // parts:多模态片段,远程 MVP 不传
|
||||
None, // mention_spans:@ mention 区间,远程 MVP 不传
|
||||
mention_spans, // mention_spans:@ mention 区间透传(对齐桌面端 ai_chat_send)
|
||||
)
|
||||
.await;
|
||||
if let Err(err_msg) = result {
|
||||
@@ -404,6 +794,37 @@ fn args_get_bool(args: &Value, key: &str) -> Option<bool> {
|
||||
args.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// 从 args 取 mention_spans 字段(数组)反序列化为 `Vec<MentionSpanDto>`。
|
||||
///
|
||||
/// 用于 route_send_message 透传 miniapp @ mention 区间到 ai_chat_send。对齐
|
||||
/// ai_chat_send 第 9 参 `Option<Vec<MentionSpanDto>>`(chat.rs:319)。
|
||||
///
|
||||
/// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 mention 走 None 的旧路径,
|
||||
/// 不因 mention_spans 脏数据阻断 send_message 路由)。失败仅 log warn,不 panic。
|
||||
fn args_get_mention_spans(args: &Value, key: &str) -> Option<Vec<MentionSpanDto>> {
|
||||
match args.get(key) {
|
||||
Some(Value::Array(_)) => match serde_json::from_value::<Vec<MentionSpanDto>>(args[key].clone())
|
||||
{
|
||||
Ok(spans) => {
|
||||
if spans.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(spans)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"[remote_bridge] mention_spans 反序列化失败,降级 None(不阻断 send)"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
// 缺失或非数组(null/string 等) → None(向后兼容无 mention 的远程调用)。
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试
|
||||
// ============================================================
|
||||
@@ -552,8 +973,12 @@ mod tests {
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。
|
||||
#[test]
|
||||
fn test_r1_target_generating_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-busy");
|
||||
// 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool),
|
||||
// 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式)
|
||||
conv.conv_state = ConvState::Generating;
|
||||
conv.generating = true;
|
||||
let is_gen = session
|
||||
.conv_read("conv-busy")
|
||||
|
||||
Reference in New Issue
Block a user