mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat(companion): route pet chat and voice through lime host
This commit is contained in:
@@ -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: ®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<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({
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user