mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
Generated
+33
-2
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -3668,7 +3668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.20.5"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/Users/coso/Documents/dev/ai/astercloud/aster/aster-server
|
||||
@@ -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 @@
|
||||
{"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}]}]}}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user