diff --git a/package-lock.json b/package-lock.json index 27a32e53b..829defe54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "proxycast", - "version": "0.18.0", + "version": "0.20.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.18.0", + "version": "0.20.5", "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -1439,6 +1440,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index c725a741a..9e741f8a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.20.5", + "version": "0.21.0", "type": "module", "repository": { "type": "git", @@ -20,6 +20,8 @@ "test:watch": "vitest" }, "dependencies": { + "@fabianlars/tauri-plugin-oauth": "^2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -32,7 +34,6 @@ "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-shell": "^2.0.0", - "@fabianlars/tauri-plugin-oauth": "^2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ba0a78e56..066ad139e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3668,7 +3668,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.20.5" +version = "0.21.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 69b9ca5e1..cffbc73f3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.20.5" +version = "0.21.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/binaries/aster-server-aarch64-apple-darwin b/src-tauri/binaries/aster-server-aarch64-apple-darwin new file mode 120000 index 000000000..c63a91e57 --- /dev/null +++ b/src-tauri/binaries/aster-server-aarch64-apple-darwin @@ -0,0 +1 @@ +/Users/coso/Documents/dev/ai/astercloud/aster/aster-server \ No newline at end of file diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 28697948b..5eae2a317 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,26 @@ "shell:allow-execute", "shell:allow-kill", "shell:allow-stdin-write", - "dialog:default" + "dialog:default", + { + "identifier": "shell:allow-execute", + "allow": [ + { + "name": "binaries/aster-server", + "sidecar": true, + "args": true + } + ] + }, + { + "identifier": "shell:allow-spawn", + "allow": [ + { + "name": "binaries/aster-server", + "sidecar": true, + "args": true + } + ] + } ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index a7f76c40b..a50618471 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}} \ No newline at end of file diff --git a/src-tauri/src/agent/aster_client.rs b/src-tauri/src/agent/aster_client.rs new file mode 100644 index 000000000..2b823e27f --- /dev/null +++ b/src-tauri/src/agent/aster_client.rs @@ -0,0 +1,630 @@ +//! aster HTTP 客户端 +//! +//! 提供与 aster 子进程通信的 HTTP 客户端接口 + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// 根据模型名称推断 provider 类型 +/// 注意:当使用 ProxyCast 作为网关时,应该使用 "gateway" provider +fn infer_provider_from_model(model: Option<&str>) -> &'static str { + // 始终使用 gateway provider,因为我们通过 ProxyCast 代理请求 + // gateway provider 会根据模型名称自动选择正确的协议(Anthropic 或 OpenAI) + "gateway" +} + +/// aster HTTP 客户端 +pub struct AsterClient { + /// HTTP 客户端 + client: Client, + /// aster 服务基础 URL + base_url: String, +} + +/// 创建会话请求 +#[derive(Debug, Serialize)] +pub struct CreateSessionRequest { + /// Provider 类型 + pub provider_type: String, + /// 模型名称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 模型配置(包含 API Key) + #[serde(skip_serializing_if = "Option::is_none")] + pub model_config: Option, +} + +/// 图片输入 +#[derive(Debug, Serialize, Clone)] +pub struct ImageInput { + /// base64 编码的图片数据 + pub data: String, + /// MIME 类型,如 "image/png" + pub media_type: String, +} + +/// Chat 请求(直接调用 /v1/agents/chat) +#[derive(Debug, Serialize)] +pub struct ChatRequest { + /// 模板 ID + pub template_id: String, + /// 输入消息 + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + /// 图片列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + /// 模型配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub model_config: Option, +} + +/// Chat 响应 +#[derive(Debug, Deserialize)] +pub struct ChatResponse { + /// Agent ID + pub agent_id: String, + /// 输出内容 + #[serde(default)] + pub output: String, + /// 文本内容 + #[serde(default)] + pub text: String, + /// 状态 + pub status: String, + /// 是否成功 + pub success: bool, +} + +/// 创建 Agent 请求 +#[derive(Debug, Serialize)] +pub struct CreateAgentRequest { + /// 模板 ID + pub template_id: String, + /// Agent 名称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 模型配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub model_config: Option, +} + +/// 创建 Agent 响应 +#[derive(Debug, Deserialize)] +pub struct CreateAgentResponse { + /// 响应数据 + pub data: CreateAgentData, + /// 是否成功 + pub success: bool, +} + +/// 创建 Agent 数据 +#[derive(Debug, Deserialize)] +pub struct CreateAgentData { + /// Agent ID + pub id: String, +} + +/// 发送消息到 Agent 请求 +#[derive(Debug, Serialize)] +pub struct SendToAgentRequest { + /// 消息内容 + pub message: String, +} + +/// 发送消息到 Agent 响应 +#[derive(Debug, Deserialize)] +pub struct SendToAgentResponse { + /// 响应文本 + #[serde(default)] + pub text: String, + /// 是否成功 + pub success: bool, +} + +/// 模型配置 +#[derive(Debug, Serialize)] +pub struct ModelConfig { + /// Provider 名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// 模型名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// API Key + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Base URL(用于 gateway provider) + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, +} + +/// 创建会话响应 +#[derive(Debug, Deserialize)] +pub struct CreateSessionResponse { + /// 会话 ID + pub session_id: String, + /// Provider 类型 + pub provider_type: String, + /// 模型名称 + pub model: Option, + /// 创建时间 + pub created_at: String, +} + +/// 发送消息请求 +#[derive(Debug, Serialize)] +pub struct SendMessageRequest { + /// 消息内容 + pub message: String, + /// 是否流式响应 + #[serde(default)] + pub stream: bool, +} + +/// 发送消息响应(非流式) +#[derive(Debug, Deserialize)] +pub struct SendMessageResponse { + /// 消息 ID + pub message_id: String, + /// 会话 ID + pub session_id: String, + /// 响应内容 + pub content: String, + /// Token 使用量 + pub usage: Option, +} + +/// Token 使用量 +#[derive(Debug, Deserialize)] +pub struct TokenUsage { + /// 输入 Token 数 + pub input_tokens: u32, + /// 输出 Token 数 + pub output_tokens: u32, +} + +/// 会话信息 +#[derive(Debug, Deserialize)] +pub struct SessionInfo { + /// 会话 ID + pub session_id: String, + /// Provider 类型 + pub provider_type: String, + /// 模型名称 + pub model: Option, + /// 创建时间 + pub created_at: String, + /// 最后活动时间 + pub last_activity: String, + /// 消息数量 + pub messages_count: usize, +} + +impl AsterClient { + /// 创建新的 aster 客户端 + /// + /// # 参数 + /// + /// - `base_url`: aster 服务基础 URL (例如 "http://127.0.0.1:8081") + pub fn new(base_url: String) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .no_proxy() // 禁用代理,直接连接 localhost + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + Ok(Self { client, base_url }) + } + + /// 创建新会话 + /// + /// # 参数 + /// + /// - `provider_type`: Provider 类型 (gateway, anthropic, openai, etc.) + /// - `model`: 模型名称(可选) + /// - `api_key`: API Key(可选,如果提供则注入到请求中) + /// - `gateway_base_url`: Gateway 的目标 base_url(可选,用于 gateway provider) + pub async fn create_session( + &self, + provider_type: &str, + model: Option, + api_key: Option, + gateway_base_url: Option, + ) -> Result { + let url = format!("{}/v1/sessions", self.base_url); + + let model_config = if api_key.is_some() || gateway_base_url.is_some() { + Some(ModelConfig { + provider: Some(provider_type.to_string()), + model: model.clone(), + api_key, + base_url: gateway_base_url, + }) + } else { + None + }; + + let request = CreateSessionRequest { + provider_type: provider_type.to_string(), + model, + model_config, + }; + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| format!("创建会话请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("创建会话失败 ({}): {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("解析响应失败: {}", e)) + } + + /// 直接聊天(调用 /v1/agents/chat) + /// + /// 这个方法是同步的,会等待 LLM 响应完成后返回。 + /// 每次调用会创建一个临时 agent 来处理请求。 + /// + /// # 参数 + /// + /// - `input`: 输入消息 + /// - `model`: 模型名称(可选) + /// - `api_key`: API Key + /// - `gateway_base_url`: Gateway 的目标 base_url + pub async fn chat( + &self, + input: &str, + model: Option, + api_key: String, + gateway_base_url: String, + ) -> Result { + self.chat_with_images(input, None, model, api_key, gateway_base_url) + .await + } + + /// 直接聊天(支持图片) + /// + /// # 参数 + /// + /// - `input`: 输入消息 + /// - `images`: 图片列表(可选) + /// - `model`: 模型名称(可选) + /// - `api_key`: API Key + /// - `gateway_base_url`: Gateway 的目标 base_url + pub async fn chat_with_images( + &self, + input: &str, + images: Option>, + model: Option, + api_key: String, + gateway_base_url: String, + ) -> Result { + let url = format!("{}/v1/agents/chat", self.base_url); + + println!("[DEBUG] chat URL: {}", url); + + // 根据模型名称推断 provider 类型 + let provider = infer_provider_from_model(model.as_deref()); + + let model_config = ModelConfig { + provider: Some(provider.to_string()), + model: model.clone(), + api_key: Some(api_key), + base_url: Some(gateway_base_url), + }; + + let request = ChatRequest { + template_id: "chat".to_string(), + input: if input.is_empty() { + None + } else { + Some(input.to_string()) + }, + images, + model_config: Some(model_config), + }; + + let request_json = serde_json::to_string(&request).unwrap_or_default(); + println!("[DEBUG] chat request: {}", request_json); + + let response = self + .client + .post(&url) + .json(&request) + .timeout(Duration::from_secs(300)) // 聊天可能需要很长时间 + .send() + .await + .map_err(|e| { + println!("[DEBUG] chat send error: {}", e); + format!("聊天请求失败: {}", e) + })?; + + let status = response.status(); + println!("[DEBUG] chat response status: {}", status); + + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + println!("[DEBUG] chat error body: {}", body); + return Err(format!("聊天失败 ({}): {}", status, body)); + } + + let body = response.text().await.map_err(|e| { + println!("[DEBUG] chat read body error: {}", e); + format!("读取响应失败: {}", e) + })?; + + println!("[DEBUG] chat response body: {}", body); + + serde_json::from_str::(&body).map_err(|e| { + println!("[DEBUG] chat parse error: {}", e); + format!("解析响应失败: {}", e) + }) + } + + /// 创建 Agent(调用 /v1/agents) + /// + /// # 参数 + /// + /// - `model`: 模型名称(可选) + /// - `api_key`: API Key + /// - `gateway_base_url`: Gateway 的目标 base_url + pub async fn create_agent( + &self, + model: Option, + api_key: String, + gateway_base_url: String, + ) -> Result { + let url = format!("{}/v1/agents", self.base_url); + + println!("[DEBUG] create_agent URL: {}", url); + + // 根据模型名称推断 provider 类型 + let provider = infer_provider_from_model(model.as_deref()); + + let model_config = ModelConfig { + provider: Some(provider.to_string()), + model: model.clone(), + api_key: Some(api_key), + base_url: Some(gateway_base_url), + }; + + let request = CreateAgentRequest { + template_id: "chat".to_string(), + name: None, + model_config: Some(model_config), + }; + + let request_json = serde_json::to_string(&request).unwrap_or_default(); + println!("[DEBUG] create_agent request: {}", request_json); + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| { + println!("[DEBUG] create_agent send error: {}", e); + format!("创建 Agent 请求失败: {}", e) + })?; + + let status = response.status(); + let headers = response.headers().clone(); + println!("[DEBUG] create_agent response status: {}", status); + println!("[DEBUG] create_agent response headers: {:?}", headers); + + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + println!("[DEBUG] create_agent error body: '{}'", body); + println!("[DEBUG] create_agent error body len: {}", body.len()); + return Err(format!("创建 Agent 失败 ({}): {}", status, body)); + } + + let body = response.text().await.map_err(|e| { + println!("[DEBUG] create_agent read body error: {}", e); + format!("读取响应失败: {}", e) + })?; + + println!("[DEBUG] create_agent response body: {}", body); + + serde_json::from_str::(&body).map_err(|e| { + println!("[DEBUG] create_agent parse error: {}", e); + format!("解析响应失败: {}", e) + }) + } + + /// 向 Agent 发送消息(调用 /v1/agents/:id/send) + /// + /// # 参数 + /// + /// - `agent_id`: Agent ID + /// - `message`: 消息内容 + pub async fn send_to_agent( + &self, + agent_id: &str, + message: &str, + ) -> Result { + let url = format!("{}/v1/agents/{}/send", self.base_url, agent_id); + + let request = SendToAgentRequest { + message: message.to_string(), + }; + + let response = self + .client + .post(&url) + .json(&request) + .timeout(Duration::from_secs(120)) // 聊天可能需要更长时间 + .send() + .await + .map_err(|e| format!("发送消息请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("发送消息失败 ({}): {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("解析响应失败: {}", e)) + } + + /// 发送消息(非流式) + /// + /// # 参数 + /// + /// - `session_id`: 会话 ID + /// - `message`: 消息内容 + pub async fn send_message( + &self, + session_id: &str, + message: &str, + ) -> Result { + let url = format!("{}/api/v1/sessions/{}/messages", self.base_url, session_id); + let request = SendMessageRequest { + message: message.to_string(), + stream: false, + }; + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| format!("发送消息请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("发送消息失败 ({}): {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("解析响应失败: {}", e)) + } + + /// 获取会话列表 + pub async fn list_sessions(&self) -> Result, String> { + let url = format!("{}/api/v1/sessions", self.base_url); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("获取会话列表请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("获取会话列表失败 ({}): {}", status, body)); + } + + response + .json::>() + .await + .map_err(|e| format!("解析响应失败: {}", e)) + } + + /// 获取会话详情 + /// + /// # 参数 + /// + /// - `session_id`: 会话 ID + pub async fn get_session(&self, session_id: &str) -> Result { + let url = format!("{}/api/v1/sessions/{}", self.base_url, session_id); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("获取会话详情请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("获取会话详情失败 ({}): {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("解析响应失败: {}", e)) + } + + /// 删除会话 + /// + /// # 参数 + /// + /// - `session_id`: 会话 ID + pub async fn delete_session(&self, session_id: &str) -> Result<(), String> { + let url = format!("{}/api/v1/sessions/{}", self.base_url, session_id); + + let response = self + .client + .delete(&url) + .send() + .await + .map_err(|e| format!("删除会话请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "无法读取响应".to_string()); + return Err(format!("删除会话失败 ({}): {}", status, body)); + } + + Ok(()) + } + + /// 检查健康状态 + pub async fn health_check(&self) -> Result { + let url = format!("{}/health", self.base_url); + + match self.client.get(&url).send().await { + Ok(response) => Ok(response.status().is_success()), + Err(e) => Err(format!("健康检查失败: {}", e)), + } + } +} diff --git a/src-tauri/src/agent/aster_process.rs b/src-tauri/src/agent/aster_process.rs new file mode 100644 index 000000000..958e8bd07 --- /dev/null +++ b/src-tauri/src/agent/aster_process.rs @@ -0,0 +1,583 @@ +//! aster 子进程生命周期管理 +//! +//! 支持两种启动方式: +//! 1. Tauri Sidecar(打包在应用中) +//! 2. Plugin 目录(按需下载) + +use parking_lot::RwLock; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +use tauri_plugin_shell::process::CommandChild; +use tauri_plugin_shell::ShellExt; +use tokio::time::{sleep, timeout}; +use tracing::{error, info, warn}; + +/// aster 进程管理器 +pub struct AsterProcess { + /// 子进程句柄(Sidecar 模式) + child: Arc>>, + /// 标准进程句柄(Plugin 模式) + std_child: Arc>>, + /// aster 服务基础 URL + base_url: String, + /// aster 服务端口 + port: u16, +} + +impl AsterProcess { + /// 获取 aster-server 二进制文件路径(从 plugin 目录) + pub fn get_binary_path() -> Result { + let plugins_dir = dirs::config_dir() + .ok_or("无法获取配置目录")? + .join("proxycast") + .join("plugins") + .join("aster-server"); + + let platform_binary = match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => "aster-server-aarch64-apple-darwin", + ("x86_64", "macos") => "aster-server-x86_64-apple-darwin", + ("x86_64", "linux") => "aster-server-x86_64-unknown-linux-gnu", + ("aarch64", "linux") => "aster-server-aarch64-unknown-linux-gnu", + ("x86_64", "windows") => "aster-server-x86_64-pc-windows-msvc.exe", + _ => return Err("不支持的平台".to_string()), + }; + + let binary_path = plugins_dir.join(platform_binary); + + if !binary_path.exists() { + return Err("aster-server 未安装,请先在扩展页面下载安装".to_string()); + } + + Ok(binary_path) + } + + /// 检查 aster-server 是否已安装(在 plugin 目录) + pub fn is_installed() -> bool { + Self::get_binary_path().is_ok() + } + + /// 从 plugin 目录启动 aster 进程 + /// + /// # 参数 + /// + /// - `port`: aster 服务监听端口 + /// + /// # 返回 + /// + /// 成功返回 `AsterProcess` 实例,失败返回错误信息 + pub async fn start_from_plugin(port: u16) -> Result { + println!("[DEBUG] AsterProcess::start_from_plugin() 开始"); + println!("[DEBUG] port: {}", port); + + info!("从 plugin 目录启动 aster 进程: port={}", port); + + // 获取二进制文件路径 + let binary_path = Self::get_binary_path()?; + let work_dir = binary_path + .parent() + .ok_or("无法获取工作目录")? + .to_path_buf(); + + println!("[DEBUG] 二进制文件路径: {:?}", binary_path); + println!("[DEBUG] 工作目录: {:?}", work_dir); + + // 检查端口是否被占用,如果被占用则尝试清理 + if Self::is_port_in_use(port).await { + println!("[DEBUG] 端口 {} 被占用,尝试清理...", port); + warn!("端口 {} 已被占用,尝试清理旧进程...", port); + Self::kill_process_on_port(port).await?; + // 等待端口释放 + sleep(Duration::from_secs(2)).await; + } else { + println!("[DEBUG] 端口 {} 空闲", port); + } + + // 使用 std::process::Command 启动进程 + println!("[DEBUG] 准备启动进程..."); + + let child = std::process::Command::new(&binary_path) + .current_dir(&work_dir) + .env("PORT", port.to_string()) + .env("GIN_MODE", "release") + .env("ASTER_SLASH_COMMANDS", "true") + .env("ENABLE_SLASH_COMMANDS", "true") + .env("ANTHROPIC_API_KEY", "placeholder-key-for-compressor") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("启动进程失败: {}", e))?; + + println!("[DEBUG] 进程已启动, PID: {}", child.id()); + info!("aster 进程已启动, PID: {}", child.id()); + + let process = Self { + child: Arc::new(RwLock::new(None)), + std_child: Arc::new(RwLock::new(Some(child))), + base_url: format!("http://127.0.0.1:{}", port), + port, + }; + + // 等待 6 秒让 aster 初始化 + println!("[DEBUG] 等待 6 秒让 aster 初始化..."); + info!("等待 6 秒让 aster 进程初始化..."); + sleep(Duration::from_secs(6)).await; + + // 等待健康检查通过 + println!("[DEBUG] 开始健康检查..."); + process.wait_for_health_check(60).await?; + + println!("[DEBUG] 启动成功!"); + info!("aster 进程启动成功,服务地址: {}", process.base_url); + Ok(process) + } + + /// 使用 Tauri Sidecar 启动 aster 子进程 + /// + /// # 参数 + /// + /// - `app_handle`: Tauri AppHandle + /// - `port`: aster 服务监听端口 + /// + /// # 返回 + /// + /// 成功返回 `AsterProcess` 实例,失败返回错误信息 + pub async fn start_with_sidecar(app_handle: &AppHandle, port: u16) -> Result { + println!("[DEBUG] AsterProcess::start_with_sidecar() 开始"); + println!("[DEBUG] port: {}", port); + + info!("启动 aster sidecar 子进程: port={}", port); + + // 检查端口是否被占用,如果被占用则尝试清理 + if Self::is_port_in_use(port).await { + println!("[DEBUG] 端口 {} 被占用,尝试清理...", port); + warn!("端口 {} 已被占用,尝试清理旧进程...", port); + Self::kill_process_on_port(port).await?; + // 等待端口释放 + sleep(Duration::from_secs(2)).await; + } else { + println!("[DEBUG] 端口 {} 空闲", port); + } + + // 使用 Tauri sidecar API 启动进程 + println!("[DEBUG] 准备启动 sidecar..."); + + // 获取 sidecar 二进制文件的目录作为工作目录 + // aster 需要在其二进制文件所在目录运行,以访问 .data 目录 + // + // 在开发模式下,sidecar 是符号链接,我们需要解析到实际目录 + // 在生产模式下,sidecar 在 resources/binaries 目录 + let work_dir = { + // 获取 target triple (编译时确定) + let target = std::env::consts::ARCH.to_string() + "-" + std::env::consts::OS; + let target = match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => "aarch64-apple-darwin", + ("x86_64", "macos") => "x86_64-apple-darwin", + ("x86_64", "linux") => "x86_64-unknown-linux-gnu", + ("aarch64", "linux") => "aarch64-unknown-linux-gnu", + ("x86_64", "windows") => "x86_64-pc-windows-msvc", + _ => "unknown", + }; + + let sidecar_filename = format!("aster-server-{}", target); + + // 尝试从资源目录获取 + let resource_dir = app_handle + .path() + .resource_dir() + .map_err(|e| format!("获取资源目录失败: {}", e))?; + + let sidecar_path = resource_dir.join("binaries").join(&sidecar_filename); + println!("[DEBUG] 尝试 sidecar 路径: {:?}", sidecar_path); + + if sidecar_path.exists() { + // 如果是符号链接,解析到实际路径 + let real_path = std::fs::canonicalize(&sidecar_path) + .map_err(|e| format!("解析 sidecar 路径失败: {}", e))?; + println!("[DEBUG] 实际 sidecar 路径: {:?}", real_path); + + // 获取父目录作为工作目录 + real_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| resource_dir.join("binaries")) + } else { + // 开发模式下,尝试从 src-tauri/binaries 目录 + let dev_sidecar_path = std::env::current_dir() + .unwrap_or_default() + .join("binaries") + .join(&sidecar_filename); + + println!("[DEBUG] 尝试开发模式 sidecar 路径: {:?}", dev_sidecar_path); + + if dev_sidecar_path.exists() { + let real_path = std::fs::canonicalize(&dev_sidecar_path) + .map_err(|e| format!("解析开发模式 sidecar 路径失败: {}", e))?; + real_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + } else { + // 最后回退到当前目录 + std::env::current_dir().unwrap_or_default() + } + } + }; + + println!("[DEBUG] 工作目录: {:?}", work_dir); + + let sidecar_command = app_handle + .shell() + .sidecar("aster-server") + .map_err(|e| format!("获取 sidecar 命令失败: {}", e))? + .current_dir(&work_dir) + .env("PORT", port.to_string()) + .env("GIN_MODE", "release") + // 启用 slash commands 功能 + .env("ASTER_SLASH_COMMANDS", "true") + .env("ENABLE_SLASH_COMMANDS", "true") + // 设置一个虚拟的 ANTHROPIC_API_KEY 以避免 aster 的 prompt compressor panic + // 实际的 API Key 会在创建 agent 时通过 model_config 传递 + .env("ANTHROPIC_API_KEY", "placeholder-key-for-compressor"); + + let (mut rx, child) = sidecar_command + .spawn() + .map_err(|e| format!("启动 sidecar 进程失败: {}", e))?; + + println!("[DEBUG] Sidecar 进程已启动"); + info!("aster sidecar 进程已启动"); + + // 在后台任务中处理进程输出 + tauri::async_runtime::spawn(async move { + use tauri_plugin_shell::process::CommandEvent; + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => { + let line_str = String::from_utf8_lossy(&line); + // 打印所有日志以便调试 + println!("[aster stdout] {}", line_str.trim()); + } + CommandEvent::Stderr(line) => { + let line_str = String::from_utf8_lossy(&line); + println!("[aster stderr] {}", line_str.trim()); + } + CommandEvent::Terminated(payload) => { + println!( + "[aster] 进程已终止: code={:?}, signal={:?}", + payload.code, payload.signal + ); + break; + } + _ => {} + } + } + }); + + let process = Self { + child: Arc::new(RwLock::new(Some(child))), + std_child: Arc::new(RwLock::new(None)), + base_url: format!("http://127.0.0.1:{}", port), + port, + }; + + // 等待 6 秒让 aster 初始化(aster 启动需要较长时间) + println!("[DEBUG] 等待 6 秒让 aster 初始化..."); + info!("等待 6 秒让 aster 进程初始化..."); + sleep(Duration::from_secs(6)).await; + + // 等待健康检查通过(增加到 60 秒,因为 aster 启动需要时间) + println!("[DEBUG] 开始健康检查..."); + process.wait_for_health_check(60).await?; + + println!("[DEBUG] 启动成功!"); + info!("aster sidecar 进程启动成功,服务地址: {}", process.base_url); + Ok(process) + } + + /// 检查端口是否被占用 + async fn is_port_in_use(port: u16) -> bool { + use std::net::TcpListener; + TcpListener::bind(format!("127.0.0.1:{}", port)).is_err() + } + + /// 杀死占用指定端口的进程 + #[cfg(target_os = "macos")] + async fn kill_process_on_port(port: u16) -> Result<(), String> { + use std::process::Command as StdCommand; + + // 使用 lsof 查找占用端口的进程 + let output = StdCommand::new("lsof") + .args(["-ti", &format!(":{}", port)]) + .output() + .map_err(|e| format!("执行 lsof 失败: {}", e))?; + + if output.status.success() { + let pids = String::from_utf8_lossy(&output.stdout); + for pid_str in pids.lines() { + if let Ok(pid) = pid_str.trim().parse::() { + info!("杀死占用端口 {} 的进程 PID: {}", port, pid); + let _ = StdCommand::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + } + } + } + + Ok(()) + } + + /// 杀死占用指定端口的进程(Linux) + #[cfg(target_os = "linux")] + async fn kill_process_on_port(port: u16) -> Result<(), String> { + use std::process::Command as StdCommand; + + // 使用 fuser 查找占用端口的进程 + let output = StdCommand::new("fuser") + .args(["-k", &format!("{}/tcp", port)]) + .output() + .map_err(|e| format!("执行 fuser 失败: {}", e))?; + + if !output.status.success() { + warn!("fuser 执行失败,可能没有找到占用端口的进程"); + } + + Ok(()) + } + + /// 杀死占用指定端口的进程(Windows) + #[cfg(target_os = "windows")] + async fn kill_process_on_port(port: u16) -> Result<(), String> { + use std::process::Command as StdCommand; + + // 使用 netstat 查找占用端口的进程 + let output = StdCommand::new("netstat") + .args(["-ano"]) + .output() + .map_err(|e| format!("执行 netstat 失败: {}", e))?; + + if output.status.success() { + let output_str = String::from_utf8_lossy(&output.stdout); + for line in output_str.lines() { + if line.contains(&format!(":{}", port)) && line.contains("LISTENING") { + if let Some(pid_str) = line.split_whitespace().last() { + if let Ok(pid) = pid_str.parse::() { + info!("杀死占用端口 {} 的进程 PID: {}", port, pid); + let _ = StdCommand::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output(); + } + } + } + } + } + + Ok(()) + } + + /// 等待 aster 服务健康检查通过 + /// + /// # 参数 + /// + /// - `timeout_seconds`: 超时时间(秒) + async fn wait_for_health_check(&self, timeout_seconds: u64) -> Result<(), String> { + let health_url = format!("{}/health", self.base_url); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) // 增加单次请求超时到 10 秒 + .connect_timeout(Duration::from_secs(5)) // 连接超时 5 秒 + .no_proxy() // 禁用系统代理,直接连接 localhost + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + println!("[DEBUG] 健康检查 URL: {}", health_url); + info!("等待 aster 服务就绪,健康检查 URL: {}", health_url); + + let result = timeout(Duration::from_secs(timeout_seconds), async { + let mut attempt = 0; + loop { + attempt += 1; + println!("[DEBUG] 健康检查尝试 #{}", attempt); + info!("健康检查尝试 #{}: {}", attempt, health_url); + + match client.get(&health_url).send().await { + Ok(response) if response.status().is_success() => { + println!("[DEBUG] ✓ 健康检查通过(尝试 {} 次)", attempt); + info!("✓ aster 服务健康检查通过(尝试 {} 次)", attempt); + return Ok(()); + } + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + println!("[DEBUG] ✗ 非成功状态: {} - {}", status, body); + warn!("健康检查返回非成功状态: {} - {}", status, body); + } + Err(e) => { + println!("[DEBUG] ✗ 请求失败: {}", e); + warn!("健康检查失败(尝试 #{}): {}, 2秒后重试...", attempt, e); + } + } + sleep(Duration::from_secs(2)).await; + } + }) + .await; + + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(_) => Err(format!( + "aster 服务启动超时({}秒),健康检查未通过", + timeout_seconds + )), + } + } + + /// 停止 aster 进程 + pub async fn stop(&self) -> Result<(), String> { + // 先尝试停止 Sidecar 模式的进程 + let mut child_guard = self.child.write(); + if let Some(child) = child_guard.take() { + info!("停止 aster sidecar 进程"); + match child.kill() { + Ok(_) => { + info!("aster sidecar 进程已发送终止信号"); + return Ok(()); + } + Err(e) => { + error!("终止 aster sidecar 进程失败: {}", e); + return Err(format!("终止进程失败: {}", e)); + } + } + } + drop(child_guard); + + // 再尝试停止 Plugin 模式的进程 + let mut std_child_guard = self.std_child.write(); + if let Some(mut child) = std_child_guard.take() { + info!("停止 aster plugin 进程"); + match child.kill() { + Ok(_) => { + info!("aster plugin 进程已发送终止信号"); + return Ok(()); + } + Err(e) => { + error!("终止 aster plugin 进程失败: {}", e); + return Err(format!("终止进程失败: {}", e)); + } + } + } + + warn!("aster 进程未运行"); + Ok(()) + } + + /// 检查 aster 进程是否正在运行 + pub fn is_running(&self) -> bool { + let child_guard = self.child.read(); + let std_child_guard = self.std_child.read(); + child_guard.is_some() || std_child_guard.is_some() + } + + /// 获取 aster 服务基础 URL + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// 获取 aster 服务端口 + pub fn port(&self) -> u16 { + self.port + } +} + +impl Drop for AsterProcess { + fn drop(&mut self) { + // 确保进程在对象销毁时被终止 + let mut child_guard = self.child.write(); + if let Some(child) = child_guard.take() { + warn!("AsterProcess 被销毁,终止 aster sidecar 进程"); + let _ = child.kill(); + } + drop(child_guard); + + let mut std_child_guard = self.std_child.write(); + if let Some(mut child) = std_child_guard.take() { + warn!("AsterProcess 被销毁,终止 aster plugin 进程"); + let _ = child.kill(); + } + } +} + +/// Tauri 状态:aster 进程管理器 +/// +/// 用于在 Tauri 应用中共享 AsterProcess 实例 +#[derive(Clone)] +pub struct AsterProcessState(pub Arc>>); + +impl AsterProcessState { + /// 创建新的空状态 + pub fn new() -> Self { + Self(Arc::new(RwLock::new(None))) + } + + /// 设置 AsterProcess 实例 + pub fn set(&self, process: AsterProcess) { + let mut guard = self.0.write(); + *guard = Some(process); + } + + /// 获取 AsterProcess 的只读引用 + pub fn get(&self) -> Option>> { + let guard = self.0.read(); + if guard.is_some() { + Some(guard) + } else { + None + } + } + + /// 停止 aster 进程并清除状态 + pub async fn stop(&self) -> Result<(), String> { + // 先从锁中取出 process,避免跨 await 持有锁 + let process = { + let mut guard = self.0.write(); + guard.take() + }; + + if let Some(process) = process { + process.stop().await + } else { + Ok(()) + } + } + + /// 检查 aster 进程是否正在运行 + pub fn is_running(&self) -> bool { + let guard = self.0.read(); + guard.as_ref().map(|p| p.is_running()).unwrap_or(false) + } +} + +impl Default for AsterProcessState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aster_process_state_new() { + let state = AsterProcessState::new(); + assert!(!state.is_running()); + } + + #[test] + fn test_base_url_format() { + // 测试 URL 格式 + let port = 8081; + let expected_url = "http://127.0.0.1:8081"; + let url = format!("http://127.0.0.1:{}", port); + assert_eq!(url, expected_url); + } +} diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs new file mode 100644 index 000000000..2fafbd0ea --- /dev/null +++ b/src-tauri/src/agent/mod.rs @@ -0,0 +1,18 @@ +//! AI Agent 集成模块 +//! +//! 负责管理 aster AI Agent 子进程,提供 Agent 功能的 Rust 接口。 +//! +//! # 模块结构 +//! +//! - `aster_process`: aster 子进程生命周期管理 +//! - `aster_client`: HTTP 客户端,调用 aster API +//! - `credential_sync`: 凭证同步服务(后续实现) + +pub mod aster_client; +pub mod aster_process; + +pub use aster_client::{ + AsterClient, ChatRequest, ChatResponse, CreateAgentData, CreateAgentRequest, + CreateAgentResponse, ImageInput, ModelConfig, SendToAgentRequest, SendToAgentResponse, +}; +pub use aster_process::{AsterProcess, AsterProcessState}; diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs new file mode 100644 index 000000000..a5635cd4e --- /dev/null +++ b/src-tauri/src/commands/agent_cmd.rs @@ -0,0 +1,477 @@ +//! Agent 命令模块 +//! +//! 提供 aster Agent 子进程管理和会话管理的 Tauri 命令 + +use crate::agent::{AsterClient, AsterProcess, AsterProcessState}; +use crate::AppState; +use serde::{Deserialize, Serialize}; +use tauri::State; + +/// Agent 进程状态响应 +#[derive(Debug, Serialize)] +pub struct AgentProcessStatus { + /// 进程是否正在运行 + pub running: bool, + /// aster 服务地址 + pub base_url: Option, + /// aster 服务端口 + pub port: Option, +} + +/// 创建会话响应 +#[derive(Debug, Serialize)] +pub struct CreateSessionResponse { + /// 会话 ID + pub session_id: String, + /// 使用的凭证名称 + pub credential_name: String, + /// 使用的凭证 UUID + pub credential_uuid: String, + /// Provider 类型 + pub provider_type: String, + /// 模型名称 + pub model: Option, +} + +/// 启动 Agent 进程 +/// +/// 优先从 plugin 目录启动,如果未安装则回退到 Tauri Sidecar +/// +/// # 参数 +/// +/// - `port`: aster 服务监听端口(可选,默认 8081) +#[tauri::command] +pub async fn agent_start_process( + app_handle: tauri::AppHandle, + aster_state: State<'_, AsterProcessState>, + port: Option, +) -> Result { + println!("[DEBUG] ========== agent_start_process() 开始 =========="); + + // 检查是否已经启动 + println!("[DEBUG] 检查进程是否已运行..."); + if aster_state.is_running() { + println!("[DEBUG] 进程已在运行"); + return Err("aster 进程已经在运行".to_string()); + } + println!("[DEBUG] 进程未运行"); + + // 默认参数 + let service_port = port.unwrap_or(8081); + + println!("[DEBUG] 参数: port={}", service_port); + + // 优先从 plugin 目录启动 + let process = if AsterProcess::is_installed() { + tracing::info!( + "[AGENT] 从 plugin 目录启动 aster 进程: port={}", + service_port + ); + println!("[DEBUG] 调用 AsterProcess::start_from_plugin()..."); + AsterProcess::start_from_plugin(service_port).await? + } else { + // 回退到 Tauri Sidecar + tracing::info!("[AGENT] 从 sidecar 启动 aster 进程: port={}", service_port); + println!("[DEBUG] 调用 AsterProcess::start_with_sidecar()..."); + AsterProcess::start_with_sidecar(&app_handle, service_port).await? + }; + println!("[DEBUG] AsterProcess 启动完成"); + + let base_url = process.base_url().to_string(); + let port = process.port(); + + // 保存进程状态 + aster_state.set(process); + + println!("[DEBUG] ========== agent_start_process() 完成 =========="); + tracing::info!("[AGENT] aster 进程启动成功: {}", base_url); + + Ok(AgentProcessStatus { + running: true, + base_url: Some(base_url), + port: Some(port), + }) +} + +/// 停止 Agent 进程 +#[tauri::command] +pub async fn agent_stop_process(aster_state: State<'_, AsterProcessState>) -> Result<(), String> { + tracing::info!("[AGENT] 停止 aster 进程"); + + aster_state.stop().await?; + + tracing::info!("[AGENT] aster 进程已停止"); + Ok(()) +} + +/// 获取 Agent 进程状态 +#[tauri::command] +pub async fn agent_get_process_status( + aster_state: State<'_, AsterProcessState>, +) -> Result { + let running = aster_state.is_running(); + + if running { + let guard = aster_state.0.read(); + if let Some(process) = guard.as_ref() { + Ok(AgentProcessStatus { + running: true, + base_url: Some(process.base_url().to_string()), + port: Some(process.port()), + }) + } else { + Ok(AgentProcessStatus { + running: false, + base_url: None, + port: None, + }) + } + } else { + Ok(AgentProcessStatus { + running: false, + base_url: None, + port: None, + }) + } +} + +/// 创建 Agent 会话 +/// +/// 使用 gateway provider 将请求转发到 ProxyCast API Server, +/// 由 ProxyCast 统一处理凭证和路由。 +/// +/// # 参数 +/// +/// - `provider_type`: Provider 类型(用于前端显示,如 claude, openai, gemini) +/// - `model`: 模型名称(可选) +#[tauri::command] +pub async fn agent_create_session( + aster_state: State<'_, AsterProcessState>, + app_state: State<'_, AppState>, + provider_type: String, + model: Option, +) -> Result { + tracing::info!( + "[AGENT] 创建会话: provider_type={}, model={:?}", + provider_type, + model + ); + + // 检查 aster 进程是否运行 + if !aster_state.is_running() { + return Err("aster 进程未运行,请先启动进程".to_string()); + } + + // 获取 ProxyCast API Server 配置 + let (proxycast_port, proxycast_api_key, server_running) = { + let state = app_state.read().await; + ( + state.config.server.port, + state.running_api_key.clone(), + state.running, + ) + }; + + // 检查 ProxyCast 服务器是否运行 + if !server_running { + return Err("ProxyCast API Server 未运行,请先启动服务器".to_string()); + } + + let proxycast_api_key = + proxycast_api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?; + + // 构建 ProxyCast base_url + let proxycast_base_url = format!("http://127.0.0.1:{}", proxycast_port); + + tracing::info!( + "[AGENT] 使用 gateway provider, base_url={}, model={:?}", + proxycast_base_url, + model + ); + + // 获取 aster 服务地址 + let aster_base_url = { + let guard = aster_state.0.read(); + guard + .as_ref() + .map(|p| p.base_url().to_string()) + .ok_or_else(|| "无法获取 aster 服务地址".to_string())? + }; + + // 创建 aster 客户端 + let client = AsterClient::new(aster_base_url)?; + + // 创建 Agent,使用 gateway provider + // gateway provider 会根据 model 名称自动推断协议(anthropic/openai/gemini) + let response = client + .create_agent(model.clone(), proxycast_api_key, proxycast_base_url) + .await?; + + tracing::info!("[AGENT] Agent 创建成功: {}", response.data.id); + + Ok(CreateSessionResponse { + session_id: response.data.id, // 使用 agent_id 作为 session_id + credential_name: "ProxyCast".to_string(), + credential_uuid: "proxycast-gateway".to_string(), + provider_type, + model, + }) +} + +/// 图片输入(前端传入) +#[derive(Debug, Deserialize)] +pub struct ImageInputParam { + /// base64 编码的图片数据 + pub data: String, + /// MIME 类型,如 "image/png" + pub media_type: String, +} + +/// 发送消息到 Agent(使用同步 chat API) +/// +/// # 参数 +/// +/// - `message`: 消息内容 +/// - `images`: 图片列表(可选) +/// - `model`: 模型名称(可选) +#[tauri::command] +pub async fn agent_send_message( + aster_state: State<'_, AsterProcessState>, + app_state: State<'_, AppState>, + message: String, + images: Option>, + model: Option, +) -> Result { + tracing::info!( + "[AGENT] 发送消息: message={}, images={:?}", + message, + images.as_ref().map(|v| v.len()) + ); + println!( + "[DEBUG] agent_send_message: message={}, model={:?}", + message, model + ); + + // 检查进程是否运行 + if !aster_state.is_running() { + return Err("aster 进程未运行,请先启动进程".to_string()); + } + + // 获取 ProxyCast API Server 配置 + let (proxycast_port, proxycast_api_key, server_running) = { + let state = app_state.read().await; + ( + state.config.server.port, + state.running_api_key.clone(), + state.running, + ) + }; + + // 检查 ProxyCast 服务器是否运行 + if !server_running { + return Err("ProxyCast API Server 未运行,请先启动服务器".to_string()); + } + + let proxycast_api_key = + proxycast_api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?; + + // 构建 ProxyCast base_url + let proxycast_base_url = format!("http://127.0.0.1:{}", proxycast_port); + + // 获取 aster 服务地址 + let aster_base_url = { + let guard = aster_state.0.read(); + guard + .as_ref() + .map(|p| p.base_url().to_string()) + .ok_or_else(|| "无法获取 aster 服务地址".to_string())? + }; + + println!( + "[DEBUG] agent_send_message: aster_base_url={}, proxycast_base_url={}", + aster_base_url, proxycast_base_url + ); + + // 创建客户端 + let client = AsterClient::new(aster_base_url)?; + + // 转换图片格式 + let images_for_api = images.map(|imgs| { + imgs.into_iter() + .map(|img| crate::agent::ImageInput { + data: img.data, + media_type: img.media_type, + }) + .collect() + }); + + // 使用同步 chat API 发送消息(支持图片) + let response = client + .chat_with_images( + &message, + images_for_api, + model, + proxycast_api_key, + proxycast_base_url, + ) + .await?; + + println!( + "[DEBUG] agent_send_message: response success={}, text len={}", + response.success, + response.text.len() + ); + tracing::info!("[AGENT] 消息发送成功"); + + // 优先返回 text,如果为空则返回 output + let result = if !response.text.is_empty() { + response.text + } else { + response.output + }; + + Ok(result) +} + +/// 会话信息 +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionInfo { + /// 会话 ID + pub session_id: String, + /// Provider 类型 + pub provider_type: String, + /// 模型名称 + pub model: Option, + /// 创建时间 + pub created_at: String, + /// 最后活动时间 + pub last_activity: String, + /// 消息数量 + pub messages_count: usize, +} + +/// 获取会话列表 +#[tauri::command] +pub async fn agent_list_sessions( + aster_state: State<'_, AsterProcessState>, +) -> Result, String> { + tracing::info!("[AGENT] 获取会话列表"); + + // 检查进程是否运行 + if !aster_state.is_running() { + return Err("aster 进程未运行,请先启动进程".to_string()); + } + + // 获取 base_url + let base_url = { + let guard = aster_state.0.read(); + guard + .as_ref() + .map(|p| p.base_url().to_string()) + .ok_or_else(|| "无法获取 aster 服务地址".to_string())? + }; + + // 创建客户端 + let client = AsterClient::new(base_url)?; + + // 获取会话列表 + let sessions = client.list_sessions().await?; + + // 转换为前端格式 + let result = sessions + .into_iter() + .map(|s| SessionInfo { + session_id: s.session_id, + provider_type: s.provider_type, + model: s.model, + created_at: s.created_at, + last_activity: s.last_activity, + messages_count: s.messages_count, + }) + .collect(); + + tracing::info!("[AGENT] 获取会话列表成功"); + + Ok(result) +} + +/// 获取会话详情 +/// +/// # 参数 +/// +/// - `session_id`: 会话 ID +#[tauri::command] +pub async fn agent_get_session( + aster_state: State<'_, AsterProcessState>, + session_id: String, +) -> Result { + tracing::info!("[AGENT] 获取会话详情: session_id={}", session_id); + + // 检查进程是否运行 + if !aster_state.is_running() { + return Err("aster 进程未运行,请先启动进程".to_string()); + } + + // 获取 base_url + let base_url = { + let guard = aster_state.0.read(); + guard + .as_ref() + .map(|p| p.base_url().to_string()) + .ok_or_else(|| "无法获取 aster 服务地址".to_string())? + }; + + // 创建客户端 + let client = AsterClient::new(base_url)?; + + // 获取会话详情 + let session = client.get_session(&session_id).await?; + + tracing::info!("[AGENT] 获取会话详情成功"); + + Ok(SessionInfo { + session_id: session.session_id, + provider_type: session.provider_type, + model: session.model, + created_at: session.created_at, + last_activity: session.last_activity, + messages_count: session.messages_count, + }) +} + +/// 删除会话 +/// +/// # 参数 +/// +/// - `session_id`: 会话 ID +#[tauri::command] +pub async fn agent_delete_session( + aster_state: State<'_, AsterProcessState>, + session_id: String, +) -> Result<(), String> { + tracing::info!("[AGENT] 删除会话: session_id={}", session_id); + + // 检查进程是否运行 + if !aster_state.is_running() { + return Err("aster 进程未运行,请先启动进程".to_string()); + } + + // 获取 base_url + let base_url = { + let guard = aster_state.0.read(); + guard + .as_ref() + .map(|p| p.base_url().to_string()) + .ok_or_else(|| "无法获取 aster 服务地址".to_string())? + }; + + // 创建客户端 + let client = AsterClient::new(base_url)?; + + // 删除会话 + client.delete_session(&session_id).await?; + + tracing::info!("[AGENT] 会话删除成功"); + + Ok(()) +} diff --git a/src-tauri/src/commands/binary_cmd.rs b/src-tauri/src/commands/binary_cmd.rs new file mode 100644 index 000000000..cef971fb3 --- /dev/null +++ b/src-tauri/src/commands/binary_cmd.rs @@ -0,0 +1,309 @@ +//! 二进制组件管理命令 +//! +//! 提供 aster-server 等二进制组件的安装、卸载、更新功能 + +use crate::plugin::{BinaryComponentStatus, BinaryDownloader}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tokio::fs; +use tracing::{error, info}; + +/// 下载进度事件 +#[derive(Debug, Clone, Serialize)] +pub struct DownloadProgress { + /// 组件名称 + pub component: String, + /// 已下载字节数 + pub downloaded: u64, + /// 总字节数 + pub total: u64, + /// 下载百分比 + pub percentage: f64, +} + +/// aster-server 组件配置 +const ASTER_COMPONENT_NAME: &str = "aster-server"; +const ASTER_GITHUB_OWNER: &str = "astercloud"; +const ASTER_GITHUB_REPO: &str = "aster"; +const ASTER_CHECKSUM_FILE: &str = "checksums.txt"; + +/// 比较版本号 +fn version_compare(installed: &str, latest: &str) -> bool { + // 简单的版本比较:移除 'v' 前缀后比较 + let installed = installed.trim_start_matches('v'); + let latest = latest.trim_start_matches('v'); + + // 按 . 分割并比较每个部分 + let installed_parts: Vec = installed + .split('.') + .filter_map(|s| s.parse().ok()) + .collect(); + let latest_parts: Vec = latest.split('.').filter_map(|s| s.parse().ok()).collect(); + + for i in 0..std::cmp::max(installed_parts.len(), latest_parts.len()) { + let installed_part = installed_parts.get(i).unwrap_or(&0); + let latest_part = latest_parts.get(i).unwrap_or(&0); + if latest_part > installed_part { + return true; + } else if latest_part < installed_part { + return false; + } + } + false +} + +/// 获取 aster-server 组件状态 +#[tauri::command] +pub async fn get_aster_status() -> Result { + let downloader = BinaryDownloader::new(); + + // 检查本地安装状态 + let component_dir = BinaryDownloader::get_component_dir(ASTER_COMPONENT_NAME)?; + let manifest_path = component_dir.join("manifest.json"); + let installed = manifest_path.exists(); + + let (installed_version, installed_at, description) = if installed { + // 读取本地 manifest 获取版本 + match fs::read_to_string(&manifest_path).await { + Ok(content) => { + let manifest: serde_json::Value = + serde_json::from_str(&content).unwrap_or_default(); + ( + manifest["version"].as_str().map(|s| s.to_string()), + manifest["installed_at"].as_str().map(|s| s.to_string()), + manifest["description"].as_str().map(|s| s.to_string()), + ) + } + Err(_) => (None, None, None), + } + } else { + (None, None, None) + }; + + // 获取最新版本(可能失败,不影响返回结果) + let latest_version = match downloader + .get_latest_version(ASTER_GITHUB_OWNER, ASTER_GITHUB_REPO) + .await + { + Ok((version, _)) => Some(version), + Err(e) => { + error!("获取最新版本失败: {}", e); + None + } + }; + + let has_update = match (&installed_version, &latest_version) { + (Some(installed), Some(latest)) => version_compare(installed, latest), + _ => false, + }; + + // 获取二进制文件路径 + let binary_name = BinaryDownloader::get_platform_binary_name(ASTER_COMPONENT_NAME); + let binary_path = if installed { + let path = component_dir.join(&binary_name); + if path.exists() { + Some(path.to_string_lossy().to_string()) + } else { + None + } + } else { + None + }; + + Ok(BinaryComponentStatus { + name: ASTER_COMPONENT_NAME.to_string(), + installed, + installed_version, + latest_version, + has_update, + binary_path, + installed_at, + description: description + .or_else(|| Some("AI Agent 框架 - 提供 Agent 对话能力".to_string())), + }) +} + +/// 安装 aster-server 组件 +#[tauri::command] +pub async fn install_aster(app_handle: AppHandle) -> Result { + info!("开始安装 aster-server"); + + let downloader = BinaryDownloader::new(); + + // 获取最新版本 + let (version, assets) = downloader + .get_latest_version(ASTER_GITHUB_OWNER, ASTER_GITHUB_REPO) + .await?; + + info!("最新版本: {}", version); + + // 获取当前平台的二进制文件名 + let binary_name = BinaryDownloader::get_platform_binary_name(ASTER_COMPONENT_NAME); + info!("平台二进制文件名: {}", binary_name); + + // 查找对应的 asset + let asset = assets + .iter() + .find(|a| a.name == binary_name) + .ok_or_else(|| format!("未找到平台对应的二进制文件: {}", binary_name))?; + + info!("找到 asset: {} ({})", asset.name, asset.size); + + // 目标路径 + let target_dir = BinaryDownloader::get_component_dir(ASTER_COMPONENT_NAME)?; + let target_path = target_dir.join(&binary_name); + + // 确保目录存在 + fs::create_dir_all(&target_dir) + .await + .map_err(|e| format!("创建目录失败: {}", e))?; + + // 下载(带进度事件) + let app_handle_clone = app_handle.clone(); + let component_name = ASTER_COMPONENT_NAME.to_string(); + + downloader + .download_binary( + &asset.download_url, + &target_path, + move |downloaded, total| { + let progress = DownloadProgress { + component: component_name.clone(), + downloaded, + total, + percentage: if total > 0 { + (downloaded as f64 / total as f64) * 100.0 + } else { + 0.0 + }, + }; + let _ = app_handle_clone.emit("binary-download-progress", progress); + }, + ) + .await?; + + // 验证校验和(如果有) + match downloader.get_checksums(&assets, ASTER_CHECKSUM_FILE).await { + Ok(checksums) => { + if let Some(expected_hash) = checksums.get(&binary_name) { + info!("验证校验和: {}", expected_hash); + if !downloader + .verify_checksum(&target_path, expected_hash) + .await? + { + // 删除损坏的文件 + let _ = fs::remove_file(&target_path).await; + return Err("校验和验证失败,文件可能已损坏".to_string()); + } + info!("校验和验证通过"); + } + } + Err(e) => { + // 校验文件不存在不是致命错误 + info!("跳过校验和验证: {}", e); + } + } + + // 创建 manifest.json + let manifest = serde_json::json!({ + "name": ASTER_COMPONENT_NAME, + "version": version, + "description": "AI Agent 框架 - 提供 Agent 对话能力", + "author": ASTER_GITHUB_OWNER, + "homepage": format!("https://github.com/{}/{}", ASTER_GITHUB_OWNER, ASTER_GITHUB_REPO), + "plugin_type": "binary", + "installed_at": chrono::Utc::now().to_rfc3339(), + "binary_name": binary_name, + }); + + fs::write( + target_dir.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .await + .map_err(|e| format!("保存 manifest 失败: {}", e))?; + + info!("aster-server v{} 安装成功", version); + Ok(format!("aster-server v{} 安装成功", version)) +} + +/// 卸载 aster-server 组件 +#[tauri::command] +pub async fn uninstall_aster() -> Result { + info!("开始卸载 aster-server"); + + let target_dir = BinaryDownloader::get_component_dir(ASTER_COMPONENT_NAME)?; + + if target_dir.exists() { + fs::remove_dir_all(&target_dir) + .await + .map_err(|e| format!("删除目录失败: {}", e))?; + info!("aster-server 已卸载"); + } else { + info!("aster-server 未安装"); + } + + Ok("aster-server 已卸载".to_string()) +} + +/// 检查 aster-server 更新 +#[tauri::command] +pub async fn check_aster_update() -> Result { + get_aster_status().await +} + +/// 更新 aster-server 组件 +#[tauri::command] +pub async fn update_aster(app_handle: AppHandle) -> Result { + info!("开始更新 aster-server"); + + // 先卸载旧版本 + uninstall_aster().await?; + + // 安装新版本 + install_aster(app_handle).await +} + +/// 获取 aster-server 二进制文件路径 +#[tauri::command] +pub fn get_aster_binary_path() -> Result { + let component_dir = BinaryDownloader::get_component_dir(ASTER_COMPONENT_NAME)?; + let binary_name = BinaryDownloader::get_platform_binary_name(ASTER_COMPONENT_NAME); + let binary_path = component_dir.join(&binary_name); + + if binary_path.exists() { + Ok(binary_path.to_string_lossy().to_string()) + } else { + Err("aster-server 未安装".to_string()) + } +} + +/// 检查 aster-server 是否已安装 +#[tauri::command] +pub fn is_aster_installed() -> bool { + let component_dir = match BinaryDownloader::get_component_dir(ASTER_COMPONENT_NAME) { + Ok(dir) => dir, + Err(_) => return false, + }; + + let binary_name = BinaryDownloader::get_platform_binary_name(ASTER_COMPONENT_NAME); + let binary_path = component_dir.join(&binary_name); + + binary_path.exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_compare() { + assert!(version_compare("0.34.0", "0.35.0")); + assert!(version_compare("v0.34.0", "v0.35.0")); + assert!(!version_compare("0.35.0", "0.34.0")); + assert!(!version_compare("0.35.0", "0.35.0")); + assert!(version_compare("1.0.0", "1.0.1")); + assert!(version_compare("1.0.0", "1.1.0")); + assert!(version_compare("1.0.0", "2.0.0")); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f4af90171..de5bce47d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,6 @@ +pub mod agent_cmd; pub mod auto_fix_cmd; +pub mod binary_cmd; pub mod browser_interceptor_cmd; pub mod config_cmd; pub mod flow_monitor_cmd; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b6bfc144..f8731ef85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod browser_interceptor; mod commands; mod config; @@ -28,6 +29,7 @@ use std::sync::Arc; use tauri::{Manager, Runtime}; use tokio::sync::RwLock; +use agent::AsterProcessState; use commands::browser_interceptor_cmd::BrowserInterceptorState; use commands::flow_monitor_cmd::{ BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState, @@ -1647,6 +1649,9 @@ pub fn run() { // Initialize BrowserInterceptorState let browser_interceptor_state = BrowserInterceptorState::default(); + // Initialize AsterProcessState + let aster_process_state = AsterProcessState::default(); + // FlowQueryService 需要 file_store,如果没有则创建一个临时的 let flow_query_service_state = if let Some(file_store) = flow_file_store { let query_service = FlowQueryService::new(flow_monitor.memory_store(), file_store); @@ -1744,6 +1749,7 @@ pub fn run() { .manage(enhanced_stats_service_state) .manage(batch_operations_state) .manage(browser_interceptor_state) + .manage(aster_process_state) .on_window_event(move |window, event| { // 处理窗口关闭事件 if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -2346,6 +2352,23 @@ pub fn run() { commands::kiro_local::switch_kiro_to_local, commands::kiro_local::get_kiro_fingerprint_info, commands::kiro_local::get_local_kiro_credential_uuid, + // Agent commands + commands::agent_cmd::agent_start_process, + commands::agent_cmd::agent_stop_process, + commands::agent_cmd::agent_get_process_status, + commands::agent_cmd::agent_create_session, + commands::agent_cmd::agent_send_message, + commands::agent_cmd::agent_list_sessions, + commands::agent_cmd::agent_get_session, + commands::agent_cmd::agent_delete_session, + // Binary component commands + commands::binary_cmd::get_aster_status, + commands::binary_cmd::install_aster, + commands::binary_cmd::uninstall_aster, + commands::binary_cmd::check_aster_update, + commands::binary_cmd::update_aster, + commands::binary_cmd::get_aster_binary_path, + commands::binary_cmd::is_aster_installed, // Network commands commands::network_cmd::get_network_info, ]) diff --git a/src-tauri/src/plugin/binary_downloader.rs b/src-tauri/src/plugin/binary_downloader.rs new file mode 100644 index 000000000..a8ff14eac --- /dev/null +++ b/src-tauri/src/plugin/binary_downloader.rs @@ -0,0 +1,313 @@ +//! 二进制组件下载器 +//! +//! 从 GitHub Releases 下载二进制组件 + +use reqwest::Client; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::PathBuf; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tracing::{info, warn}; + +/// GitHub Release Asset 信息 +#[derive(Debug, Clone)] +pub struct ReleaseAsset { + /// 文件名 + pub name: String, + /// 下载 URL + pub download_url: String, + /// 文件大小 (bytes) + pub size: u64, +} + +/// 二进制组件下载器 +pub struct BinaryDownloader { + client: Client, +} + +impl BinaryDownloader { + /// 创建新的下载器 + pub fn new() -> Self { + Self { + client: Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .user_agent("ProxyCast") + .build() + .expect("Failed to create HTTP client"), + } + } + + /// 获取当前平台标识 + pub fn get_platform_key() -> &'static str { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => "macos-arm64", + ("x86_64", "macos") => "macos-x64", + ("x86_64", "linux") => "linux-x64", + ("aarch64", "linux") => "linux-arm64", + ("x86_64", "windows") => "windows-x64", + _ => "unknown", + } + } + + /// 获取当前平台的二进制文件名 + pub fn get_platform_binary_name(base_name: &str) -> String { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => format!("{}-aarch64-apple-darwin", base_name), + ("x86_64", "macos") => format!("{}-x86_64-apple-darwin", base_name), + ("x86_64", "linux") => format!("{}-x86_64-unknown-linux-gnu", base_name), + ("aarch64", "linux") => format!("{}-aarch64-unknown-linux-gnu", base_name), + ("x86_64", "windows") => format!("{}-x86_64-pc-windows-msvc.exe", base_name), + _ => format!("{}-unknown", base_name), + } + } + + /// 获取最新版本信息 + pub async fn get_latest_version( + &self, + github_owner: &str, + github_repo: &str, + ) -> Result<(String, Vec), String> { + let api_url = format!( + "https://api.github.com/repos/{}/{}/releases/latest", + github_owner, github_repo + ); + + info!("获取最新版本: {}", api_url); + + let response = self + .client + .get(&api_url) + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .map_err(|e| format!("请求 GitHub API 失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("GitHub API 请求失败: {} - {}", status, body)); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + let version = data["tag_name"] + .as_str() + .unwrap_or("") + .trim_start_matches('v') + .to_string(); + + let assets = data["assets"] + .as_array() + .ok_or("未找到 assets")? + .iter() + .filter_map(|a| { + Some(ReleaseAsset { + name: a["name"].as_str()?.to_string(), + download_url: a["browser_download_url"].as_str()?.to_string(), + size: a["size"].as_u64().unwrap_or(0), + }) + }) + .collect(); + + info!( + "最新版本: {}, 找到 {} 个 assets", + version, + data["assets"].as_array().map(|a| a.len()).unwrap_or(0) + ); + + Ok((version, assets)) + } + + /// 下载二进制文件(带进度回调) + pub async fn download_binary( + &self, + download_url: &str, + target_path: &PathBuf, + progress_callback: F, + ) -> Result<(), String> + where + F: Fn(u64, u64) + Send + 'static, + { + info!("开始下载: {} -> {:?}", download_url, target_path); + + let response = self + .client + .get(download_url) + .send() + .await + .map_err(|e| format!("下载请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(format!("下载失败: HTTP {}", response.status())); + } + + let total_size = response.content_length().unwrap_or(0); + info!("文件大小: {} bytes", total_size); + + // 确保目标目录存在 + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建目录失败: {}", e))?; + } + + let mut file = fs::File::create(target_path) + .await + .map_err(|e| format!("创建文件失败: {}", e))?; + + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + + use futures::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取数据失败: {}", e))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("写入文件失败: {}", e))?; + downloaded += chunk.len() as u64; + progress_callback(downloaded, total_size); + } + + file.flush() + .await + .map_err(|e| format!("刷新文件失败: {}", e))?; + + // 设置可执行权限 (Unix) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(target_path) + .await + .map_err(|e| format!("获取文件权限失败: {}", e))?; + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + fs::set_permissions(target_path, perms) + .await + .map_err(|e| format!("设置可执行权限失败: {}", e))?; + } + + info!("下载完成: {:?}", target_path); + Ok(()) + } + + /// 验证校验和 + pub async fn verify_checksum( + &self, + file_path: &PathBuf, + expected_hash: &str, + ) -> Result { + let content = fs::read(file_path) + .await + .map_err(|e| format!("读取文件失败: {}", e))?; + + let mut hasher = Sha256::new(); + hasher.update(&content); + let result = format!("{:x}", hasher.finalize()); + + let matches = result.to_lowercase() == expected_hash.to_lowercase(); + if !matches { + warn!("校验和不匹配: 期望 {}, 实际 {}", expected_hash, result); + } + + Ok(matches) + } + + /// 下载并解析 checksums.txt + pub async fn get_checksums( + &self, + assets: &[ReleaseAsset], + checksum_filename: &str, + ) -> Result, String> { + let checksum_asset = assets + .iter() + .find(|a| a.name == checksum_filename) + .ok_or_else(|| format!("未找到校验文件: {}", checksum_filename))?; + + let response = self + .client + .get(&checksum_asset.download_url) + .send() + .await + .map_err(|e| format!("下载校验文件失败: {}", e))?; + + if !response.status().is_success() { + return Err(format!("下载校验文件失败: HTTP {}", response.status())); + } + + let content = response + .text() + .await + .map_err(|e| format!("读取校验文件失败: {}", e))?; + + let mut checksums = HashMap::new(); + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + // 格式: hash filename 或 hash *filename + let hash = parts[0]; + let filename = parts[1].trim_start_matches('*'); + checksums.insert(filename.to_string(), hash.to_string()); + } + } + + info!("解析到 {} 个校验和", checksums.len()); + Ok(checksums) + } + + /// 获取插件目录 + pub fn get_plugins_dir() -> Result { + dirs::config_dir() + .ok_or_else(|| "无法获取配置目录".to_string()) + .map(|p| p.join("proxycast").join("plugins")) + } + + /// 获取特定组件的目录 + pub fn get_component_dir(component_name: &str) -> Result { + Self::get_plugins_dir().map(|p| p.join(component_name)) + } +} + +impl Default for BinaryDownloader { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_platform_key() { + let key = BinaryDownloader::get_platform_key(); + assert!(!key.is_empty()); + // 在测试环境中应该返回有效的平台标识 + assert!([ + "macos-arm64", + "macos-x64", + "linux-x64", + "linux-arm64", + "windows-x64", + "unknown" + ] + .contains(&key)); + } + + #[test] + fn test_get_platform_binary_name() { + let name = BinaryDownloader::get_platform_binary_name("aster-server"); + assert!(name.starts_with("aster-server-")); + } + + #[test] + fn test_get_plugins_dir() { + let result = BinaryDownloader::get_plugins_dir(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.ends_with("proxycast/plugins") || path.ends_with("proxycast\\plugins")); + } +} diff --git a/src-tauri/src/plugin/loader.rs b/src-tauri/src/plugin/loader.rs index d6ab500a3..0780bd351 100644 --- a/src-tauri/src/plugin/loader.rs +++ b/src-tauri/src/plugin/loader.rs @@ -69,6 +69,9 @@ impl PluginLoader { match manifest.plugin_type { PluginType::Script => self.load_script_plugin(plugin_dir, manifest, config).await, PluginType::Native => Err(PluginError::LoadError("原生插件暂不支持".to_string())), + PluginType::Binary => Err(PluginError::LoadError( + "二进制组件不通过插件加载器加载".to_string(), + )), } } diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs index 6211f0fd9..73c7d954a 100644 --- a/src-tauri/src/plugin/mod.rs +++ b/src-tauri/src/plugin/mod.rs @@ -5,16 +5,19 @@ //! - 请求前/响应后钩子 //! - 插件隔离和错误处理 //! - 插件配置管理 +//! - 二进制组件下载和管理 +pub mod binary_downloader; mod loader; mod manager; mod types; +pub use binary_downloader::BinaryDownloader; pub use loader::PluginLoader; pub use manager::PluginManager; pub use types::{ - HookResult, Plugin, PluginConfig, PluginContext, PluginError, PluginInfo, PluginManifest, - PluginState, PluginStatus, PluginType, + BinaryComponentStatus, BinaryManifest, HookResult, PlatformBinaries, Plugin, PluginConfig, + PluginContext, PluginError, PluginInfo, PluginManifest, PluginState, PluginStatus, PluginType, }; #[cfg(test)] diff --git a/src-tauri/src/plugin/types.rs b/src-tauri/src/plugin/types.rs index 02db5e503..db4e94f61 100644 --- a/src-tauri/src/plugin/types.rs +++ b/src-tauri/src/plugin/types.rs @@ -142,6 +142,79 @@ pub enum PluginType { Script, /// 原生 Rust 插件 (预留) Native, + /// 二进制可执行文件 + Binary, +} + +/// 平台二进制文件名映射 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformBinaries { + /// macOS ARM64 (Apple Silicon) + #[serde(rename = "macos-arm64")] + pub macos_arm64: String, + /// macOS x64 (Intel) + #[serde(rename = "macos-x64")] + pub macos_x64: String, + /// Linux x64 + #[serde(rename = "linux-x64")] + pub linux_x64: String, + /// Linux ARM64 + #[serde(rename = "linux-arm64")] + pub linux_arm64: String, + /// Windows x64 + #[serde(rename = "windows-x64")] + pub windows_x64: String, +} + +impl PlatformBinaries { + /// 获取当前平台的二进制文件名 + pub fn get_current_platform(&self) -> Option<&str> { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => Some(&self.macos_arm64), + ("x86_64", "macos") => Some(&self.macos_x64), + ("x86_64", "linux") => Some(&self.linux_x64), + ("aarch64", "linux") => Some(&self.linux_arm64), + ("x86_64", "windows") => Some(&self.windows_x64), + _ => None, + } + } +} + +/// Binary 类型的 manifest 扩展字段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinaryManifest { + /// 二进制文件名(不含平台后缀) + pub binary_name: String, + /// GitHub 仓库 owner + pub github_owner: String, + /// GitHub 仓库名 + pub github_repo: String, + /// 平台文件名映射 + pub platform_binaries: PlatformBinaries, + /// 校验文件名(可选) + #[serde(default)] + pub checksum_file: Option, +} + +/// 二进制组件状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinaryComponentStatus { + /// 组件名称 + pub name: String, + /// 是否已安装 + pub installed: bool, + /// 已安装版本 + pub installed_version: Option, + /// 最新可用版本 + pub latest_version: Option, + /// 是否有更新 + pub has_update: bool, + /// 二进制文件路径 + pub binary_path: Option, + /// 安装时间 + pub installed_at: Option, + /// 描述 + pub description: Option, } /// 插件上下文 - 传递给钩子函数的上下文信息 diff --git a/src-tauri/src/server/handlers/credentials_api.rs b/src-tauri/src/server/handlers/credentials_api.rs new file mode 100644 index 000000000..a91a7f7cf --- /dev/null +++ b/src-tauri/src/server/handlers/credentials_api.rs @@ -0,0 +1,288 @@ +//! 凭证 API 端点(用于 aster Agent 集成) +//! +//! 为 aster 子进程提供凭证查询接口,支持所有 11 种 Provider 类型。 +//! 此 API 仅供内部使用,返回完整的凭证信息(包括未脱敏的 access_token)。 + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::dao::provider_pool::ProviderPoolDao; +use crate::models::provider_pool_model::PoolProviderType; +use crate::server::AppState; + +/// 选择凭证请求参数 +#[derive(Debug, Deserialize)] +pub struct SelectCredentialRequest { + /// Provider 类型(kiro, gemini, qwen, openai, claude, etc.) + pub provider_type: String, + /// 指定模型(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// 凭证信息响应 +#[derive(Debug, Serialize)] +pub struct CredentialResponse { + /// 凭证 UUID + pub uuid: String, + /// Provider 类型 + pub provider_type: String, + /// Access Token(完整,未脱敏) + pub access_token: String, + /// Base URL + pub base_url: String, + /// Token 过期时间 + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + /// 凭证名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// API 错误响应 +#[derive(Debug, Serialize)] +pub struct CredentialApiError { + pub error: String, + pub message: String, + pub status_code: u16, +} + +impl IntoResponse for CredentialApiError { + fn into_response(self) -> Response { + let status = + StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Json(self)).into_response() + } +} + +/// POST /v1/credentials/select - 选择可用凭证 +pub async fn credentials_select( + State(state): State, + _headers: HeaderMap, + Json(request): Json, +) -> Result, CredentialApiError> { + tracing::info!( + "[CREDENTIALS_API] 选择凭证请求: provider_type={}, model={:?}", + request.provider_type, + request.model + ); + + let db = state.db.as_ref().ok_or_else(|| CredentialApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + // 使用 ProviderPoolService 智能选择凭证 + let credential = state + .pool_service + .select_credential(db, &request.provider_type, request.model.as_deref()) + .map_err(|e| CredentialApiError { + error: "selection_error".to_string(), + message: format!("凭证选择失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| CredentialApiError { + error: "no_available_credentials".to_string(), + message: format!("没有可用的 {} 凭证", request.provider_type), + status_code: 503, + })?; + + // 获取 access_token + let access_token = credential + .cached_token + .as_ref() + .and_then(|cache| cache.access_token.clone()) + .ok_or_else(|| CredentialApiError { + error: "no_cached_token".to_string(), + message: "凭证没有缓存的 Token".to_string(), + status_code: 503, + })?; + + // 根据 Provider 类型确定 base_url + let base_url = match credential.provider_type { + PoolProviderType::Kiro => "https://api.anthropic.com".to_string(), + PoolProviderType::Gemini => "https://generativelanguage.googleapis.com".to_string(), + PoolProviderType::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1".to_string(), + PoolProviderType::Antigravity => "https://api.anthropic.com".to_string(), + PoolProviderType::Vertex => "https://vertex-ai.googleapis.com".to_string(), + PoolProviderType::GeminiApiKey => "https://generativelanguage.googleapis.com".to_string(), + PoolProviderType::Codex => "https://api.openai.com/v1".to_string(), + PoolProviderType::ClaudeOAuth => "https://api.anthropic.com".to_string(), + PoolProviderType::IFlow => "https://chat.iflyrec.com".to_string(), + _ => { + return Err(CredentialApiError { + error: "unsupported_provider".to_string(), + message: format!("不支持的 Provider 类型: {:?}", credential.provider_type), + status_code: 400, + }) + } + }; + + let response = CredentialResponse { + uuid: credential.uuid.clone(), + provider_type: credential.provider_type.to_string(), + access_token, + base_url, + expires_at: credential + .cached_token + .as_ref() + .and_then(|cache| cache.expiry_time), + name: credential.name.clone(), + }; + + tracing::info!( + "[CREDENTIALS_API] 凭证选择成功: {} ({})", + response.name.as_deref().unwrap_or("未命名"), + response.uuid + ); + + Ok(Json(response)) +} + +/// GET /v1/credentials/{uuid}/token - 获取指定凭证的 Token +pub async fn credentials_get_token( + State(state): State, + Path(uuid): Path, + _headers: HeaderMap, +) -> Result, CredentialApiError> { + tracing::info!("[CREDENTIALS_API] 获取凭证 Token: {}", uuid); + + let db = state.db.as_ref().ok_or_else(|| CredentialApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + // 查询凭证 + let credential = { + let conn = db.lock().map_err(|e| CredentialApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + ProviderPoolDao::get_by_uuid(&conn, &uuid) + .map_err(|e| CredentialApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| CredentialApiError { + error: "credential_not_found".to_string(), + message: format!("未找到 UUID 为 {} 的凭证", uuid), + status_code: 404, + })? + }; + + // 如果 Token 即将过期,尝试刷新 + let cached_token = if let Some(cache) = &credential.cached_token { + if let Some(expiry_time) = cache.expiry_time { + let now = chrono::Utc::now(); + let time_until_expiry = expiry_time - now; + + // 如果距离过期不到 30 分钟,尝试刷新 + if time_until_expiry < chrono::Duration::minutes(30) { + tracing::info!("[CREDENTIALS_API] Token 即将过期,尝试刷新: {}", uuid); + match state + .token_cache + .refresh_and_cache_with_events( + db, + &uuid, + false, + Some(state.kiro_event_service.clone()), + ) + .await + { + Ok(new_token) => { + tracing::info!("[CREDENTIALS_API] Token 刷新成功: {}", uuid); + Some(new_token) + } + Err(e) => { + tracing::warn!("[CREDENTIALS_API] Token 刷新失败,使用现有 Token: {}", e); + cache.access_token.clone() + } + } + } else { + cache.access_token.clone() + } + } else { + cache.access_token.clone() + } + } else { + None + }; + + let access_token = cached_token.ok_or_else(|| CredentialApiError { + error: "no_cached_token".to_string(), + message: "凭证没有缓存的 Token".to_string(), + status_code: 503, + })?; + + // 根据 Provider 类型确定 base_url + let base_url = match credential.provider_type { + PoolProviderType::Kiro => "https://api.anthropic.com".to_string(), + PoolProviderType::Gemini => "https://generativelanguage.googleapis.com".to_string(), + PoolProviderType::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1".to_string(), + PoolProviderType::Antigravity => "https://api.anthropic.com".to_string(), + PoolProviderType::Vertex => "https://vertex-ai.googleapis.com".to_string(), + PoolProviderType::GeminiApiKey => "https://generativelanguage.googleapis.com".to_string(), + PoolProviderType::Codex => "https://api.openai.com/v1".to_string(), + PoolProviderType::ClaudeOAuth => "https://api.anthropic.com".to_string(), + PoolProviderType::IFlow => "https://chat.iflyrec.com".to_string(), + _ => { + return Err(CredentialApiError { + error: "unsupported_provider".to_string(), + message: format!("不支持的 Provider 类型: {:?}", credential.provider_type), + status_code: 400, + }) + } + }; + + // 重新查询凭证以获取更新后的 expires_at + let updated_credential = { + let conn = db.lock().map_err(|e| CredentialApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + ProviderPoolDao::get_by_uuid(&conn, &uuid) + .map_err(|e| CredentialApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| CredentialApiError { + error: "credential_not_found".to_string(), + message: format!("未找到 UUID 为 {} 的凭证", uuid), + status_code: 404, + })? + }; + + let response = CredentialResponse { + uuid: updated_credential.uuid.clone(), + provider_type: updated_credential.provider_type.to_string(), + access_token, + base_url, + expires_at: updated_credential + .cached_token + .as_ref() + .and_then(|cache| cache.expiry_time), + name: updated_credential.name.clone(), + }; + + tracing::info!( + "[CREDENTIALS_API] 返回凭证 Token: {} ({})", + response.name.as_deref().unwrap_or("未命名"), + response.uuid + ); + + Ok(Json(response)) +} diff --git a/src-tauri/src/server/handlers/mod.rs b/src-tauri/src/server/handlers/mod.rs index 06343dca2..da731f254 100644 --- a/src-tauri/src/server/handlers/mod.rs +++ b/src-tauri/src/server/handlers/mod.rs @@ -3,12 +3,14 @@ //! 将 server 中的各类处理器拆分到独立文件 pub mod api; +pub mod credentials_api; pub mod kiro_credential; pub mod management; pub mod provider_calls; pub mod websocket; pub use api::*; +pub use credentials_api::*; pub use kiro_credential::*; pub use management::*; pub use provider_calls::*; diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index c3533b967..4956c602f 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -836,6 +836,14 @@ async fn run_server( get(handlers::get_credential_status), ); + // 凭证 API 路由(用于 aster Agent 集成) + let credentials_api_routes = Router::new() + .route("/v1/credentials/select", post(handlers::credentials_select)) + .route( + "/v1/credentials/:uuid/token", + get(handlers::credentials_get_token), + ); + let app = Router::new() .route("/health", get(health)) .route("/v1/models", get(models)) @@ -876,6 +884,8 @@ async fn run_server( .merge(management_routes) // Kiro凭证管理API路由 .merge(kiro_api_routes) + // 凭证 API 路由(用于 aster Agent 集成) + .merge(credentials_api_routes) .layer(DefaultBodyLimit::max(body_limit)) .with_state(state); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 95616ff0a..824cd835a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.20.4", + "version": "0.21.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/aster_client_tests.rs b/src-tauri/tests/aster_client_tests.rs new file mode 100644 index 000000000..a2b0ff97b --- /dev/null +++ b/src-tauri/tests/aster_client_tests.rs @@ -0,0 +1,183 @@ +//! aster_client 模块测试 +//! +//! 测试 AsterClient 的序列化、反序列化和 URL 格式 + +use proxycast_lib::agent::{ + AsterClient, ChatRequest, ChatResponse, CreateAgentData, CreateAgentRequest, + CreateAgentResponse, ModelConfig, SendToAgentRequest, SendToAgentResponse, +}; + +#[test] +fn test_aster_client_creation() { + let client = AsterClient::new("http://127.0.0.1:8081".to_string()); + assert!(client.is_ok()); +} + +#[test] +fn test_model_config_serialization() { + let config = ModelConfig { + provider: Some("gateway".to_string()), + model: Some("claude-opus-4-5-20251101".to_string()), + api_key: Some("test-key".to_string()), + base_url: Some("http://127.0.0.1:8999".to_string()), + }; + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("gateway")); + assert!(json.contains("claude-opus-4-5-20251101")); + assert!(json.contains("test-key")); + assert!(json.contains("http://127.0.0.1:8999")); +} + +#[test] +fn test_model_config_skip_none() { + let config = ModelConfig { + provider: Some("gateway".to_string()), + model: None, + api_key: None, + base_url: None, + }; + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("gateway")); + assert!(!json.contains("\"model\"")); + assert!(!json.contains("api_key")); + assert!(!json.contains("base_url")); +} + +#[test] +fn test_create_agent_request_serialization() { + let config = ModelConfig { + provider: Some("gateway".to_string()), + model: Some("claude-opus-4-5-20251101".to_string()), + api_key: Some("test-key".to_string()), + base_url: Some("http://127.0.0.1:8999".to_string()), + }; + + let request = CreateAgentRequest { + template_id: "chat".to_string(), + name: None, + model_config: Some(config), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("template_id")); + assert!(json.contains("chat")); + assert!(json.contains("model_config")); + assert!(json.contains("gateway")); + assert!(!json.contains("\"name\"")); // name is None, should be skipped +} + +#[test] +fn test_chat_request_serialization() { + let config = ModelConfig { + provider: Some("gateway".to_string()), + model: Some("claude-opus-4-5-20251101".to_string()), + api_key: Some("test-key".to_string()), + base_url: Some("http://127.0.0.1:8999".to_string()), + }; + + let request = ChatRequest { + template_id: "chat".to_string(), + input: "Hello, world!".to_string(), + model_config: Some(config), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("template_id")); + assert!(json.contains("chat")); + assert!(json.contains("input")); + assert!(json.contains("Hello, world!")); + assert!(json.contains("model_config")); +} + +#[test] +fn test_send_to_agent_request_serialization() { + let request = SendToAgentRequest { + message: "Test message".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("message")); + assert!(json.contains("Test message")); +} + +#[test] +fn test_create_agent_response_deserialization() { + let json = r#"{"data": {"id": "agt-12345"}, "success": true}"#; + let response: CreateAgentResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.data.id, "agt-12345"); + assert!(response.success); +} + +#[test] +fn test_chat_response_deserialization() { + let json = r#"{ + "agent_id": "agt-12345", + "output": "Hello!", + "text": "Hello!", + "status": "ok", + "success": true + }"#; + let response: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.agent_id, "agt-12345"); + assert_eq!(response.output, "Hello!"); + assert_eq!(response.text, "Hello!"); + assert_eq!(response.status, "ok"); + assert!(response.success); +} + +#[test] +fn test_chat_response_with_empty_output() { + let json = r#"{ + "agent_id": "agt-12345", + "status": "ok", + "success": true + }"#; + let response: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.agent_id, "agt-12345"); + assert_eq!(response.output, ""); // default value + assert_eq!(response.text, ""); // default value + assert!(response.success); +} + +#[test] +fn test_send_to_agent_response_deserialization() { + let json = r#"{"text": "Response text", "success": true}"#; + let response: SendToAgentResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.text, "Response text"); + assert!(response.success); +} + +#[test] +fn test_send_to_agent_response_with_empty_text() { + let json = r#"{"success": true}"#; + let response: SendToAgentResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.text, ""); // default value + assert!(response.success); +} + +#[test] +fn test_url_format_create_agent() { + let base_url = "http://127.0.0.1:8081"; + let expected_url = "http://127.0.0.1:8081/v1/agents"; + let actual_url = format!("{}/v1/agents", base_url); + assert_eq!(actual_url, expected_url); +} + +#[test] +fn test_url_format_send_to_agent() { + let base_url = "http://127.0.0.1:8081"; + let agent_id = "agt-12345"; + let expected_url = "http://127.0.0.1:8081/v1/agents/agt-12345/send"; + let actual_url = format!("{}/v1/agents/{}/send", base_url, agent_id); + assert_eq!(actual_url, expected_url); +} + +#[test] +fn test_url_format_chat() { + let base_url = "http://127.0.0.1:8081"; + let expected_url = "http://127.0.0.1:8081/v1/agents/chat"; + let actual_url = format!("{}/v1/agents/chat", base_url); + assert_eq!(actual_url, expected_url); +} diff --git a/src/App.tsx b/src/App.tsx index 1fc36a696..7e14cd8ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { FlowMonitorPage } from "./pages"; import { ToolsPage } from "./components/tools/ToolsPage"; import { BrowserInterceptorTool } from "./components/tools/browser-interceptor/BrowserInterceptorTool"; import { MachineIdTool } from "./components/tools/machine-id/MachineIdTool"; +import { AgentChatPage } from "./components/agent"; import { Toaster } from "./components/ui/sonner"; import { flowEventManager } from "./lib/flowEventManager"; @@ -16,6 +17,7 @@ type Page = | "config-management" | "api-server" | "flow-monitor" + | "agent" | "tools" | "browser-interceptor" | "machine-id" @@ -30,6 +32,14 @@ function App() { // 应用卸载时不取消订阅,因为这是全局订阅 }, []); + // 页面切换时重置滚动位置 + useEffect(() => { + const mainElement = document.querySelector("main"); + if (mainElement) { + mainElement.scrollTop = 0; + } + }, [currentPage]); + const renderPage = () => { switch (currentPage) { case "provider-pool": @@ -40,6 +50,8 @@ function App() { return ; case "flow-monitor": return ; + case "agent": + return ; case "tools": return ; case "browser-interceptor": diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fdadac802..71e507513 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { FileCode, Activity, Wrench, + Bot, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -13,6 +14,7 @@ type Page = | "config-management" | "api-server" | "flow-monitor" + | "agent" | "tools" | "browser-interceptor" | "machine-id" @@ -28,6 +30,7 @@ const navItems = [ { id: "provider-pool" as Page, label: "凭证池", icon: Database }, { id: "config-management" as Page, label: "配置管理", icon: FileCode }, { id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity }, + { id: "agent" as Page, label: "AI Agent", icon: Bot }, { id: "tools" as Page, label: "工具", icon: Wrench }, { id: "settings" as Page, label: "设置", icon: Settings }, ]; diff --git a/src/components/agent/AgentChatPage.tsx b/src/components/agent/AgentChatPage.tsx new file mode 100644 index 000000000..43c4f5b37 --- /dev/null +++ b/src/components/agent/AgentChatPage.tsx @@ -0,0 +1,1108 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { toast } from "sonner"; +import { + Play, + Square, + Send, + Loader2, + MessageSquare, + Bot, + User, + ChevronDown, + ChevronUp, + Settings2, + Monitor, + Maximize, + Trash2, + X, + Plus, + History, + Download, +} from "lucide-react"; +import { + startAgentProcess, + stopAgentProcess, + getAgentProcessStatus, + createAgentSession, + sendAgentMessage, + type AgentProcessStatus, + type CreateSessionResponse, +} from "@/lib/api/agent"; +import { + isAsterInstalled, + installAster, + type DownloadProgress, +} from "@/lib/api/binary"; +import { listen } from "@tauri-apps/api/event"; +import { windowApi, type WindowSizeOption } from "@/lib/api/window"; +import { cn } from "@/lib/utils"; + +interface MessageImage { + data: string; // base64 encoded image + mediaType: string; // e.g., "image/png" +} + +interface Message { + role: "user" | "assistant"; + content: string; + images?: MessageImage[]; + timestamp: Date; +} + +// Provider 配置,包含推荐模型 +const PROVIDER_CONFIG: Record = { + claude: { + label: "Claude", + models: [ + "claude-opus-4-5-20251101", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + ], + }, + kiro: { + label: "Kiro", + models: [ + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-3-5-sonnet-20241022", + ], + }, + openai: { + label: "OpenAI", + models: [ + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "o1", + "o1-mini", + "o3", + "o3-mini", + ], + }, + gemini: { + label: "Gemini", + models: ["gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash"], + }, + qwen: { + label: "通义千问", + models: ["qwen-max", "qwen-plus", "qwen-turbo"], + }, + codex: { + label: "Codex", + models: ["codex-mini-latest"], + }, + claude_oauth: { + label: "Claude OAuth", + models: ["claude-sonnet-4-5-20250929", "claude-3-5-sonnet-20241022"], + }, + iflow: { + label: "iFlow", + models: [], + }, + antigravity: { + label: "Antigravity", + models: [ + "gemini-claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking", + "gemini-claude-opus-4-5-thinking", + ], + }, +}; + +// 历史会话类型 +interface ChatSession { + id: string; + title: string; + providerType: string; + model: string; + messages: Message[]; + createdAt: Date; + updatedAt: Date; +} + +// 从 sessionStorage 恢复状态的辅助函数 +const loadFromStorage = (key: string, defaultValue: T): T => { + try { + const stored = sessionStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + // 特殊处理 messages,恢复 Date 对象 + if (key === "agent_messages" && Array.isArray(parsed)) { + return parsed.map((msg: Message & { timestamp: string }) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })) as T; + } + return parsed; + } + } catch (e) { + console.error(`Failed to load ${key} from storage:`, e); + } + return defaultValue; +}; + +// 保存到 sessionStorage 的辅助函数 +const saveToStorage = (key: string, value: unknown) => { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error(`Failed to save ${key} to storage:`, e); + } +}; + +// 从 localStorage 加载历史会话 +const loadChatHistory = (): ChatSession[] => { + try { + const stored = localStorage.getItem("agent_chat_history"); + if (stored) { + const sessions = JSON.parse(stored); + return sessions.map( + ( + s: ChatSession & { + createdAt: string; + updatedAt: string; + messages: (Message & { timestamp: string })[]; + }, + ) => ({ + ...s, + createdAt: new Date(s.createdAt), + updatedAt: new Date(s.updatedAt), + messages: s.messages.map((m) => ({ + ...m, + timestamp: new Date(m.timestamp), + })), + }), + ); + } + } catch (e) { + console.error("Failed to load chat history:", e); + } + return []; +}; + +// 保存历史会话到 localStorage +const saveChatHistory = (sessions: ChatSession[]) => { + try { + localStorage.setItem("agent_chat_history", JSON.stringify(sessions)); + } catch (e) { + console.error("Failed to save chat history:", e); + } +}; + +export function AgentChatPage() { + const [processStatus, setProcessStatus] = useState({ + running: false, + }); + const [isStarting, setIsStarting] = useState(false); + const [isStopping, setIsStopping] = useState(false); + + // 从 sessionStorage 恢复会话状态 + const [sessionId, setSessionId] = useState(() => + loadFromStorage("agent_sessionId", null), + ); + const [sessionInfo, setSessionInfo] = useState( + () => loadFromStorage("agent_sessionInfo", null), + ); + const [providerType, setProviderType] = useState(() => + loadFromStorage("agent_providerType", "claude"), + ); + const [model, setModel] = useState(() => loadFromStorage("agent_model", "")); + const [isCreatingSession, setIsCreatingSession] = useState(false); + + // 从 sessionStorage 恢复消息 + const [messages, setMessages] = useState(() => + loadFromStorage("agent_messages", []), + ); + const [inputMessage, setInputMessage] = useState(""); + const [isSending, setIsSending] = useState(false); + + // 待发送的图片 + const [pendingImages, setPendingImages] = useState([]); + + // 历史会话 + const [chatHistory, setChatHistory] = useState(() => + loadChatHistory(), + ); + const [currentSessionId, setCurrentSessionId] = useState(() => + loadFromStorage("agent_currentSessionId", null), + ); + const [showHistory, setShowHistory] = useState(false); + + // 控制面板折叠状态 - 如果有会话则默认折叠 + const [isControlOpen, setIsControlOpen] = useState( + () => !loadFromStorage("agent_sessionId", null), + ); + + // 窗口大小状态 + const [windowSizeOptions, setWindowSizeOptions] = useState< + WindowSizeOption[] + >([]); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showWindowMenu, setShowWindowMenu] = useState(false); + + // aster-server 安装状态 + const [asterInstalled, setAsterInstalled] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [installProgress, setInstallProgress] = + useState(null); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // 获取当前 Provider 的推荐模型 + const currentProviderModels = PROVIDER_CONFIG[providerType]?.models || []; + + // 自动滚动到底部 + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // 保存状态到 sessionStorage + useEffect(() => { + saveToStorage("agent_sessionId", sessionId); + }, [sessionId]); + + useEffect(() => { + saveToStorage("agent_sessionInfo", sessionInfo); + }, [sessionInfo]); + + useEffect(() => { + saveToStorage("agent_providerType", providerType); + }, [providerType]); + + useEffect(() => { + saveToStorage("agent_model", model); + }, [model]); + + useEffect(() => { + saveToStorage("agent_messages", messages); + }, [messages]); + + // 加载进程状态和检查安装状态 + useEffect(() => { + loadProcessStatus(); + // 检查 aster-server 是否已安装 + isAsterInstalled() + .then(setAsterInstalled) + .catch(() => setAsterInstalled(false)); + + // 监听下载进度事件 + const unlisten = listen( + "binary-download-progress", + (event) => { + setInstallProgress(event.payload); + }, + ); + + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + // 快速安装 aster-server + const handleQuickInstall = async () => { + setIsInstalling(true); + setInstallProgress(null); + try { + await installAster(); + toast.success("aster-server 安装成功"); + setAsterInstalled(true); + } catch (error) { + toast.error(`安装失败: ${error}`); + } finally { + setIsInstalling(false); + setInstallProgress(null); + } + }; + + // 当 Provider 改变时,重置模型选择(但不在初始加载时触发) + const isInitialMount = useRef(true); + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + setModel(""); + }, [providerType]); + + // 初始化窗口大小选项 + useEffect(() => { + const loadWindowOptions = async () => { + try { + const options = await windowApi.getWindowSizeOptions(); + setWindowSizeOptions(options); + const fullscreen = await windowApi.isFullscreen(); + setIsFullscreen(fullscreen); + } catch (error) { + console.error("加载窗口选项失败:", error); + } + }; + loadWindowOptions(); + }, []); + + // 点击外部关闭窗口菜单和历史会话菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (showWindowMenu && !target.closest(".window-menu-container")) { + setShowWindowMenu(false); + } + if (showHistory && !target.closest(".history-menu-container")) { + setShowHistory(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showWindowMenu, showHistory]); + + // 当会话创建成功后,自动折叠控制面板 + useEffect(() => { + if (sessionId) { + setIsControlOpen(false); + } + }, [sessionId]); + + const loadProcessStatus = async () => { + try { + const status = await getAgentProcessStatus(); + setProcessStatus(status); + } catch (error) { + console.error("获取进程状态失败:", error); + } + }; + + const handleStartProcess = async () => { + setIsStarting(true); + try { + const status = await startAgentProcess(); + setProcessStatus(status); + toast.success("aster 进程启动成功"); + } catch (error) { + toast.error(`启动失败: ${error}`); + } finally { + setIsStarting(false); + } + }; + + const handleStopProcess = async () => { + setIsStopping(true); + try { + await stopAgentProcess(); + setProcessStatus({ running: false }); + setSessionId(null); + setSessionInfo(null); + setMessages([]); + toast.success("aster 进程已停止"); + } catch (error) { + toast.error(`停止失败: ${error}`); + } finally { + setIsStopping(false); + } + }; + + const handleCreateSession = async () => { + if (!processStatus.running) { + toast.error("请先启动 aster 进程"); + return; + } + + setIsCreatingSession(true); + try { + const response = await createAgentSession( + providerType, + model || undefined, + ); + setSessionId(response.session_id); + setSessionInfo(response); + setMessages([]); + toast.success(`会话创建成功,使用凭证: ${response.credential_name}`); + } catch (error) { + toast.error(`创建会话失败: ${error}`); + } finally { + setIsCreatingSession(false); + } + }; + + const handleSendMessage = async () => { + if (!sessionId || (!inputMessage.trim() && pendingImages.length === 0)) + return; + + // 检查是否有图片 + const hasImages = pendingImages.length > 0; + + const userMessage: Message = { + role: "user", + content: inputMessage, + images: hasImages ? [...pendingImages] : undefined, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInputMessage(""); + setPendingImages([]); + setIsSending(true); + + try { + // 准备图片数据(转换为 API 格式) + const imagesToSend = hasImages + ? userMessage.images?.map((img) => ({ + data: img.data, + media_type: img.mediaType, + })) + : undefined; + + const response = await sendAgentMessage( + inputMessage || "", + model || undefined, + imagesToSend, + ); + const assistantMessage: Message = { + role: "assistant", + content: response || "(无响应)", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + } catch (error) { + toast.error(`发送消息失败: ${error}`); + } finally { + setIsSending(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + // 处理粘贴事件(支持图片) + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + // 移除 data:image/xxx;base64, 前缀 + const base64Data = base64.split(",")[1]; + const mediaType = item.type; + setPendingImages((prev) => [ + ...prev, + { data: base64Data, mediaType }, + ]); + toast.success("图片已添加"); + }; + reader.readAsDataURL(file); + } + break; + } + } + }; + + // 移除待发送的图片 + const handleRemoveImage = (index: number) => { + setPendingImages((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleClearMessages = () => { + setMessages([]); + toast.success("对话已清空"); + }; + + // 保存当前会话到历史 + const saveCurrentSession = useCallback(() => { + if (messages.length === 0) return; + + const now = new Date(); + const title = messages[0]?.content?.slice(0, 30) || "新会话"; + + if (currentSessionId) { + // 更新现有会话 + setChatHistory((prev) => { + const updated = prev.map((s) => + s.id === currentSessionId + ? { ...s, messages, updatedAt: now, title } + : s, + ); + saveChatHistory(updated); + return updated; + }); + } else { + // 创建新会话 + const newSession: ChatSession = { + id: crypto.randomUUID(), + title, + providerType, + model, + messages, + createdAt: now, + updatedAt: now, + }; + setCurrentSessionId(newSession.id); + saveToStorage("agent_currentSessionId", newSession.id); + setChatHistory((prev) => { + const updated = [newSession, ...prev]; + saveChatHistory(updated); + return updated; + }); + } + }, [messages, currentSessionId, providerType, model]); + + // 当消息变化时自动保存 + useEffect(() => { + if (messages.length > 0) { + saveCurrentSession(); + } + }, [messages, saveCurrentSession]); + + // 加载历史会话 + const handleLoadSession = (session: ChatSession) => { + setMessages(session.messages); + setProviderType(session.providerType); + setModel(session.model); + setCurrentSessionId(session.id); + saveToStorage("agent_currentSessionId", session.id); + setShowHistory(false); + toast.success("已加载会话"); + }; + + // 删除历史会话 + const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setChatHistory((prev) => { + const updated = prev.filter((s) => s.id !== sessionId); + saveChatHistory(updated); + return updated; + }); + if (currentSessionId === sessionId) { + setCurrentSessionId(null); + setMessages([]); + saveToStorage("agent_currentSessionId", null); + } + toast.success("会话已删除"); + }; + + // 创建新会话 + const handleNewChat = () => { + setMessages([]); + setCurrentSessionId(null); + saveToStorage("agent_currentSessionId", null); + setShowHistory(false); + }; + + const handleSetWindowSize = async (optionId: string) => { + try { + await windowApi.setWindowSizeByOption(optionId); + setShowWindowMenu(false); + } catch (error) { + console.error("设置窗口大小失败:", error); + } + }; + + const handleToggleFullscreen = async () => { + try { + const newFullscreenState = await windowApi.toggleFullscreen(); + setIsFullscreen(newFullscreenState); + setShowWindowMenu(false); + } catch (error) { + console.error("切换全屏模式失败:", error); + } + }; + + // 如果 aster-server 未安装,显示提示 + if (asterInstalled === false) { + return ( +
+
+
+

+ + AI Agent +

+

+ 基于 aster 框架的 AI Agent 对话 +

+
+
+ + + +

aster-server 未安装

+

+ AI Agent 功能需要安装 aster-server 组件才能使用 +

+ + {/* 下载进度 */} + {installProgress && ( +
+
+ 下载中... + {installProgress.percentage.toFixed(1)}% +
+ +

+ {(installProgress.downloaded / 1024 / 1024).toFixed(1)} MB /{" "} + {(installProgress.total / 1024 / 1024).toFixed(1)} MB +

+
+ )} + + {/* 安装按钮 */} + + +

+ 或前往 扩展 → 插件{" "} + 页面管理组件 +

+
+
+
+ ); + } + + return ( +
+ {/* 页面头部 */} +
+
+

+ + AI Agent +

+

+ 基于 aster 框架的 AI Agent 对话 +

+
+
+ {/* 状态指示 */} + + {processStatus.running ? "运行中" : "已停止"} + + {sessionInfo && ( + + {sessionInfo.credential_name} + + )} + + {/* 新建对话 */} + + + {/* 历史会话 */} +
+ + + {showHistory && ( +
+
+
历史会话
+
+ {chatHistory.length === 0 ? ( +
+ 暂无历史会话 +
+ ) : ( +
+ {chatHistory.map((session) => ( +
handleLoadSession(session)} + className={cn( + "flex items-center justify-between p-2 rounded cursor-pointer hover:bg-accent group", + currentSessionId === session.id && "bg-accent", + )} + > +
+
+ {session.title || "新会话"} +
+
+ {session.messages.length} 条消息 ·{" "} + {session.updatedAt.toLocaleDateString()} +
+
+ +
+ ))} +
+ )} +
+ )} +
+ + {/* 窗口大小调整 */} +
+ + + {showWindowMenu && ( +
+
+ 窗口大小 +
+ {windowSizeOptions.map((option) => ( + + ))} +
+ +
+ )} +
+
+
+ + {/* 可折叠的控制面板 */} + + + + + + + + {/* 进程控制 */} +
+ + + + {processStatus.running && processStatus.base_url && ( + + {processStatus.base_url} + + )} +
+ + {/* 会话配置 */} +
+ + + + {currentProviderModels.length > 0 ? ( + + ) : ( + setModel(e.target.value)} + placeholder="模型名称" + className="w-48" + /> + )} + + +
+
+
+
+
+ + {/* 对话区域 - 占据剩余空间 */} + + {/* 对话区域头部 */} + {messages.length > 0 && ( +
+ + {messages.length} 条消息 + + +
+ )} + +
+ {messages.length === 0 ? ( +
+ +

{sessionId ? "开始对话吧" : "创建会话后开始对话"}

+
+ ) : ( + messages.map((message, index) => ( +
+ {message.role === "assistant" && ( +
+
+ +
+
+ )} + +
+ {/* 显示图片 */} + {message.images && message.images.length > 0 && ( +
+ {message.images.map((img, imgIndex) => ( + {`图片 { + // 点击放大查看 + window.open( + `data:${img.mediaType};base64,${img.data}`, + "_blank", + ); + }} + /> + ))} +
+ )} + {message.content && ( +

{message.content}

+ )} +

+ {message.timestamp.toLocaleTimeString()} +

+
+ + {message.role === "user" && ( +
+
+ +
+
+ )} +
+ )) + )} +
+
+ + + {/* 输入区域 */} +
+ {/* 待发送图片预览 */} + {pendingImages.length > 0 && ( +
+ {pendingImages.map((img, index) => ( +
+ {`待发送图片 + +
+ ))} +
+ )} +
+ setInputMessage(e.target.value)} + onKeyPress={handleKeyPress} + onPaste={handlePaste} + placeholder={sessionId ? "输入消息或粘贴图片..." : "请先创建会话"} + disabled={!sessionId || isSending} + className="flex-1" + /> + +
+
+ +
+ ); +} diff --git a/src/components/agent/index.ts b/src/components/agent/index.ts new file mode 100644 index 000000000..e17002fd4 --- /dev/null +++ b/src/components/agent/index.ts @@ -0,0 +1 @@ +export { AgentChatPage } from "./AgentChatPage"; diff --git a/src/components/extensions/BinaryComponents.tsx b/src/components/extensions/BinaryComponents.tsx new file mode 100644 index 000000000..97967d350 --- /dev/null +++ b/src/components/extensions/BinaryComponents.tsx @@ -0,0 +1,247 @@ +/** + * 二进制组件管理 UI + * + * 显示和管理 aster-server 等二进制组件 + */ + +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + Download, + Trash2, + RefreshCw, + CheckCircle, + Loader2, + Bot, + HardDrive, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + BinaryComponentStatus, + DownloadProgress, + getAsterStatus, + installAster, + uninstallAster, + updateAster, +} from "@/lib/api/binary"; + +export function BinaryComponents() { + const [asterStatus, setAsterStatus] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [installing, setInstalling] = useState(false); + const [uninstalling, setUninstalling] = useState(false); + const [downloadProgress, setDownloadProgress] = + useState(null); + + const fetchStatus = async () => { + try { + setLoading(true); + const status = await getAsterStatus(); + setAsterStatus(status); + } catch (error) { + console.error("获取状态失败:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + + // 监听下载进度事件 + const unlisten = listen( + "binary-download-progress", + (event) => { + setDownloadProgress(event.payload); + }, + ); + + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + const handleInstall = async () => { + setInstalling(true); + setDownloadProgress(null); + try { + const result = await installAster(); + toast.success(result); + await fetchStatus(); + } catch (error) { + toast.error(`安装失败: ${error}`); + } finally { + setInstalling(false); + setDownloadProgress(null); + } + }; + + const handleUninstall = async () => { + if (!confirm("确定要卸载 aster-server 吗?这将停止所有 Agent 功能。")) { + return; + } + setUninstalling(true); + try { + const result = await uninstallAster(); + toast.success(result); + await fetchStatus(); + } catch (error) { + toast.error(`卸载失败: ${error}`); + } finally { + setUninstalling(false); + } + }; + + const handleUpdate = async () => { + setInstalling(true); + setDownloadProgress(null); + try { + const result = await updateAster(); + toast.success(result); + await fetchStatus(); + } catch (error) { + toast.error(`更新失败: ${error}`); + } finally { + setInstalling(false); + setDownloadProgress(null); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( +
+
+

+ + 二进制组件 +

+ +
+ + + +
+
+ +
+ aster-server +

+ {asterStatus?.description || + "AI Agent 框架 - 提供 Agent 对话能力"} +

+
+
+
+ {asterStatus?.installed ? ( + + + 已安装 + + ) : ( + 未安装 + )} + {asterStatus?.has_update && ( + 有更新 + )} +
+
+
+ + {/* 版本信息 */} +
+
+ 已安装版本: + + {asterStatus?.installed_version || "-"} + +
+
+ 最新版本: + + {asterStatus?.latest_version || "-"} + +
+
+ + {/* 下载进度 */} + {downloadProgress && ( +
+
+ 下载中... + {downloadProgress.percentage.toFixed(1)}% +
+ +

+ {(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB /{" "} + {(downloadProgress.total / 1024 / 1024).toFixed(1)} MB +

+
+ )} + + {/* 操作按钮 */} +
+ {!asterStatus?.installed ? ( + + ) : ( + <> + {asterStatus?.has_update && ( + + )} + + + )} +
+ + {/* 安装时间 */} + {asterStatus?.installed_at && ( +

+ 安装时间:{new Date(asterStatus.installed_at).toLocaleString()} +

+ )} +
+
+
+ ); +} diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx index 048ca2aa9..84621a3b9 100644 --- a/src/components/plugins/PluginManager.tsx +++ b/src/components/plugins/PluginManager.tsx @@ -13,6 +13,7 @@ import { ChevronDown, ChevronUp, } from "lucide-react"; +import { BinaryComponents } from "@/components/extensions/BinaryComponents"; interface PluginState { name: string; @@ -149,6 +150,9 @@ export function PluginManager() { return (
+ {/* 二进制组件 */} + + {/* 状态概览 */}
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..5c28cbcc3 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts new file mode 100644 index 000000000..016bb3add --- /dev/null +++ b/src/lib/api/agent.ts @@ -0,0 +1,123 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Agent 进程状态 + */ +export interface AgentProcessStatus { + running: boolean; + base_url?: string; + port?: number; +} + +/** + * 创建会话响应 + */ +export interface CreateSessionResponse { + session_id: string; + credential_name: string; + credential_uuid: string; + provider_type: string; + model?: string; +} + +/** + * 会话信息 + */ +export interface SessionInfo { + session_id: string; + provider_type: string; + model?: string; + created_at: string; + last_activity: string; + messages_count: number; +} + +/** + * 图片输入 + */ +export interface ImageInput { + data: string; // base64 encoded image data + media_type: string; // e.g., "image/png" +} + +/** + * 启动 Agent 进程 + */ +export async function startAgentProcess( + asterBinaryPath?: string, + port?: number, + credentialsEndpoint?: string, +): Promise { + return await invoke("agent_start_process", { + asterBinaryPath, + port, + credentialsEndpoint, + }); +} + +/** + * 停止 Agent 进程 + */ +export async function stopAgentProcess(): Promise { + return await invoke("agent_stop_process"); +} + +/** + * 获取 Agent 进程状态 + */ +export async function getAgentProcessStatus(): Promise { + return await invoke("agent_get_process_status"); +} + +/** + * 创建 Agent 会话 + */ +export async function createAgentSession( + providerType: string, + model?: string, +): Promise { + return await invoke("agent_create_session", { + providerType, + model, + }); +} + +/** + * 发送消息到 Agent(使用同步 chat API) + */ +export async function sendAgentMessage( + message: string, + model?: string, + images?: ImageInput[], +): Promise { + return await invoke("agent_send_message", { + message, + images, + model, + }); +} + +/** + * 获取会话列表 + */ +export async function listAgentSessions(): Promise { + return await invoke("agent_list_sessions"); +} + +/** + * 获取会话详情 + */ +export async function getAgentSession(sessionId: string): Promise { + return await invoke("agent_get_session", { + sessionId, + }); +} + +/** + * 删除会话 + */ +export async function deleteAgentSession(sessionId: string): Promise { + return await invoke("agent_delete_session", { + sessionId, + }); +} diff --git a/src/lib/api/binary.ts b/src/lib/api/binary.ts new file mode 100644 index 000000000..361359b8d --- /dev/null +++ b/src/lib/api/binary.ts @@ -0,0 +1,92 @@ +/** + * 二进制组件管理 API + * + * 提供 aster-server 等二进制组件的安装、卸载、更新功能 + */ + +import { invoke } from "@tauri-apps/api/core"; + +/** + * 二进制组件状态 + */ +export interface BinaryComponentStatus { + /** 组件名称 */ + name: string; + /** 是否已安装 */ + installed: boolean; + /** 已安装版本 */ + installed_version: string | null; + /** 最新可用版本 */ + latest_version: string | null; + /** 是否有更新 */ + has_update: boolean; + /** 二进制文件路径 */ + binary_path: string | null; + /** 安装时间 */ + installed_at: string | null; + /** 描述 */ + description: string | null; +} + +/** + * 下载进度事件 + */ +export interface DownloadProgress { + /** 组件名称 */ + component: string; + /** 已下载字节数 */ + downloaded: number; + /** 总字节数 */ + total: number; + /** 下载百分比 */ + percentage: number; +} + +/** + * 获取 aster-server 组件状态 + */ +export async function getAsterStatus(): Promise { + return invoke("get_aster_status"); +} + +/** + * 安装 aster-server 组件 + */ +export async function installAster(): Promise { + return invoke("install_aster"); +} + +/** + * 卸载 aster-server 组件 + */ +export async function uninstallAster(): Promise { + return invoke("uninstall_aster"); +} + +/** + * 检查 aster-server 更新 + */ +export async function checkAsterUpdate(): Promise { + return invoke("check_aster_update"); +} + +/** + * 更新 aster-server 组件 + */ +export async function updateAster(): Promise { + return invoke("update_aster"); +} + +/** + * 获取 aster-server 二进制文件路径 + */ +export async function getAsterBinaryPath(): Promise { + return invoke("get_aster_binary_path"); +} + +/** + * 检查 aster-server 是否已安装 + */ +export async function isAsterInstalled(): Promise { + return invoke("is_aster_installed"); +}