diff --git a/src-tauri/src/services/companion_service.rs b/src-tauri/src/services/companion_service.rs index dc3d471e4..dc9c0c162 100644 --- a/src-tauri/src/services/companion_service.rs +++ b/src-tauri/src/services/companion_service.rs @@ -8,13 +8,21 @@ use axum::{ Router, }; use futures::{SinkExt, StreamExt}; +use once_cell::sync::Lazy; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::{path::PathBuf, process::Command, sync::Arc}; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Listener, Manager}; use tokio::sync::{mpsc, Mutex, RwLock}; use uuid::Uuid; +use crate::app::AppState; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::commands::model_registry_cmd::ModelRegistryState; +use crate::database::dao::api_key_provider::{ApiProviderType, ProviderWithKeys}; +use crate::database::DbConnection; + const DEFAULT_COMPANION_HOST: &str = "127.0.0.1"; const DEFAULT_COMPANION_PORT: u16 = 45554; const DEFAULT_COMPANION_PATH: &str = "/companion/pet"; @@ -28,6 +36,21 @@ const WINDOWS_PET_EXE_NAME: &str = "Lime Pet.exe"; pub const COMPANION_PET_STATUS_EVENT: &str = "companion-pet-status"; pub const COMPANION_OPEN_PROVIDER_SETTINGS_EVENT: &str = "companion-open-provider-settings"; +pub const COMPANION_REQUEST_PROVIDER_SYNC_EVENT: &str = "companion-request-provider-sync"; +pub const COMPANION_REQUEST_PET_CHEER_EVENT: &str = "companion-request-pet-cheer"; +pub const COMPANION_REQUEST_PET_NEXT_STEP_EVENT: &str = "companion-request-pet-next-step"; +pub const COMPANION_REQUEST_PET_CHAT_EVENT: &str = "companion-request-pet-chat"; +pub const COMPANION_REQUEST_PET_CHAT_RESET_EVENT: &str = "companion-request-pet-chat-reset"; +pub const COMPANION_REQUEST_PET_VOICE_CHAT_EVENT: &str = "companion-request-pet-voice-chat"; +pub const COMPANION_PET_VOICE_TRANSCRIPT_EVENT: &str = "companion-pet-voice-transcript"; + +const MAX_PET_CONVERSATION_TURNS: usize = 6; +const SUPPORTED_LIVE2D_EMOTION_TAGS: &[&str] = &[ + "neutral", "joy", "sadness", "surprise", "anger", "fear", "disgust", "smirk", +]; + +static LIVE2D_TAG_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\[([a-z0-9_-]+)\]").expect("live2d tag regex should compile")); fn default_companion_endpoint() -> String { format!("ws://{DEFAULT_COMPANION_HOST}:{DEFAULT_COMPANION_PORT}{DEFAULT_COMPANION_PATH}") @@ -128,12 +151,59 @@ struct CompanionReadyPayload { capabilities: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanionPetChatRequestPayload { + pub text: String, + #[serde(default)] + pub source: Option, +} + +#[derive(Debug, Clone)] +struct CompanionPetConversationTurn { + role: CompanionPetConversationRole, + content: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompanionPetConversationRole { + User, + Assistant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompanionPetQuickAction { + Cheer, + NextStep, +} + +#[derive(Debug, Clone)] +struct CompanionResolvedChatTarget { + provider: ProviderWithKeys, + model_name: Option, +} + +#[derive(Debug, Clone)] +struct NormalizedConversationBubble { + bubble_text: String, + emotion_tags: Vec, +} + #[derive(Debug, Clone)] struct ActivePetSender { connection_id: String, tx: mpsc::UnboundedSender, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct CompanionIncomingEventEffects { + focus_main_window: bool, + open_provider_settings: bool, + request_provider_sync: bool, + request_pet_cheer: bool, + request_pet_next_step: bool, + request_pet_voice_chat: bool, +} + #[derive(Debug, Clone, Default)] struct CompanionRuntime { status: CompanionPetStatus, @@ -152,6 +222,9 @@ pub struct CompanionServiceState { runtime: Arc>, sender: Arc>>, start_lock: Arc>, + pet_action_lock: Arc>, + pet_conversation_history: Arc>>, + frontend_event_listener_registered: Arc>, } impl Default for CompanionServiceState { @@ -164,6 +237,9 @@ impl Default for CompanionServiceState { })), sender: Arc::new(Mutex::new(None)), start_lock: Arc::new(Mutex::new(())), + pet_action_lock: Arc::new(Mutex::new(())), + pet_conversation_history: Arc::new(Mutex::new(Vec::new())), + frontend_event_listener_registered: Arc::new(Mutex::new(false)), } } } @@ -171,6 +247,7 @@ impl Default for CompanionServiceState { impl CompanionServiceState { pub async fn start(&self, app_handle: AppHandle) -> Result<(), String> { self.set_app_handle(app_handle.clone()).await; + self.register_frontend_event_listeners(&app_handle).await; let _guard = self.start_lock.lock().await; if self.snapshot().await.server_listening { @@ -221,6 +298,40 @@ impl CompanionServiceState { Ok(()) } + async fn register_frontend_event_listeners(&self, app_handle: &AppHandle) { + let mut guard = self.frontend_event_listener_registered.lock().await; + if *guard { + return; + } + + let service = self.clone(); + let listener_app_handle = app_handle.clone(); + app_handle.listen(COMPANION_PET_VOICE_TRANSCRIPT_EVENT, move |event| { + let raw_payload = event.payload().to_string(); + let service = service.clone(); + let app_handle = listener_app_handle.clone(); + tauri::async_runtime::spawn(async move { + let payload = match parse_companion_chat_request_event_payload(&raw_payload) { + Ok(payload) => payload, + Err(error) => { + tracing::warn!( + "[Companion] 解析桌宠语音转写事件失败: {},payload={}", + error, + raw_payload + ); + return; + } + }; + + if let Err(error) = service.handle_pet_chat_request(&app_handle, payload).await { + tracing::warn!("[Companion] 处理桌宠语音转写失败: {}", error); + } + }); + }); + + *guard = true; + } + pub async fn snapshot(&self) -> CompanionPetStatus { self.runtime.read().await.status.clone() } @@ -441,10 +552,7 @@ impl CompanionServiceState { return; } - let should_focus_main_window = matches!( - envelope.event.as_str(), - "pet.clicked" | "pet.open_chat" | "pet.open_provider_settings" - ); + let event_effects = companion_incoming_event_effects(envelope.event.as_str()); self.update_runtime(|runtime| { if runtime.active_connection_id.as_deref() != Some(connection_id) { @@ -473,15 +581,72 @@ impl CompanionServiceState { } } - if should_focus_main_window { + if event_effects.focus_main_window { reveal_main_window(app_handle); } - if envelope.event == "pet.open_provider_settings" { + if event_effects.open_provider_settings { if let Err(error) = app_handle.emit(COMPANION_OPEN_PROVIDER_SETTINGS_EVENT, ()) { tracing::warn!("[Companion] 发送服务商设置跳转事件失败: {}", error); } } + + if event_effects.request_provider_sync { + if let Err(error) = app_handle.emit(COMPANION_REQUEST_PROVIDER_SYNC_EVENT, ()) { + tracing::warn!("[Companion] 发送桌宠摘要同步请求失败: {}", error); + } + } + + if event_effects.request_pet_cheer { + if let Err(error) = self + .handle_pet_quick_action_request(app_handle, CompanionPetQuickAction::Cheer) + .await + { + tracing::warn!("[Companion] 处理桌宠鼓励请求失败: {}", error); + } + } + + if event_effects.request_pet_next_step { + if let Err(error) = self + .handle_pet_quick_action_request(app_handle, CompanionPetQuickAction::NextStep) + .await + { + tracing::warn!("[Companion] 处理桌宠下一步建议请求失败: {}", error); + } + } + + if event_effects.request_pet_voice_chat { + if let Err(error) = self.handle_pet_voice_chat_request(app_handle).await { + tracing::warn!("[Companion] 处理桌宠语音对话请求失败: {}", error); + } + } + + if envelope.event == "pet.request_chat_reply" { + match serde_json::from_value::(envelope.payload.clone()) + { + Ok(payload) => { + if let Err(error) = self.handle_pet_chat_request(app_handle, payload).await { + tracing::warn!("[Companion] 处理桌宠对话请求失败: {}", error); + } + } + Err(error) => { + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.status.last_error = + Some(format!("桌宠对话请求负载解析失败: {error}")); + }) + .await; + } + } + } + + if envelope.event == "pet.request_chat_reset" { + if let Err(error) = self.handle_pet_chat_reset_request().await { + tracing::warn!("[Companion] 处理桌宠对话重置请求失败: {}", error); + } + } } async fn record_outbound_command(&self, event: &str, payload: &Value) { @@ -505,6 +670,206 @@ impl CompanionServiceState { }) .await; } + + async fn handle_pet_voice_chat_request(&self, app_handle: &AppHandle) -> Result<(), String> { + if !self.snapshot().await.connected { + return Ok(()); + } + + let _ = self.send_pet_bubble("你说吧,我在认真听", 1600).await; + + if let Err(error) = + crate::voice::window::open_voice_window(app_handle, Some("companion-pet")) + { + tracing::warn!("[Companion] 打开桌宠语音窗口失败: {}", error); + let _ = self + .send_pet_bubble("语音入口暂时没打开,你先打字和我聊吧", 2200) + .await; + } + + Ok(()) + } + + async fn handle_pet_chat_reset_request(&self) -> Result<(), String> { + self.pet_conversation_history.lock().await.clear(); + let _ = self + .send_pet_bubble("好呀,我们从这句重新开始聊", 1800) + .await; + Ok(()) + } + + async fn handle_pet_quick_action_request( + &self, + app_handle: &AppHandle, + action: CompanionPetQuickAction, + ) -> Result<(), String> { + let action_guard = match self.pet_action_lock.try_lock() { + Ok(guard) => guard, + Err(_) => { + let _ = self.send_pet_bubble("我还在想上一句呢", 1400).await; + return Ok(()); + } + }; + let resume_state = self.resume_visual_state().await; + let _ = self + .send_pet_visual_state(CompanionPetVisualState::Thinking) + .await; + + let target = match resolve_companion_chat_target(app_handle).await { + Ok(target) => target, + Err(error) => { + let _ = self + .send_pet_bubble(&format_quick_action_error(action, &error), 2200) + .await; + drop(action_guard); + let _ = self.send_pet_visual_state(resume_state).await; + return Ok(()); + } + }; + + let prompt = build_quick_action_prompt(action); + let result = run_companion_chat_completion(app_handle, &target, prompt).await; + match result { + Ok(content) => { + let bubble_text = normalize_quick_action_bubble(action, content.content.as_deref()); + let auto_hide_ms = match action { + CompanionPetQuickAction::Cheer => 2200, + CompanionPetQuickAction::NextStep => 2600, + }; + let _ = self.send_pet_bubble(&bubble_text, auto_hide_ms).await; + } + Err(error) => { + let _ = self + .send_pet_bubble(&format_quick_action_error(action, &error), 2200) + .await; + } + } + + drop(action_guard); + let _ = self.send_pet_visual_state(resume_state).await; + Ok(()) + } + + async fn handle_pet_chat_request( + &self, + app_handle: &AppHandle, + payload: CompanionPetChatRequestPayload, + ) -> Result<(), String> { + let input = normalize_text(&payload.text); + if input.is_empty() { + let _ = self.send_pet_bubble("你先跟我说一句话吧", 1600).await; + return Ok(()); + } + + let action_guard = match self.pet_action_lock.try_lock() { + Ok(guard) => guard, + Err(_) => { + let _ = self.send_pet_bubble("我还在想上一句呢", 1400).await; + return Ok(()); + } + }; + let resume_state = self.resume_visual_state().await; + let _ = self + .send_pet_visual_state(CompanionPetVisualState::Thinking) + .await; + + let target = match resolve_companion_chat_target(app_handle).await { + Ok(target) => target, + Err(error) => { + let _ = self + .send_pet_bubble(&format_pet_conversation_error(&error), 2200) + .await; + drop(action_guard); + let _ = self.send_pet_visual_state(resume_state).await; + return Ok(()); + } + }; + + let history = self.pet_conversation_history.lock().await.clone(); + let prompt = build_pet_conversation_prompt(&history, &input); + let result = run_companion_chat_completion(app_handle, &target, prompt).await; + match result { + Ok(content) => { + let normalized = normalize_conversation_bubble(content.content.as_deref()); + { + let mut history = self.pet_conversation_history.lock().await; + append_pet_conversation_turn( + &mut history, + CompanionPetConversationRole::User, + &input, + ); + append_pet_conversation_turn( + &mut history, + CompanionPetConversationRole::Assistant, + &normalized.bubble_text, + ); + } + + if !normalized.emotion_tags.is_empty() { + let _ = self.send_pet_live2d_action(&normalized.emotion_tags).await; + } + let _ = self.send_pet_bubble(&normalized.bubble_text, 3200).await; + } + Err(error) => { + let _ = self + .send_pet_bubble(&format_pet_conversation_error(&error), 2200) + .await; + } + } + + drop(action_guard); + let _ = self.send_pet_visual_state(resume_state).await; + Ok(()) + } + + async fn resume_visual_state(&self) -> CompanionPetVisualState { + let snapshot = self.snapshot().await; + match snapshot.last_state { + Some(state) if state != CompanionPetVisualState::Thinking => state, + _ => CompanionPetVisualState::Walking, + } + } + + async fn send_pet_visual_state( + &self, + state: CompanionPetVisualState, + ) -> Result { + self.send_pet_command(CompanionPetCommandRequest { + event: "pet.state_changed".to_string(), + payload: serde_json::json!({ + "state": state.as_wire_value(), + }), + }) + .await + } + + async fn send_pet_bubble( + &self, + text: &str, + auto_hide_ms: u64, + ) -> Result { + self.send_pet_command(CompanionPetCommandRequest { + event: "pet.show_bubble".to_string(), + payload: serde_json::json!({ + "text": text, + "auto_hide_ms": auto_hide_ms, + }), + }) + .await + } + + async fn send_pet_live2d_action( + &self, + emotion_tags: &[String], + ) -> Result { + self.send_pet_command(CompanionPetCommandRequest { + event: "pet.live2d_action".to_string(), + payload: serde_json::json!({ + "emotion_tags": emotion_tags, + }), + }) + .await + } } fn parse_visual_state(value: &str) -> Option { @@ -518,6 +883,410 @@ fn parse_visual_state(value: &str) -> Option { } } +impl CompanionPetVisualState { + fn as_wire_value(self) -> &'static str { + match self { + CompanionPetVisualState::Hidden => "hidden", + CompanionPetVisualState::Idle => "idle", + CompanionPetVisualState::Walking => "walking", + CompanionPetVisualState::Thinking => "thinking", + CompanionPetVisualState::Done => "done", + } + } +} + +fn parse_companion_chat_request_event_payload( + raw_payload: &str, +) -> Result { + if let Ok(payload) = serde_json::from_str::(raw_payload) { + return Ok(payload); + } + + let value: Value = + serde_json::from_str(raw_payload).map_err(|error| format!("JSON 解析失败: {error}"))?; + match value { + Value::String(text) => Ok(CompanionPetChatRequestPayload { + text, + source: Some("voice_window".to_string()), + }), + Value::Object(_) => serde_json::from_value(value) + .map_err(|error| format!("桌宠语音转写负载解析失败: {error}")), + _ => Err("桌宠语音转写负载不是合法对象或字符串".to_string()), + } +} + +fn build_quick_action_prompt(action: CompanionPetQuickAction) -> String { + match action { + CompanionPetQuickAction::NextStep => [ + "你是“Lime 青柠精灵”桌宠。", + "请只输出一句中文下一步行动建议。", + "要求具体、轻量、可立刻执行,不超过26个汉字。", + "不要使用表情、引号、换行、编号,也不要解释原因。", + ] + .join(""), + CompanionPetQuickAction::Cheer => [ + "你是“Lime 青柠精灵”桌宠。", + "请只输出一句中文陪伴或鼓励短句。", + "语气温柔机灵,不超过24个汉字。", + "不要使用表情、引号、换行、编号,也不要自我介绍。", + ] + .join(""), + } +} + +fn build_pet_conversation_prompt( + history: &[CompanionPetConversationTurn], + user_input: &str, +) -> String { + let supported_tags = SUPPORTED_LIVE2D_EMOTION_TAGS + .iter() + .map(|tag| format!("[{tag}]")) + .collect::>() + .join(" "); + let mut prompt = String::from("你是“Lime 青柠精灵”桌宠。用户正在直接和你说话。"); + + if !history.is_empty() { + prompt.push_str("最近几轮对话如下,请自然延续语气和上下文。"); + for turn in history { + match turn.role { + CompanionPetConversationRole::User => prompt.push_str("用户:"), + CompanionPetConversationRole::Assistant => prompt.push_str("青柠:"), + } + prompt.push_str(&turn.content); + } + } + + prompt.push_str("请直接用中文回复用户,最多两句,总长度不超过48个汉字。"); + prompt.push_str(&format!( + "为了驱动 Live2D,你可以插入 0 到 2 个情绪标签:{supported_tags}。" + )); + prompt.push_str("标签可放在句首或句中,但除了这些标签以外,不要输出任何方括号内容。"); + prompt.push_str("语气温柔、机灵、自然,像桌边陪伴,不要使用表情、引号、编号、标题或换行。"); + prompt.push_str(&format!("用户输入:{user_input}")); + prompt +} + +fn normalize_text(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn sanitize_bubble_candidate(value: Option<&str>) -> String { + let compact = normalize_text(value.unwrap_or_default()); + let trimmed_quotes = compact.trim_matches(|ch| matches!(ch, '"' | '“' | '”' | '\'' | '`')); + trimmed_quotes + .trim_start_matches(|ch: char| { + ch.is_ascii_digit() || matches!(ch, '-' | '*' | ' ' | '、' | '.') + }) + .to_string() +} + +fn truncate_chars(value: &str, limit: usize) -> String { + let chars = value.chars().collect::>(); + if chars.len() <= limit { + return value.to_string(); + } + + format!("{}…", chars.into_iter().take(limit).collect::()) +} + +fn fallback_quick_action_bubble(action: CompanionPetQuickAction) -> &'static str { + match action { + CompanionPetQuickAction::NextStep => "先把眼前最小的一步做掉", + CompanionPetQuickAction::Cheer => "青柠会一直陪着你", + } +} + +fn normalize_quick_action_bubble(action: CompanionPetQuickAction, content: Option<&str>) -> String { + let bubble = sanitize_bubble_candidate(content); + if bubble.is_empty() { + return fallback_quick_action_bubble(action).to_string(); + } + + truncate_chars(&bubble, 30) +} + +fn is_supported_live2d_emotion_tag(tag: &str) -> bool { + SUPPORTED_LIVE2D_EMOTION_TAGS.contains(&tag) +} + +fn normalize_conversation_bubble(content: Option<&str>) -> NormalizedConversationBubble { + let mut emotion_tags = Vec::new(); + let content_without_tags = LIVE2D_TAG_REGEX + .replace_all(content.unwrap_or_default(), |captures: ®ex::Captures| { + let tag = captures + .get(1) + .map(|value| value.as_str().to_ascii_lowercase()) + .unwrap_or_default(); + if is_supported_live2d_emotion_tag(&tag) { + if !emotion_tags.iter().any(|existing| existing == &tag) { + emotion_tags.push(tag); + } + " ".to_string() + } else { + captures + .get(0) + .map(|value| value.as_str().to_string()) + .unwrap_or_default() + } + }) + .to_string(); + + let bubble = sanitize_bubble_candidate(Some(&content_without_tags)); + if bubble.is_empty() { + return NormalizedConversationBubble { + bubble_text: "我在呢,我们慢慢说".to_string(), + emotion_tags, + }; + } + + NormalizedConversationBubble { + bubble_text: truncate_chars(&bubble, 56), + emotion_tags, + } +} + +fn append_pet_conversation_turn( + history: &mut Vec, + role: CompanionPetConversationRole, + content: &str, +) { + let normalized_content = normalize_text(content); + if normalized_content.is_empty() { + return; + } + + history.push(CompanionPetConversationTurn { + role, + content: normalized_content, + }); + if history.len() > MAX_PET_CONVERSATION_TURNS { + let overflow = history.len() - MAX_PET_CONVERSATION_TURNS; + history.drain(0..overflow); + } +} + +fn missing_provider_message() -> &'static str { + "还没找到可聊天的 AI 服务商,先去 Lime 里配置一个吧" +} + +fn format_quick_action_error(action: CompanionPetQuickAction, error: &str) -> String { + let message = error.trim(); + if message.contains("还没找到可聊天的 AI 服务商") { + return message.to_string(); + } + + match action { + CompanionPetQuickAction::NextStep => "青柠这次没想好下一步,稍后再试试".to_string(), + CompanionPetQuickAction::Cheer => "青柠这次灵感掉线啦,稍后再点我".to_string(), + } +} + +fn format_pet_conversation_error(error: &str) -> String { + let message = error.trim(); + if message.contains("还没找到可聊天的 AI 服务商") || message.contains("你先跟我说一句话吧") + { + return message.to_string(); + } + + "青柠刚刚走神了,你再和我说一次吧".to_string() +} + +async fn resolve_companion_chat_target( + app_handle: &AppHandle, +) -> Result { + let app_state = app_handle + .try_state::() + .ok_or_else(|| "AppState 未初始化".to_string())?; + let config = { + let guard = app_state.inner().read().await; + guard.config.clone() + }; + + let db = app_handle + .try_state::() + .ok_or_else(|| "DbConnection 未初始化".to_string())?; + let api_key_service = app_handle + .try_state::() + .ok_or_else(|| "ApiKeyProviderServiceState 未初始化".to_string())?; + let providers = api_key_service.0.get_all_providers(db.inner())?; + + let general_preference = &config.workspace_preferences.companion_defaults.general; + if let Some(preferred_provider_id) = general_preference + .preferred_provider_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(provider) = find_usable_provider_by_hint(&providers, preferred_provider_id) { + return Ok(CompanionResolvedChatTarget { + provider: provider.clone(), + model_name: general_preference.preferred_model_id.clone(), + }); + } + + if !general_preference.allow_fallback { + return Err(format!( + "桌宠通用模型 Provider 不可用:{}", + preferred_provider_id + )); + } + } + + if let Some(provider) = find_usable_provider_by_hint(&providers, &config.default_provider) { + return Ok(CompanionResolvedChatTarget { + provider: provider.clone(), + model_name: None, + }); + } + + let provider = providers + .iter() + .find(|provider| can_use_companion_chat_provider(provider)) + .cloned() + .ok_or_else(|| missing_provider_message().to_string())?; + + Ok(CompanionResolvedChatTarget { + provider, + model_name: None, + }) +} + +async fn run_companion_chat_completion( + app_handle: &AppHandle, + target: &CompanionResolvedChatTarget, + prompt: String, +) -> Result { + let db = app_handle + .try_state::() + .ok_or_else(|| "DbConnection 未初始化".to_string())?; + let api_key_service = app_handle + .try_state::() + .ok_or_else(|| "ApiKeyProviderServiceState 未初始化".to_string())?; + let fallback_models = load_local_fallback_models(app_handle, &target.provider.provider).await; + let result = api_key_service + .0 + .test_chat_with_fallback_models( + db.inner(), + &target.provider.provider.id, + target.model_name.clone(), + prompt, + fallback_models, + ) + .await?; + + if result.success { + return Ok(result); + } + + Err(normalize_text( + result + .error + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("青柠这次暂时没有连上可用模型"), + )) +} + +async fn load_local_fallback_models( + app_handle: &AppHandle, + provider: &crate::database::dao::api_key_provider::ApiKeyProvider, +) -> Vec { + let Some(model_registry_state) = app_handle.try_state::() else { + return Vec::new(); + }; + + let guard = model_registry_state.inner().read().await; + let Some(model_registry) = guard.as_ref() else { + return Vec::new(); + }; + + model_registry + .get_local_fallback_model_ids_with_hints( + &provider.id, + &provider.api_host, + Some(provider.provider_type), + &provider.custom_models, + ) + .await +} + +fn can_use_companion_chat_provider(provider: &ProviderWithKeys) -> bool { + provider.provider.enabled + && (provider.api_keys.iter().any(|item| item.enabled) + || (provider.provider.provider_type == ApiProviderType::Ollama + && !provider.provider.api_host.trim().is_empty())) +} + +fn find_usable_provider_by_hint<'a>( + providers: &'a [ProviderWithKeys], + hint: &str, +) -> Option<&'a ProviderWithKeys> { + let normalized_hint = normalize_provider_hint(hint); + if normalized_hint.is_empty() { + return None; + } + + providers.iter().find(|provider| { + can_use_companion_chat_provider(provider) + && (normalize_provider_hint(&provider.provider.id) == normalized_hint + || normalize_provider_hint(api_provider_type_key(provider.provider.provider_type)) + == normalized_hint) + }) +} + +fn normalize_provider_hint(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn api_provider_type_key(value: ApiProviderType) -> &'static str { + match value { + ApiProviderType::Openai => "openai", + ApiProviderType::OpenaiResponse => "openai-response", + ApiProviderType::Codex => "codex", + ApiProviderType::Anthropic => "anthropic", + ApiProviderType::AnthropicCompatible => "anthropic-compatible", + ApiProviderType::Gemini => "gemini", + ApiProviderType::AzureOpenai => "azure-openai", + ApiProviderType::Vertexai => "vertexai", + ApiProviderType::AwsBedrock => "aws-bedrock", + ApiProviderType::Ollama => "ollama", + ApiProviderType::Fal => "fal", + ApiProviderType::NewApi => "new-api", + ApiProviderType::Gateway => "gateway", + } +} + +fn companion_incoming_event_effects(event: &str) -> CompanionIncomingEventEffects { + match event { + "pet.clicked" | "pet.open_chat" => CompanionIncomingEventEffects { + focus_main_window: true, + ..CompanionIncomingEventEffects::default() + }, + "pet.open_provider_settings" => CompanionIncomingEventEffects { + focus_main_window: true, + open_provider_settings: true, + ..CompanionIncomingEventEffects::default() + }, + "pet.request_provider_overview_sync" => CompanionIncomingEventEffects { + request_provider_sync: true, + ..CompanionIncomingEventEffects::default() + }, + "pet.request_pet_cheer" => CompanionIncomingEventEffects { + request_pet_cheer: true, + ..CompanionIncomingEventEffects::default() + }, + "pet.request_pet_next_step" => CompanionIncomingEventEffects { + request_pet_next_step: true, + ..CompanionIncomingEventEffects::default() + }, + "pet.request_voice_chat" => CompanionIncomingEventEffects { + request_pet_voice_chat: true, + ..CompanionIncomingEventEffects::default() + }, + _ => CompanionIncomingEventEffects::default(), + } +} + fn reveal_main_window(app_handle: &AppHandle) { let Some(window) = app_handle.get_webview_window("main") else { tracing::warn!("[Companion] 未找到主窗口,无法响应桌宠点击"); @@ -779,4 +1548,101 @@ mod tests { assert_eq!(snapshot.last_state, Some(CompanionPetVisualState::Thinking)); assert!(snapshot.connected); } + + #[test] + fn incoming_event_effects_focus_or_emit_expected_side_effects() { + assert_eq!( + companion_incoming_event_effects("pet.clicked"), + CompanionIncomingEventEffects { + focus_main_window: true, + open_provider_settings: false, + request_provider_sync: false, + request_pet_cheer: false, + request_pet_next_step: false, + request_pet_voice_chat: false, + } + ); + assert_eq!( + companion_incoming_event_effects("pet.open_provider_settings"), + CompanionIncomingEventEffects { + focus_main_window: true, + open_provider_settings: true, + request_provider_sync: false, + request_pet_cheer: false, + request_pet_next_step: false, + request_pet_voice_chat: false, + } + ); + assert_eq!( + companion_incoming_event_effects("pet.request_provider_overview_sync"), + CompanionIncomingEventEffects { + focus_main_window: false, + open_provider_settings: false, + request_provider_sync: true, + request_pet_cheer: false, + request_pet_next_step: false, + request_pet_voice_chat: false, + } + ); + assert_eq!( + companion_incoming_event_effects("pet.request_pet_cheer"), + CompanionIncomingEventEffects { + focus_main_window: false, + open_provider_settings: false, + request_provider_sync: false, + request_pet_cheer: true, + request_pet_next_step: false, + request_pet_voice_chat: false, + } + ); + assert_eq!( + companion_incoming_event_effects("pet.request_pet_next_step"), + CompanionIncomingEventEffects { + focus_main_window: false, + open_provider_settings: false, + request_provider_sync: false, + request_pet_cheer: false, + request_pet_next_step: true, + request_pet_voice_chat: false, + } + ); + assert_eq!( + companion_incoming_event_effects("pet.request_voice_chat"), + CompanionIncomingEventEffects { + focus_main_window: false, + open_provider_settings: false, + request_provider_sync: false, + request_pet_cheer: false, + request_pet_next_step: false, + request_pet_voice_chat: true, + } + ); + } + + #[test] + fn normalize_conversation_bubble_should_extract_supported_live2d_tags() { + let normalized = + normalize_conversation_bubble(Some("[joy]当然在,我会陪你把今天慢慢走完[unknown]")); + + assert_eq!( + normalized.bubble_text, + "当然在,我会陪你把今天慢慢走完[unknown]" + ); + assert_eq!(normalized.emotion_tags, vec!["joy".to_string()]); + } + + #[test] + fn parse_companion_chat_request_event_payload_should_accept_object_and_string() { + let object_payload = parse_companion_chat_request_event_payload( + r#"{"text":"陪我聊两句","source":"voice_window"}"#, + ) + .expect("对象事件负载应被成功解析"); + assert_eq!(object_payload.text, "陪我聊两句"); + assert_eq!(object_payload.source.as_deref(), Some("voice_window")); + + let string_payload = parse_companion_chat_request_event_payload(r#""今天有点累""#) + .expect("字符串事件负载应被成功解析"); + assert_eq!(string_payload.text, "今天有点累"); + assert_eq!(string_payload.source.as_deref(), Some("voice_window")); + } } diff --git a/src/hooks/useCompanionProviderBridge.test.tsx b/src/hooks/useCompanionProviderBridge.test.tsx index 38e14adf3..784a9d076 100644 --- a/src/hooks/useCompanionProviderBridge.test.tsx +++ b/src/hooks/useCompanionProviderBridge.test.tsx @@ -5,18 +5,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCompanionProviderBridge } from "./useCompanionProviderBridge"; import { COMPANION_OPEN_PROVIDER_SETTINGS_EVENT, + COMPANION_REQUEST_PROVIDER_SYNC_EVENT, type CompanionPetStatus, getCompanionPetStatus, listenCompanionPetStatus, sendCompanionPetCommand, } from "@/lib/api/companion"; import { safeListen } from "@/lib/dev-bridge"; +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; import { providerPoolApi } from "@/lib/api/providerPool"; import { subscribeProviderDataChanged } from "@/lib/providerDataEvents"; import { SettingsTabs } from "@/types/settings"; vi.mock("@/lib/api/companion", () => ({ COMPANION_OPEN_PROVIDER_SETTINGS_EVENT: "companion-open-provider-settings", + COMPANION_REQUEST_PROVIDER_SYNC_EVENT: "companion-request-provider-sync", COMPANION_PROVIDER_OVERVIEW_CAPABILITY: "provider-overview", getCompanionPetStatus: vi.fn(), listenCompanionPetStatus: vi.fn(), @@ -27,6 +30,12 @@ vi.mock("@/lib/dev-bridge", () => ({ safeListen: vi.fn(), })); +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getProviders: vi.fn(), + }, +})); + vi.mock("@/lib/api/providerPool", () => ({ providerPoolApi: { getOverview: vi.fn(), @@ -93,6 +102,7 @@ function renderHook(props?: Partial) { describe("useCompanionProviderBridge", () => { beforeEach(() => { + vi.clearAllMocks(); ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -119,6 +129,7 @@ describe("useCompanionProviderBridge", () => { credentials: [], }, ]); + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([]); vi.mocked(subscribeProviderDataChanged).mockReturnValue(vi.fn()); vi.mocked(safeListen).mockResolvedValue(vi.fn()); }); @@ -187,6 +198,115 @@ describe("useCompanionProviderBridge", () => { }); }); + it("收到桌宠摘要同步请求事件时,应强制重发脱敏摘要", async () => { + let requestProviderSyncHandler: (() => void) | null = null; + + vi.mocked(safeListen).mockImplementation(async (event, handler) => { + if (event === COMPANION_REQUEST_PROVIDER_SYNC_EVENT) { + requestProviderSyncHandler = handler as () => void; + } + return vi.fn(); + }); + + const { render } = renderHook(); + await render(); + + vi.mocked(sendCompanionPetCommand).mockClear(); + vi.mocked(providerPoolApi.getOverview).mockClear(); + vi.mocked(apiKeyProviderApi.getProviders).mockClear(); + + await act(async () => { + requestProviderSyncHandler?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(providerPoolApi.getOverview).toHaveBeenCalledWith({ + forceRefresh: true, + }); + expect(apiKeyProviderApi.getProviders).toHaveBeenCalledWith({ + forceRefresh: true, + }); + expect(sendCompanionPetCommand).toHaveBeenCalledWith({ + event: "pet.provider_overview", + payload: { + providers: [ + { + provider_type: "openai", + display_name: "OpenAI", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + ], + total_provider_count: 1, + available_provider_count: 1, + needs_attention_provider_count: 0, + }, + }); + }); + + it("应把 API Key Provider 一并整理进桌宠摘要", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com/v1", + is_system: false, + group: "cloud", + enabled: true, + sort_order: 10, + custom_models: [], + api_key_count: 1, + api_keys: [ + { + id: "deepseek-key-1", + provider_id: "deepseek", + api_key_masked: "sk-***1234", + enabled: true, + usage_count: 0, + error_count: 0, + created_at: "2026-04-01T00:00:00Z", + }, + ], + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + }, + ]); + + const { render } = renderHook(); + await render(); + + expect(sendCompanionPetCommand).toHaveBeenCalledWith({ + event: "pet.provider_overview", + payload: { + providers: [ + { + provider_type: "deepseek", + display_name: "DeepSeek", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + { + provider_type: "openai", + display_name: "OpenAI", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + ], + total_provider_count: 2, + available_provider_count: 2, + needs_attention_provider_count: 0, + }, + }); + }); + it("桌宠未声明 provider 能力时,不应下发摘要", async () => { vi.mocked(getCompanionPetStatus).mockResolvedValue( createConnectedStatus({ diff --git a/src/hooks/useCompanionProviderBridge.ts b/src/hooks/useCompanionProviderBridge.ts index 9773789cc..5eb319b4b 100644 --- a/src/hooks/useCompanionProviderBridge.ts +++ b/src/hooks/useCompanionProviderBridge.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { COMPANION_OPEN_PROVIDER_SETTINGS_EVENT, + COMPANION_REQUEST_PROVIDER_SYNC_EVENT, COMPANION_PROVIDER_OVERVIEW_CAPABILITY, getCompanionPetStatus, listenCompanionPetStatus, @@ -9,9 +10,8 @@ import { type CompanionPetStatus, } from "@/lib/api/companion"; import { safeListen } from "@/lib/dev-bridge"; -import { providerPoolApi } from "@/lib/api/providerPool"; import { subscribeProviderDataChanged } from "@/lib/providerDataEvents"; -import { buildCompanionProviderOverview } from "@/lib/provider/companionProviderOverview"; +import { loadCompanionProviderOverview } from "@/lib/provider/companionProviderOverview"; import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; @@ -24,7 +24,7 @@ function supportsProviderOverview( ): boolean { return Boolean( status?.connected && - status.capabilities.includes(COMPANION_PROVIDER_OVERVIEW_CAPABILITY), + status.capabilities.includes(COMPANION_PROVIDER_OVERVIEW_CAPABILITY), ); } @@ -39,8 +39,12 @@ export function useCompanionProviderBridge({ let cancelled = false; let statusUnlisten: UnlistenFn | null = null; let openSettingsUnlisten: UnlistenFn | null = null; + let requestProviderSyncUnlisten: UnlistenFn | null = null; - const syncProviderOverview = async (forceRefresh = false) => { + const syncProviderOverview = async ( + forceRefresh = false, + forceDeliver = false, + ) => { if (!supportsProviderOverview(statusRef.current)) { return; } @@ -48,16 +52,15 @@ export function useCompanionProviderBridge({ const requestId = ++syncRequestIdRef.current; try { - const overview = await providerPoolApi.getOverview( - forceRefresh ? { forceRefresh: true } : undefined, - ); + const payload = await loadCompanionProviderOverview({ + forceRefresh, + }); if (cancelled || requestId !== syncRequestIdRef.current) { return; } - const payload = buildCompanionProviderOverview(overview); const fingerprint = JSON.stringify(payload); - if (fingerprint === lastFingerprintRef.current) { + if (!forceDeliver && fingerprint === lastFingerprintRef.current) { return; } @@ -91,7 +94,8 @@ export function useCompanionProviderBridge({ const becameProviderAware = supportsProviderOverview(status) && - (!previousStatus?.connected || !supportsProviderOverview(previousStatus)); + (!previousStatus?.connected || + !supportsProviderOverview(previousStatus)); if (becameProviderAware) { void syncProviderOverview(true); @@ -148,6 +152,20 @@ export function useCompanionProviderBridge({ console.warn("[Companion] 监听桌宠设置跳转失败:", error); }); + void safeListen(COMPANION_REQUEST_PROVIDER_SYNC_EVENT, () => { + void syncProviderOverview(true, true); + }) + .then((unlisten) => { + if (cancelled) { + void unlisten(); + return; + } + requestProviderSyncUnlisten = unlisten; + }) + .catch((error) => { + console.warn("[Companion] 监听桌宠摘要同步请求失败:", error); + }); + return () => { cancelled = true; unsubscribeProviderData(); @@ -158,6 +176,9 @@ export function useCompanionProviderBridge({ if (openSettingsUnlisten) { openSettingsUnlisten(); } + if (requestProviderSyncUnlisten) { + requestProviderSyncUnlisten(); + } }; }, [onNavigate]); } diff --git a/src/lib/api/agentRuntime.ts b/src/lib/api/agentRuntime.ts index 1783780a7..8eff51eae 100644 --- a/src/lib/api/agentRuntime.ts +++ b/src/lib/api/agentRuntime.ts @@ -24,7 +24,10 @@ import type { AgentThreadItem, AgentThreadTurn, } from "./agentProtocol"; -import { normalizeLegacyThreadItem } from "./agentTextNormalization"; +import { + normalizeLegacyThreadItem, + normalizeLegacyToolSurfaceName, +} from "./agentTextNormalization"; import type { AsterApprovalPolicy, AsterExecutionStrategy, @@ -998,8 +1001,13 @@ export interface AgentRuntimeUpdateSessionRequest { export interface AgentRuntimeSpawnSubagentRequest { parent_session_id: string; message: string; + name?: string; + team_name?: string; agent_type?: string; model?: string; + run_in_background?: boolean; + mode?: string; + isolation?: 'worktree' | 'remote' | string; reasoning_effort?: string; fork_context?: boolean; blueprint_role_id?: string; @@ -1013,6 +1021,7 @@ export interface AgentRuntimeSpawnSubagentRequest { theme?: string; system_overlay?: string; output_contract?: string; + cwd?: string; } export interface AgentRuntimeSpawnSubagentResponse { @@ -1256,6 +1265,31 @@ export interface AgentRuntimeToolInventory { mcp_tools: AgentRuntimeToolInventoryMcpEntry[]; } +function normalizeSubagentSessionInfo( + session: AsterSubagentSessionInfo, +): AsterSubagentSessionInfo { + return { + ...session, + origin_tool: normalizeLegacyToolSurfaceName(session.origin_tool), + }; +} + +function normalizeSubagentParentContext( + context?: AsterSubagentParentContext | null, +): AsterSubagentParentContext | undefined { + if (!context) { + return undefined; + } + + return { + ...context, + origin_tool: normalizeLegacyToolSurfaceName(context.origin_tool), + sibling_subagent_sessions: Array.isArray(context.sibling_subagent_sessions) + ? context.sibling_subagent_sessions.map(normalizeSubagentSessionInfo) + : context.sibling_subagent_sessions, + }; +} + export async function submitAgentRuntimeTurn( request: AgentRuntimeSubmitTurnRequest, ): Promise { @@ -1383,6 +1417,14 @@ export async function getAgentRuntimeSession( normalizeLegacyThreadItem(item as AgentThreadItem), ) : normalizedDetail?.items, + child_subagent_sessions: Array.isArray( + normalizedDetail?.child_subagent_sessions, + ) + ? normalizedDetail.child_subagent_sessions.map(normalizeSubagentSessionInfo) + : normalizedDetail?.child_subagent_sessions, + subagent_parent_context: normalizeSubagentParentContext( + normalizedDetail?.subagent_parent_context, + ), queued_turns: normalizeQueuedTurnSnapshots(normalizedDetail?.queued_turns), thread_read: normalizeThreadReadModel(normalizedDetail?.thread_read), }; diff --git a/src/lib/api/asrProvider.ts b/src/lib/api/asrProvider.ts index b12baeb3d..7c95c07a6 100644 --- a/src/lib/api/asrProvider.ts +++ b/src/lib/api/asrProvider.ts @@ -198,6 +198,12 @@ export interface TranscribeResult { provider: string; } +export type VoiceWindowTarget = "companion-pet"; + +export interface OpenVoiceWindowOptions { + target?: VoiceWindowTarget; +} + /** 润色结果 */ export interface PolishResult { text: string; @@ -229,8 +235,10 @@ export async function polishVoiceText( } /** 打开语音输入窗口 */ -export async function openVoiceWindow(): Promise { - return safeInvoke("open_voice_window"); +export async function openVoiceWindow( + options: OpenVoiceWindowOptions = {}, +): Promise { + return safeInvoke("open_voice_window", { ...options }); } /** 关闭语音输入窗口 */ diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 6ff9cd4ab..588a9f804 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -117,6 +117,16 @@ type MockBrowserConnectorInstallStatus = { message: string | null; }; +type MockToolSpec = { + name: string; + description: string; + capabilities: string[]; + source: string; + tags: string[]; + input_examples_count: number; + execution_restriction_profile?: string; +}; + const DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES = [ { key: "tabs_context_mcp", @@ -1073,17 +1083,6 @@ function syncMockAutomationBrowserSessionState( return session; } -type MockClawSolutionSummary = { - id: string; - title: string; - summary: string; - outputHint: string; - recommendedCapabilities: string[]; - readiness: "ready" | "needs_setup" | "needs_capability"; - readinessMessage: string; - reasonCode?: string; -}; - type MockReviewDecisionRequest = { session_id?: string; sessionId?: string; @@ -1108,101 +1107,6 @@ type MockReviewDecisionRequest = { notes?: string; }; -const mockClawSolutionCatalog: MockClawSolutionSummary[] = [ - { - id: "web-research-brief", - title: "网页研究简报", - summary: "快速整理研究目标、关键来源与结论框架。", - outputHint: "研究提纲 + 结论简报", - recommendedCapabilities: ["模型", "研究"], - readiness: "ready", - readinessMessage: "可直接开始", - }, - { - id: "social-post-starter", - title: "社媒主稿生成", - summary: "进入社媒专项工作台并生成一版首稿。", - outputHint: "社媒首稿 + 平台结构", - recommendedCapabilities: ["模型", "社媒主题"], - readiness: "ready", - readinessMessage: "可直接开始", - }, - { - id: "frontend-concept", - title: "前端概念方案", - summary: "输出信息架构、核心模块与页面关系。", - outputHint: "IA + 模块方案", - recommendedCapabilities: ["模型", "结构化输出"], - readiness: "ready", - readinessMessage: "可直接开始", - }, - { - id: "slide-outline", - title: "演示提纲草案", - summary: "生成一版可讲述的演示结构。", - outputHint: "PPT 大纲 + 讲述线", - recommendedCapabilities: ["模型", "结构化输出"], - readiness: "ready", - readinessMessage: "可直接开始", - }, - { - id: "browser-assist-task", - title: "浏览器协助办事", - summary: "进入工作区后直接打开浏览器协助。", - outputHint: "浏览器任务执行", - recommendedCapabilities: ["模型", "浏览器协助"], - readiness: "ready", - readinessMessage: "可直接开始", - }, - { - id: "team-breakdown", - title: "多代理拆任务", - summary: "默认启用多代理偏好,按 team runtime 方式展开任务。", - outputHint: "任务拆解 + 分工执行", - recommendedCapabilities: ["模型", "多代理"], - readiness: "ready", - readinessMessage: "可直接开始,进入后会启用多代理偏好", - reasonCode: "team_recommended", - }, -] as const; - -function getMockClawSolution(solutionId: string) { - const solution = mockClawSolutionCatalog.find( - (item) => item.id === solutionId, - ); - if (!solution) { - throw new Error(`Unknown mock claw solution: ${solutionId}`); - } - return solution; -} - -function buildMockClawSolutionPrompt( - solutionId: string, - context?: { userInput?: string }, -) { - const prompts: Record = { - "web-research-brief": - "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。", - "social-post-starter": - "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。", - "frontend-concept": - "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。", - "slide-outline": - "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。", - "browser-assist-task": - "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。", - "team-breakdown": - "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", - }; - - const basePrompt = prompts[solutionId] ?? ""; - const userInput = context?.userInput?.trim(); - if (!userInput) { - return basePrompt; - } - return `${basePrompt}\n\n补充上下文:${userInput}`; -} - function buildMockAgentRuntimeToolInventory(request?: { caller?: string; workbench?: boolean; @@ -1214,170 +1118,374 @@ function buildMockAgentRuntimeToolInventory(request?: { browser_assist: request?.browserAssist === true, }; - const catalogTools = [ - { - name: "ToolSearch", - profiles: ["core"], - capabilities: ["web_search"], - lifecycle: "current", - source: "lime_injected", - permission_plane: "session_allowlist", - workspace_default_allow: true, - execution_warning_policy: "none", - execution_warning_policy_source: "default", - execution_restriction_profile: "none", - execution_restriction_profile_source: "default", - execution_sandbox_profile: "none", - execution_sandbox_profile_source: "default", - }, - { - name: "WebSearch", - profiles: ["core"], - capabilities: ["web_search"], - lifecycle: "current", - source: "aster_builtin", - permission_plane: "session_allowlist", - workspace_default_allow: true, - execution_warning_policy: "none", - execution_warning_policy_source: "default", - execution_restriction_profile: "safe_https_url_required", - execution_restriction_profile_source: "default", - execution_sandbox_profile: "none", - execution_sandbox_profile_source: "default", - }, - { - name: "ask", - profiles: ["core"], - capabilities: ["planning"], - lifecycle: "current", - source: "aster_builtin", - permission_plane: "session_allowlist", - workspace_default_allow: true, - execution_warning_policy: "none", - execution_warning_policy_source: "default", - execution_restriction_profile: "none", - execution_restriction_profile_source: "default", - execution_sandbox_profile: "none", - execution_sandbox_profile_source: "default", - }, - { - name: "spawn_agent", - profiles: ["core"], - capabilities: ["delegation"], - lifecycle: "current", - source: "lime_injected", - permission_plane: "session_allowlist", - workspace_default_allow: true, - execution_warning_policy: "none", - execution_warning_policy_source: "default", - execution_restriction_profile: "none", - execution_restriction_profile_source: "default", - execution_sandbox_profile: "none", - execution_sandbox_profile_source: "default", - }, - ]; - - const registryTools = [ + const toolSpecs: MockToolSpec[] = [ { name: "ToolSearch", description: "搜索当前会话可用工具与能力清单。", - catalog_entry_name: "ToolSearch", - catalog_source: "lime_injected", - catalog_lifecycle: "current", - catalog_permission_plane: "session_allowlist", - catalog_workspace_default_allow: true, - catalog_execution_warning_policy: "none", - catalog_execution_warning_policy_source: "default", - catalog_execution_restriction_profile: "none", - catalog_execution_restriction_profile_source: "default", - catalog_execution_sandbox_profile: "none", - catalog_execution_sandbox_profile_source: "default", - deferred_loading: false, - always_visible: true, - allowed_callers: [caller], + capabilities: ["web_search"], + source: "lime_injected", tags: ["search"], input_examples_count: 1, - caller_allowed: true, - visible_in_context: true, + }, + { + name: "ListMcpResourcesTool", + description: "列出当前已连接 MCP 服务暴露的资源。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["mcp", "resource", "list"], + input_examples_count: 1, + }, + { + name: "ReadMcpResourceTool", + description: "按 server 与 uri 读取指定 MCP 资源内容。", + capabilities: ["web_search"], + source: "lime_injected", + tags: ["mcp", "resource", "read"], + input_examples_count: 1, + }, + { + name: "Bash", + description: "执行工作区命令并返回结果。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["command", "workspace"], + input_examples_count: 1, + }, + { + name: "Read", + description: "读取文件内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["read", "file"], + input_examples_count: 1, + }, + { + name: "Write", + description: "写入文件内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["write", "file"], + input_examples_count: 1, + }, + { + name: "Edit", + description: "按补丁方式编辑文件。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["edit", "file"], + input_examples_count: 1, + }, + { + name: "Glob", + description: "按模式列出匹配文件。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["search", "file"], + input_examples_count: 1, + }, + { + name: "Grep", + description: "在工作区中搜索文本。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["search", "text"], + input_examples_count: 1, + }, + { + name: "WebFetch", + description: "抓取指定网页内容。", + capabilities: ["web_search"], + source: "aster_builtin", + tags: ["web", "fetch"], + input_examples_count: 1, + execution_restriction_profile: "safe_https_url_required", }, { name: "WebSearch", description: "联网检索公开网页信息。", - catalog_entry_name: "WebSearch", - catalog_source: "aster_builtin", - catalog_lifecycle: "current", - catalog_permission_plane: "session_allowlist", - catalog_workspace_default_allow: true, - catalog_execution_warning_policy: "none", - catalog_execution_warning_policy_source: "default", - catalog_execution_restriction_profile: "safe_https_url_required", - catalog_execution_restriction_profile_source: "default", - catalog_execution_sandbox_profile: "none", - catalog_execution_sandbox_profile_source: "default", - deferred_loading: false, - always_visible: true, - allowed_callers: [caller], + capabilities: ["web_search"], + source: "aster_builtin", tags: ["research"], input_examples_count: 2, - caller_allowed: true, - visible_in_context: true, + execution_restriction_profile: "safe_https_url_required", }, { - name: "ask", + name: "AskUserQuestion", description: "向用户发起单轮最小必要澄清。", - catalog_entry_name: "ask", - catalog_source: "aster_builtin", - catalog_lifecycle: "current", - catalog_permission_plane: "session_allowlist", - catalog_workspace_default_allow: true, - catalog_execution_warning_policy: "none", - catalog_execution_warning_policy_source: "default", - catalog_execution_restriction_profile: "none", - catalog_execution_restriction_profile_source: "default", - catalog_execution_sandbox_profile: "none", - catalog_execution_sandbox_profile_source: "default", - deferred_loading: false, - always_visible: true, - allowed_callers: [caller], + capabilities: ["planning"], + source: "aster_builtin", tags: ["clarify"], input_examples_count: 1, - caller_allowed: true, - visible_in_context: true, }, { - name: "spawn_agent", + name: "SendUserMessage", + description: "向用户发送一条主可见消息,可用于回复、进度同步或主动提醒。", + capabilities: ["session_control"], + source: "aster_builtin", + tags: ["message", "user"], + input_examples_count: 1, + }, + { + name: "Agent", description: "在需要并行处理时派生子代理。", - catalog_entry_name: "spawn_agent", - catalog_source: "lime_injected", - catalog_lifecycle: "current", - catalog_permission_plane: "session_allowlist", - catalog_workspace_default_allow: true, - catalog_execution_warning_policy: "none", - catalog_execution_warning_policy_source: "default", - catalog_execution_restriction_profile: "none", - catalog_execution_restriction_profile_source: "default", - catalog_execution_sandbox_profile: "none", - catalog_execution_sandbox_profile_source: "default", - deferred_loading: false, - always_visible: true, - allowed_callers: [caller], + capabilities: ["delegation"], + source: "lime_injected", tags: ["delegation"], input_examples_count: 1, - caller_allowed: true, - visible_in_context: true, }, - ]; + { + name: "SendMessage", + description: "向已存在的协作成员追加说明或指令。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation"], + input_examples_count: 1, + }, + { + name: "TeamCreate", + description: "创建共享 task board 与 team 协作上下文。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "TeamDelete", + description: "删除当前 team 协作上下文。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "ListPeers", + description: "列出当前 team 中可直接通信的协作成员。", + capabilities: ["delegation"], + source: "aster_builtin", + tags: ["delegation", "team"], + input_examples_count: 1, + }, + { + name: "Skill", + description: "加载并执行当前可用技能。", + capabilities: ["skill_execution"], + source: "aster_builtin", + tags: ["skill"], + input_examples_count: 1, + }, + { + name: "Workflow", + description: "执行工作流脚本。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["workflow"], + input_examples_count: 1, + }, + { + name: "TaskCreate", + description: "创建结构化任务。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskList", + description: "查看结构化任务列表。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskGet", + description: "读取单个结构化任务。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskUpdate", + description: "更新结构化任务状态。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "TaskOutput", + description: "读取任务输出结果。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task", "output"], + input_examples_count: 1, + }, + { + name: "TaskStop", + description: "停止正在执行的任务。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["task"], + input_examples_count: 1, + }, + { + name: "NotebookEdit", + description: "编辑 notebook 单元内容。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["notebook"], + input_examples_count: 1, + }, + { + name: "EnterPlanMode", + description: "进入计划模式以拆解方案。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["planning"], + input_examples_count: 1, + }, + { + name: "ExitPlanMode", + description: "退出计划模式并继续执行。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["planning"], + input_examples_count: 1, + }, + { + name: "EnterWorktree", + description: "进入独立工作树执行隔离修改。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["worktree"], + input_examples_count: 1, + }, + { + name: "ExitWorktree", + description: "退出独立工作树并回到主工作区。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["worktree"], + input_examples_count: 1, + }, + { + name: "Config", + description: "查看或调整当前运行配置。", + capabilities: ["session_control"], + source: "aster_builtin", + tags: ["config"], + input_examples_count: 1, + }, + { + name: "Sleep", + description: "等待一段时间后继续执行。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["timing"], + input_examples_count: 1, + }, + { + name: "PowerShell", + description: "在 PowerShell 环境中执行命令。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["command", "windows"], + input_examples_count: 1, + }, + { + name: "LSP", + description: "查询语言服务返回的语义信息。", + capabilities: ["workspace_io"], + source: "aster_builtin", + tags: ["code", "lsp"], + input_examples_count: 1, + }, + { + name: "RemoteTrigger", + description: "管理或触发远程 trigger 执行。", + capabilities: ["execution"], + source: "aster_builtin", + tags: ["trigger", "remote"], + input_examples_count: 1, + }, + { + name: "CronCreate", + description: "创建新的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, + { + name: "CronList", + description: "查看当前可用的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, + { + name: "CronDelete", + description: "删除指定的定时触发器。", + capabilities: ["planning"], + source: "aster_builtin", + tags: ["trigger", "schedule"], + input_examples_count: 1, + }, + ] as const; + + const catalogTools = toolSpecs.map((tool) => ({ + name: tool.name, + profiles: ["core"], + capabilities: [...tool.capabilities], + lifecycle: "current", + source: tool.source, + permission_plane: "session_allowlist", + workspace_default_allow: true, + execution_warning_policy: "none", + execution_warning_policy_source: "default", + execution_restriction_profile: + tool.execution_restriction_profile || "none", + execution_restriction_profile_source: "default", + execution_sandbox_profile: "none", + execution_sandbox_profile_source: "default", + })); + + const registryTools = toolSpecs.map((tool) => ({ + name: tool.name, + description: tool.description, + catalog_entry_name: tool.name, + catalog_source: tool.source, + catalog_lifecycle: "current", + catalog_permission_plane: "session_allowlist", + catalog_workspace_default_allow: true, + catalog_execution_warning_policy: "none", + catalog_execution_warning_policy_source: "default", + catalog_execution_restriction_profile: + tool.execution_restriction_profile || "none", + catalog_execution_restriction_profile_source: "default", + catalog_execution_sandbox_profile: "none", + catalog_execution_sandbox_profile_source: "default", + deferred_loading: false, + always_visible: true, + allowed_callers: [caller], + tags: [...tool.tags], + input_examples_count: tool.input_examples_count, + caller_allowed: true, + visible_in_context: true, + })); const extensionSurfaces = surface.browser_assist ? [ { - extension_name: "lime-browser", + extension_name: "mcp__lime-browser", description: "浏览器协助桥接工具集。", source_kind: "mcp_bridge", deferred_loading: false, allowed_caller: caller, - available_tools: ["mcp__lime-browser__navigate"], - always_expose_tools: ["mcp__lime-browser__navigate"], + available_tools: ["navigate"], + always_expose_tools: ["navigate"], loaded_tools: ["mcp__lime-browser__navigate"], searchable_tools: ["mcp__lime-browser__navigate"], }, @@ -1388,7 +1496,7 @@ function buildMockAgentRuntimeToolInventory(request?: { { name: "mcp__lime-browser__navigate", description: "导航到目标网页。", - extension_name: "lime-browser", + extension_name: "mcp__lime-browser", source_kind: "mcp_bridge", deferred_loading: false, allowed_caller: caller, @@ -1675,6 +1783,7 @@ const defaultMocks: Record = { workspace_preferences: { schema_version: 1, media_defaults: {}, + companion_defaults: {}, }, navigation: { schema_version: 1, @@ -3279,61 +3388,6 @@ const defaultMocks: Record = { // API Key Provider 相关 get_api_key_providers: () => [], get_api_key_provider: () => null, - claw_solution_list: () => mockClawSolutionCatalog, - claw_solution_detail: (args: any) => { - const solution = getMockClawSolution(args?.solutionId ?? ""); - return { - ...solution, - starterPrompt: buildMockClawSolutionPrompt(solution.id), - themeTarget: - solution.id === "social-post-starter" ? "social-media" : undefined, - followupMode: - solution.id === "browser-assist-task" - ? "browser_assist" - : solution.id === "team-breakdown" - ? "team_runtime" - : "iterative", - capabilityTags: - solution.id === "browser-assist-task" - ? ["browser", "automation"] - : solution.id === "team-breakdown" - ? ["team", "decomposition"] - : solution.id === "social-post-starter" - ? ["social-media", "draft"] - : ["general"], - }; - }, - claw_solution_check_readiness: (args: any) => { - const solution = getMockClawSolution(args?.solutionId ?? ""); - return { - solutionId: solution.id, - readiness: solution.readiness, - readinessMessage: solution.readinessMessage, - reasonCode: solution.reasonCode, - }; - }, - claw_solution_prepare: (args: any) => { - const solution = getMockClawSolution(args?.solutionId ?? ""); - return { - solutionId: solution.id, - actionType: - solution.id === "social-post-starter" - ? "navigate_theme" - : solution.id === "browser-assist-task" - ? "launch_browser_assist" - : solution.id === "team-breakdown" - ? "enable_team_mode" - : "fill_input", - prompt: buildMockClawSolutionPrompt(solution.id, args?.context), - themeTarget: - solution.id === "social-post-starter" ? "social-media" : undefined, - shouldLaunchBrowserAssist: solution.id === "browser-assist-task", - shouldEnableTeamMode: solution.id === "team-breakdown", - readiness: solution.readiness, - readinessMessage: solution.readinessMessage, - reasonCode: solution.reasonCode, - }; - }, add_custom_api_key_provider: () => ({ success: true }), update_api_key_provider: () => ({ success: true }), delete_custom_api_key_provider: () => ({ success: true }),