feat: 添加 AI Agent 功能和 aster-server 按需下载

主要功能:
- 新增 AI Agent 页面,支持多 Provider 对话
- aster-server 改为按需下载,不再打包在应用中
- 在 Extensions 页面添加二进制组件管理
- Agent 页面支持快速安装 aster-server

技术改进:
- 扩展 Plugin 类型,新增 Binary 类型
- 添加 GitHub Release 下载器
- 支持下载进度显示和 SHA256 校验
- 使用 gateway provider 通过 ProxyCast 代理请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2025-12-29 12:13:57 +08:00
co-authored by Claude Opus 4.5
parent 6dbe47cfed
commit 3f6479b1ad
32 changed files with 4580 additions and 11 deletions
+33 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+1 -1
View File
@@ -3668,7 +3668,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.20.5"
version = "0.21.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
/Users/coso/Documents/dev/ai/astercloud/aster/aster-server
+21 -1
View File
@@ -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
}
]
}
]
}
+1 -1
View File
@@ -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"]}}
{"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}]}]}}
+630
View File
@@ -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<String>,
/// 模型配置(包含 API Key)
#[serde(skip_serializing_if = "Option::is_none")]
pub model_config: Option<ModelConfig>,
}
/// 图片输入
#[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<String>,
/// 图片列表
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<ImageInput>>,
/// 模型配置
#[serde(skip_serializing_if = "Option::is_none")]
pub model_config: Option<ModelConfig>,
}
/// 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<String>,
/// 模型配置
#[serde(skip_serializing_if = "Option::is_none")]
pub model_config: Option<ModelConfig>,
}
/// 创建 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<String>,
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// API Key
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// Base URL(用于 gateway provider)
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
/// 创建会话响应
#[derive(Debug, Deserialize)]
pub struct CreateSessionResponse {
/// 会话 ID
pub session_id: String,
/// Provider 类型
pub provider_type: String,
/// 模型名称
pub model: Option<String>,
/// 创建时间
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<TokenUsage>,
}
/// 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<String>,
/// 创建时间
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<Self, String> {
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<String>,
api_key: Option<String>,
gateway_base_url: Option<String>,
) -> Result<CreateSessionResponse, String> {
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::<CreateSessionResponse>()
.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<String>,
api_key: String,
gateway_base_url: String,
) -> Result<ChatResponse, String> {
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<Vec<ImageInput>>,
model: Option<String>,
api_key: String,
gateway_base_url: String,
) -> Result<ChatResponse, String> {
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::<ChatResponse>(&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<String>,
api_key: String,
gateway_base_url: String,
) -> Result<CreateAgentResponse, String> {
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::<CreateAgentResponse>(&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<SendToAgentResponse, String> {
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::<SendToAgentResponse>()
.await
.map_err(|e| format!("解析响应失败: {}", e))
}
/// 发送消息(非流式)
///
/// # 参数
///
/// - `session_id`: 会话 ID
/// - `message`: 消息内容
pub async fn send_message(
&self,
session_id: &str,
message: &str,
) -> Result<SendMessageResponse, String> {
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::<SendMessageResponse>()
.await
.map_err(|e| format!("解析响应失败: {}", e))
}
/// 获取会话列表
pub async fn list_sessions(&self) -> Result<Vec<SessionInfo>, 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::<Vec<SessionInfo>>()
.await
.map_err(|e| format!("解析响应失败: {}", e))
}
/// 获取会话详情
///
/// # 参数
///
/// - `session_id`: 会话 ID
pub async fn get_session(&self, session_id: &str) -> Result<SessionInfo, String> {
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::<SessionInfo>()
.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<bool, String> {
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)),
}
}
}
+583
View File
@@ -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<RwLock<Option<CommandChild>>>,
/// 标准进程句柄(Plugin 模式)
std_child: Arc<RwLock<Option<std::process::Child>>>,
/// aster 服务基础 URL
base_url: String,
/// aster 服务端口
port: u16,
}
impl AsterProcess {
/// 获取 aster-server 二进制文件路径(从 plugin 目录)
pub fn get_binary_path() -> Result<PathBuf, String> {
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<Self, String> {
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<Self, String> {
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::<i32>() {
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::<u32>() {
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<RwLock<Option<AsterProcess>>>);
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<parking_lot::RwLockReadGuard<'_, Option<AsterProcess>>> {
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);
}
}
+18
View File
@@ -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};
+477
View File
@@ -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<String>,
/// aster 服务端口
pub port: Option<u16>,
}
/// 创建会话响应
#[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<String>,
}
/// 启动 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<u16>,
) -> Result<AgentProcessStatus, String> {
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<AgentProcessStatus, String> {
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<String>,
) -> Result<CreateSessionResponse, String> {
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<Vec<ImageInputParam>>,
model: Option<String>,
) -> Result<String, String> {
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<String>,
/// 创建时间
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<Vec<SessionInfo>, 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<SessionInfo, 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)?;
// 获取会话详情
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(())
}
+309
View File
@@ -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<u32> = installed
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
let latest_parts: Vec<u32> = 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<BinaryComponentStatus, String> {
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<String, String> {
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<String, String> {
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<BinaryComponentStatus, String> {
get_aster_status().await
}
/// 更新 aster-server 组件
#[tauri::command]
pub async fn update_aster(app_handle: AppHandle) -> Result<String, String> {
info!("开始更新 aster-server");
// 先卸载旧版本
uninstall_aster().await?;
// 安装新版本
install_aster(app_handle).await
}
/// 获取 aster-server 二进制文件路径
#[tauri::command]
pub fn get_aster_binary_path() -> Result<String, String> {
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"));
}
}
+2
View File
@@ -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;
+23
View File
@@ -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,
])
+313
View File
@@ -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<ReleaseAsset>), 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<F>(
&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<bool, String> {
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<HashMap<String, String>, 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<PathBuf, String> {
dirs::config_dir()
.ok_or_else(|| "无法获取配置目录".to_string())
.map(|p| p.join("proxycast").join("plugins"))
}
/// 获取特定组件的目录
pub fn get_component_dir(component_name: &str) -> Result<PathBuf, String> {
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"));
}
}
+3
View File
@@ -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(),
)),
}
}
+5 -2
View File
@@ -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)]
+73
View File
@@ -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<String>,
}
/// 二进制组件状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinaryComponentStatus {
/// 组件名称
pub name: String,
/// 是否已安装
pub installed: bool,
/// 已安装版本
pub installed_version: Option<String>,
/// 最新可用版本
pub latest_version: Option<String>,
/// 是否有更新
pub has_update: bool,
/// 二进制文件路径
pub binary_path: Option<String>,
/// 安装时间
pub installed_at: Option<String>,
/// 描述
pub description: Option<String>,
}
/// 插件上下文 - 传递给钩子函数的上下文信息
@@ -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<String>,
}
/// 凭证信息响应
#[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<DateTime<Utc>>,
/// 凭证名称
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// 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<AppState>,
_headers: HeaderMap,
Json(request): Json<SelectCredentialRequest>,
) -> Result<Json<CredentialResponse>, 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<AppState>,
Path(uuid): Path<String>,
_headers: HeaderMap,
) -> Result<Json<CredentialResponse>, 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))
}
+2
View File
@@ -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::*;
+10
View File
@@ -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);
+1 -1
View File
@@ -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",
+183
View File
@@ -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);
}
+12
View File
@@ -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 <ApiServerPage />;
case "flow-monitor":
return <FlowMonitorPage />;
case "agent":
return <AgentChatPage />;
case "tools":
return <ToolsPage onNavigate={setCurrentPage} />;
case "browser-interceptor":
+3
View File
@@ -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 },
];
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { AgentChatPage } from "./AgentChatPage";
@@ -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<BinaryComponentStatus | null>(
null,
);
const [loading, setLoading] = useState(true);
const [installing, setInstalling] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(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<DownloadProgress>(
"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 (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<HardDrive className="h-5 w-5" />
二进制组件
</h3>
<Button variant="ghost" size="sm" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="h-8 w-8 text-primary" />
<div>
<CardTitle className="text-base">aster-server</CardTitle>
<p className="text-sm text-muted-foreground">
{asterStatus?.description ||
"AI Agent 框架 - 提供 Agent 对话能力"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{asterStatus?.installed ? (
<Badge variant="default" className="flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
已安装
</Badge>
) : (
<Badge variant="secondary">未安装</Badge>
)}
{asterStatus?.has_update && (
<Badge variant="destructive">有更新</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 版本信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">已安装版本:</span>
<span className="ml-2 font-mono">
{asterStatus?.installed_version || "-"}
</span>
</div>
<div>
<span className="text-muted-foreground">最新版本:</span>
<span className="ml-2 font-mono">
{asterStatus?.latest_version || "-"}
</span>
</div>
</div>
{/* 下载进度 */}
{downloadProgress && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>下载中...</span>
<span>{downloadProgress.percentage.toFixed(1)}%</span>
</div>
<Progress value={downloadProgress.percentage} />
<p className="text-xs text-muted-foreground">
{(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB /{" "}
{(downloadProgress.total / 1024 / 1024).toFixed(1)} MB
</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2">
{!asterStatus?.installed ? (
<Button onClick={handleInstall} disabled={installing}>
{installing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
安装
</Button>
) : (
<>
{asterStatus?.has_update && (
<Button onClick={handleUpdate} disabled={installing}>
{installing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
更新
</Button>
)}
<Button
variant="destructive"
onClick={handleUninstall}
disabled={uninstalling}
>
{uninstalling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
卸载
</Button>
</>
)}
</div>
{/* 安装时间 */}
{asterStatus?.installed_at && (
<p className="text-xs text-muted-foreground">
安装时间:{new Date(asterStatus.installed_at).toLocaleString()}
</p>
)}
</CardContent>
</Card>
</div>
);
}
+4
View File
@@ -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 (
<div className="space-y-4">
{/* 二进制组件 */}
<BinaryComponents />
{/* 状态概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">
+9
View File
@@ -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 };
+123
View File
@@ -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<AgentProcessStatus> {
return await invoke("agent_start_process", {
asterBinaryPath,
port,
credentialsEndpoint,
});
}
/**
* 停止 Agent 进程
*/
export async function stopAgentProcess(): Promise<void> {
return await invoke("agent_stop_process");
}
/**
* 获取 Agent 进程状态
*/
export async function getAgentProcessStatus(): Promise<AgentProcessStatus> {
return await invoke("agent_get_process_status");
}
/**
* 创建 Agent 会话
*/
export async function createAgentSession(
providerType: string,
model?: string,
): Promise<CreateSessionResponse> {
return await invoke("agent_create_session", {
providerType,
model,
});
}
/**
* 发送消息到 Agent(使用同步 chat API)
*/
export async function sendAgentMessage(
message: string,
model?: string,
images?: ImageInput[],
): Promise<string> {
return await invoke("agent_send_message", {
message,
images,
model,
});
}
/**
* 获取会话列表
*/
export async function listAgentSessions(): Promise<SessionInfo[]> {
return await invoke("agent_list_sessions");
}
/**
* 获取会话详情
*/
export async function getAgentSession(sessionId: string): Promise<SessionInfo> {
return await invoke("agent_get_session", {
sessionId,
});
}
/**
* 删除会话
*/
export async function deleteAgentSession(sessionId: string): Promise<void> {
return await invoke("agent_delete_session", {
sessionId,
});
}
+92
View File
@@ -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<BinaryComponentStatus> {
return invoke<BinaryComponentStatus>("get_aster_status");
}
/**
* 安装 aster-server 组件
*/
export async function installAster(): Promise<string> {
return invoke<string>("install_aster");
}
/**
* 卸载 aster-server 组件
*/
export async function uninstallAster(): Promise<string> {
return invoke<string>("uninstall_aster");
}
/**
* 检查 aster-server 更新
*/
export async function checkAsterUpdate(): Promise<BinaryComponentStatus> {
return invoke<BinaryComponentStatus>("check_aster_update");
}
/**
* 更新 aster-server 组件
*/
export async function updateAster(): Promise<string> {
return invoke<string>("update_aster");
}
/**
* 获取 aster-server 二进制文件路径
*/
export async function getAsterBinaryPath(): Promise<string> {
return invoke<string>("get_aster_binary_path");
}
/**
* 检查 aster-server 是否已安装
*/
export async function isAsterInstalled(): Promise<boolean> {
return invoke<boolean>("is_aster_installed");
}