feat(companion): route pet chat and voice through lime host

This commit is contained in:
coso
2026-04-03 21:40:32 +08:00
parent 8ea5050aba
commit cf242a6fe5
6 changed files with 1427 additions and 316 deletions
+873 -7
View File
@@ -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<Regex> =
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanionPetChatRequestPayload {
pub text: String,
#[serde(default)]
pub source: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Clone)]
struct NormalizedConversationBubble {
bubble_text: String,
emotion_tags: Vec<String>,
}
#[derive(Debug, Clone)]
struct ActivePetSender {
connection_id: String,
tx: mpsc::UnboundedSender<String>,
}
#[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<RwLock<CompanionRuntime>>,
sender: Arc<Mutex<Option<ActivePetSender>>>,
start_lock: Arc<Mutex<()>>,
pet_action_lock: Arc<Mutex<()>>,
pet_conversation_history: Arc<Mutex<Vec<CompanionPetConversationTurn>>>,
frontend_event_listener_registered: Arc<Mutex<bool>>,
}
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::<CompanionPetChatRequestPayload>(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<CompanionPetSendResult, String> {
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<CompanionPetSendResult, String> {
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<CompanionPetSendResult, String> {
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<CompanionPetVisualState> {
@@ -518,6 +883,410 @@ fn parse_visual_state(value: &str) -> Option<CompanionPetVisualState> {
}
}
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<CompanionPetChatRequestPayload, String> {
if let Ok(payload) = serde_json::from_str::<CompanionPetChatRequestPayload>(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::<Vec<_>>()
.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::<Vec<_>>().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::<Vec<_>>();
if chars.len() <= limit {
return value.to_string();
}
format!("{}…", chars.into_iter().take(limit).collect::<String>())
}
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: &regex::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<CompanionPetConversationTurn>,
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<CompanionResolvedChatTarget, String> {
let app_state = app_handle
.try_state::<AppState>()
.ok_or_else(|| "AppState 未初始化".to_string())?;
let config = {
let guard = app_state.inner().read().await;
guard.config.clone()
};
let db = app_handle
.try_state::<DbConnection>()
.ok_or_else(|| "DbConnection 未初始化".to_string())?;
let api_key_service = app_handle
.try_state::<ApiKeyProviderServiceState>()
.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<lime_services::api_key_provider_service::ChatTestResult, String> {
let db = app_handle
.try_state::<DbConnection>()
.ok_or_else(|| "DbConnection 未初始化".to_string())?;
let api_key_service = app_handle
.try_state::<ApiKeyProviderServiceState>()
.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<String> {
let Some(model_registry_state) = app_handle.try_state::<ModelRegistryState>() 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"));
}
}
@@ -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<HookProps>) {
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({
+31 -10
View File
@@ -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]);
}
+43 -1
View File
@@ -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<void> {
@@ -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),
};
+10 -2
View File
@@ -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<void> {
return safeInvoke<void>("open_voice_window");
export async function openVoiceWindow(
options: OpenVoiceWindowOptions = {},
): Promise<void> {
return safeInvoke<void>("open_voice_window", { ...options });
}
/** 关闭语音输入窗口 */
+350 -296
View File
@@ -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<string, string> = {
"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<string, any> = {
workspace_preferences: {
schema_version: 1,
media_defaults: {},
companion_defaults: {},
},
navigation: {
schema_version: 1,
@@ -3279,61 +3388,6 @@ const defaultMocks: Record<string, any> = {
// 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 }),