diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5d583fe..0b2d709 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -654,6 +654,7 @@ dependencies = [ "tempfile", "tokio", "unic-langid", + "url", "uuid", "walkdir", "which", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 057e020..554019f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,6 +57,7 @@ fluent = "0.16" fluent-bundle = "0.15" unic-langid = "0.9" once_cell = "1.19" +url = "2.5" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/locales/en/messages.ftl b/src-tauri/locales/en/messages.ftl index 53c8603..5ebe801 100644 --- a/src-tauri/locales/en/messages.ftl +++ b/src-tauri/locales/en/messages.ftl @@ -38,6 +38,48 @@ project-not-found = Project not found project-access-denied = Access denied to project session-not-found = Session not found +# Relay Station Messages +relay-station-not-found = Relay station not found +relay-station-create-failed = Failed to create relay station +relay-station-update-failed = Failed to update relay station +relay-station-delete-failed = Failed to delete relay station +relay-station-delete-success = Relay station deleted successfully +relay-station-name-required = Relay station name is required +relay-station-api-url-required = API URL is required +relay-station-invalid-url = Invalid URL format +relay-station-https-required = Only HTTPS URLs are allowed for security +relay-station-token-required = System token is required +relay-station-token-too-short = Token is too short (minimum 10 characters) +relay-station-token-invalid-chars = Token contains invalid characters +relay-station-invalid-adapter = Invalid adapter type +relay-station-invalid-auth-method = Invalid authentication method +relay-station-invalid-config = Invalid adapter configuration + +# Relay Adapter Messages +relay-adapter-connection-success = Connection successful +relay-adapter-api-error = API returned error +relay-adapter-parse-error = Failed to parse response +relay-adapter-http-error = HTTP request failed +relay-adapter-network-error = Network connection failed +relay-adapter-custom-no-test = Custom configuration, connection test skipped +relay-adapter-user-info-not-available = User info not available for this configuration +relay-adapter-usage-logs-not-available = Usage logs not available for this configuration +relay-adapter-token-management-not-available = Token management not available for this configuration +relay-adapter-token-deleted = Token deleted successfully +relay-adapter-get-info-failed = Failed to get station information +relay-adapter-get-user-info-failed = Failed to get user information +relay-adapter-connection-test-failed = Connection test failed +relay-adapter-get-usage-logs-failed = Failed to get usage logs +relay-adapter-list-tokens-failed = Failed to list tokens +relay-adapter-create-token-failed = Failed to create token +relay-adapter-update-token-failed = Failed to update token +relay-adapter-delete-token-failed = Failed to delete token + +# Database Messages +database-lock-failed = Failed to acquire database lock +database-init-failed = Failed to initialize database +database-query-failed = Database query failed + # General Messages operation-cancelled = Operation cancelled timeout-error = Operation timed out diff --git a/src-tauri/locales/zh/messages.ftl b/src-tauri/locales/zh/messages.ftl index a0cc707..0ca5935 100644 --- a/src-tauri/locales/zh/messages.ftl +++ b/src-tauri/locales/zh/messages.ftl @@ -38,6 +38,48 @@ project-not-found = 未找到项目 project-access-denied = 拒绝访问项目 session-not-found = 未找到会话 +# 中转站消息 +relay-station-not-found = 未找到中转站 +relay-station-create-failed = 创建中转站失败 +relay-station-update-failed = 更新中转站失败 +relay-station-delete-failed = 删除中转站失败 +relay-station-delete-success = 中转站删除成功 +relay-station-name-required = 中转站名称必填 +relay-station-api-url-required = API 地址必填 +relay-station-invalid-url = URL 格式无效 +relay-station-https-required = 为了安全,仅允许 HTTPS URL +relay-station-token-required = 系统令牌必填 +relay-station-token-too-short = 令牌太短(最少10个字符) +relay-station-token-invalid-chars = 令牌包含无效字符 +relay-station-invalid-adapter = 适配器类型无效 +relay-station-invalid-auth-method = 认证方式无效 +relay-station-invalid-config = 适配器配置无效 + +# 中转站适配器消息 +relay-adapter-connection-success = 连接成功 +relay-adapter-api-error = API 返回错误 +relay-adapter-parse-error = 解析响应失败 +relay-adapter-http-error = HTTP 请求失败 +relay-adapter-network-error = 网络连接失败 +relay-adapter-custom-no-test = 自定义配置,跳过连接测试 +relay-adapter-user-info-not-available = 该配置不支持用户信息查询 +relay-adapter-usage-logs-not-available = 该配置不支持使用日志查询 +relay-adapter-token-management-not-available = 该配置不支持 Token 管理 +relay-adapter-token-deleted = Token 删除成功 +relay-adapter-get-info-failed = 获取站点信息失败 +relay-adapter-get-user-info-failed = 获取用户信息失败 +relay-adapter-connection-test-failed = 连接测试失败 +relay-adapter-get-usage-logs-failed = 获取使用日志失败 +relay-adapter-list-tokens-failed = 获取 Token 列表失败 +relay-adapter-create-token-failed = 创建 Token 失败 +relay-adapter-update-token-failed = 更新 Token 失败 +relay-adapter-delete-token-failed = 删除 Token 失败 + +# 数据库消息 +database-lock-failed = 获取数据库锁失败 +database-init-failed = 初始化数据库失败 +database-query-failed = 数据库查询失败 + # 通用消息 operation-cancelled = 操作已取消 timeout-error = 操作超时 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0d25fa5..d7f83a9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,3 +6,5 @@ pub mod storage; pub mod slash_commands; pub mod proxy; pub mod language; +pub mod relay_stations; +pub mod relay_adapters; diff --git a/src-tauri/src/commands/relay_adapters.rs b/src-tauri/src/commands/relay_adapters.rs new file mode 100644 index 0000000..d835713 --- /dev/null +++ b/src-tauri/src/commands/relay_adapters.rs @@ -0,0 +1,757 @@ +use async_trait::async_trait; +use anyhow::{anyhow, Result}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tauri::{command, State}; + +use crate::commands::agents::AgentDb; +use crate::commands::relay_stations::{ + RelayStation, StationInfo, UserInfo, ConnectionTestResult, + TokenInfo, TokenPaginationResponse, RelayStationAdapter +}; +use crate::i18n; + +/// HTTP 客户端单例 +static HTTP_CLIENT: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + Client::builder() + .timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .unwrap() +}); + +/// 中转站适配器 trait +#[async_trait] +pub trait StationAdapter: Send + Sync { + /// 获取站点信息 + async fn get_station_info(&self, station: &RelayStation) -> Result; + + /// 获取用户信息 + async fn get_user_info(&self, station: &RelayStation, user_id: &str) -> Result; + + /// 测试连接 + async fn test_connection(&self, station: &RelayStation) -> Result; + + /// 获取用户使用日志 + async fn get_usage_logs(&self, station: &RelayStation, user_id: &str, page: Option, size: Option) -> Result; + + /// 列出用户 Token + async fn list_tokens(&self, station: &RelayStation, page: Option, size: Option) -> Result; + + /// 创建 Token + async fn create_token(&self, station: &RelayStation, name: &str, quota: Option) -> Result; + + /// 更新 Token + async fn update_token(&self, station: &RelayStation, token_id: &str, name: Option<&str>, quota: Option) -> Result; + + /// 删除 Token + async fn delete_token(&self, station: &RelayStation, token_id: &str) -> Result; +} + +/// NewAPI 适配器(支持 NewAPI 和 OneAPI) +pub struct NewApiAdapter; + +#[async_trait] +impl StationAdapter for NewApiAdapter { + async fn get_station_info(&self, station: &RelayStation) -> Result { + let url = format!("{}/api/status", station.api_url.trim_end_matches('/')); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let default_data = json!({}); + let data = data.get("data").unwrap_or(&default_data); + + Ok(StationInfo { + name: data.get("system_name") + .and_then(|v| v.as_str()) + .unwrap_or(&station.name) + .to_string(), + announcement: data.get("announcement") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + api_url: station.api_url.clone(), + version: data.get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + metadata: Some({ + let mut map = HashMap::new(); + map.insert("adapter_type".to_string(), json!("newapi")); + if let Some(quota_per_unit) = data.get("quota_per_unit").and_then(|v| v.as_i64()) { + map.insert("quota_per_unit".to_string(), json!(quota_per_unit)); + } + map + }), + quota_per_unit: data.get("quota_per_unit").and_then(|v| v.as_i64()), + }) + } + + async fn get_user_info(&self, station: &RelayStation, user_id: &str) -> Result { + let url = format!("{}/api/user/self", station.api_url.trim_end_matches('/')); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .header("New-API-User", user_id) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let user_data = data.get("data").ok_or_else(|| anyhow!("No user data returned"))?; + + Ok(UserInfo { + user_id: user_data.get("id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .unwrap_or_else(|| user_id.to_string()), + username: user_data.get("username") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + email: user_data.get("email") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + balance_remaining: user_data.get("quota") + .and_then(|v| v.as_i64()) + .map(|q| q as f64 / 500000.0), // 转换为美元 + amount_used: user_data.get("used_quota") + .and_then(|v| v.as_i64()) + .map(|q| q as f64 / 500000.0), + request_count: user_data.get("request_count") + .and_then(|v| v.as_i64()), + status: match user_data.get("status").and_then(|v| v.as_i64()) { + Some(1) => Some("active".to_string()), + Some(0) => Some("disabled".to_string()), + _ => Some("unknown".to_string()), + }, + metadata: Some({ + let mut map = HashMap::new(); + map.insert("raw_data".to_string(), user_data.clone()); + map + }), + }) + } + + async fn test_connection(&self, station: &RelayStation) -> Result { + let start_time = Instant::now(); + let url = format!("{}/api/status", station.api_url.trim_end_matches('/')); + + match HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Ok(response) => { + let response_time = start_time.elapsed().as_millis() as u64; + + if response.status().is_success() { + match response.json::().await { + Ok(data) => { + let success = data.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + if success { + Ok(ConnectionTestResult { + success: true, + response_time: Some(response_time), + message: i18n::t("relay_adapter.connection_success"), + error: None, + }) + } else { + let error_msg = data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.api_error"), + error: Some(error_msg.to_string()), + }) + } + } + Err(e) => Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.parse_error"), + error: Some(e.to_string()), + }) + } + } else { + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.http_error"), + error: Some(format!("HTTP {}", response.status())), + }) + } + } + Err(e) => { + let response_time = start_time.elapsed().as_millis() as u64; + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.network_error"), + error: Some(e.to_string()), + }) + } + } + } + + async fn get_usage_logs(&self, station: &RelayStation, user_id: &str, page: Option, size: Option) -> Result { + let page = page.unwrap_or(1); + let size = size.unwrap_or(10); + let url = format!("{}/api/log/self?page={}&size={}", + station.api_url.trim_end_matches('/'), page, size); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .header("New-API-User", user_id) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + Ok(data.get("data").cloned().unwrap_or(json!([]))) + } + + async fn list_tokens(&self, station: &RelayStation, page: Option, size: Option) -> Result { + let page = page.unwrap_or(1); + let size = size.unwrap_or(10); + let url = format!("{}/api/token?page={}&size={}", + station.api_url.trim_end_matches('/'), page, size); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let data = data.get("data").ok_or_else(|| anyhow!("No data returned"))?; + let tokens_data = data.get("data").and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("Invalid response format: data is not an array"))?; + + let tokens: Result, _> = tokens_data.iter() + .map(|token| { + Ok::(TokenInfo { + id: token.get("id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .ok_or_else(|| anyhow::anyhow!("Missing token id"))?, + name: token.get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed Token") + .to_string(), + token: token.get("key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + quota: token.get("remain_quota") + .and_then(|v| v.as_i64()), + used_quota: token.get("used_quota") + .and_then(|v| v.as_i64()), + status: match token.get("status").and_then(|v| v.as_i64()) { + Some(1) => "active".to_string(), + Some(0) => "disabled".to_string(), + _ => "unknown".to_string(), + }, + created_at: token.get("created_time") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + updated_at: token.get("updated_time") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + }) + }) + .collect(); + + let tokens = tokens?; + let total = data.get("total").and_then(|v| v.as_i64()).unwrap_or(0); + let has_more = (page * size) < total as usize; + + Ok(TokenPaginationResponse { + tokens, + total, + page, + size, + has_more, + }) + } + + async fn create_token(&self, station: &RelayStation, name: &str, quota: Option) -> Result { + let url = format!("{}/api/token", station.api_url.trim_end_matches('/')); + + let mut body = json!({ + "name": name, + "unlimited_quota": quota.is_none(), + }); + + if let Some(q) = quota { + body["remain_quota"] = json!(q); + } + + let response = HTTP_CLIENT + .post(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let token_data = data.get("data").ok_or_else(|| anyhow!("No token data returned"))?; + + Ok(TokenInfo { + id: token_data.get("id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .ok_or_else(|| anyhow!("Missing token id"))?, + name: name.to_string(), + token: token_data.get("key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + quota, + used_quota: Some(0), + status: "active".to_string(), + created_at: chrono::Utc::now().timestamp(), + updated_at: chrono::Utc::now().timestamp(), + }) + } + + async fn update_token(&self, station: &RelayStation, token_id: &str, name: Option<&str>, quota: Option) -> Result { + let url = format!("{}/api/token", station.api_url.trim_end_matches('/')); + + let mut body = json!({ + "id": token_id.parse::() + .map_err(|_| anyhow!("Invalid token ID format"))?, + }); + + if let Some(n) = name { + body["name"] = json!(n); + } + + if let Some(q) = quota { + body["remain_quota"] = json!(q); + body["unlimited_quota"] = json!(false); + } + + let response = HTTP_CLIENT + .put(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + Ok(TokenInfo { + id: token_id.to_string(), + name: name.unwrap_or("Updated Token").to_string(), + token: "".to_string(), // 更新后不返回完整token + quota, + used_quota: None, + status: "active".to_string(), + created_at: 0, + updated_at: chrono::Utc::now().timestamp(), + }) + } + + async fn delete_token(&self, station: &RelayStation, token_id: &str) -> Result { + let url = format!("{}/api/token/{}", station.api_url.trim_end_matches('/'), token_id); + + let response = HTTP_CLIENT + .delete(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + Ok(i18n::t("relay_adapter.token_deleted")) + } +} + +/// YourAPI 适配器(基于 NewAPI 的优化版本) +pub struct YourApiAdapter { + newapi: NewApiAdapter, +} + +impl YourApiAdapter { + pub fn new() -> Self { + Self { + newapi: NewApiAdapter, + } + } +} + +#[async_trait] +impl StationAdapter for YourApiAdapter { + async fn get_station_info(&self, station: &RelayStation) -> Result { + // 复用 NewAPI 的实现,但修改适配器类型 + let mut info = self.newapi.get_station_info(station).await?; + if let Some(ref mut metadata) = info.metadata { + metadata.insert("adapter_type".to_string(), json!("yourapi")); + } + Ok(info) + } + + async fn get_user_info(&self, station: &RelayStation, user_id: &str) -> Result { + self.newapi.get_user_info(station, user_id).await + } + + async fn test_connection(&self, station: &RelayStation) -> Result { + self.newapi.test_connection(station).await + } + + async fn get_usage_logs(&self, station: &RelayStation, user_id: &str, page: Option, size: Option) -> Result { + self.newapi.get_usage_logs(station, user_id, page, size).await + } + + async fn list_tokens(&self, station: &RelayStation, page: Option, size: Option) -> Result { + // YourAPI 特定的 Token 列表实现 + let page = page.unwrap_or(1); + let size = size.unwrap_or(10); + let url = format!("{}/api/token?page={}&size={}", + station.api_url.trim_end_matches('/'), page, size); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("Bearer {}", station.system_token)) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + // YourAPI 返回直接数组而非嵌套对象 + let tokens_data = data["data"].as_array() + .ok_or_else(|| anyhow!("Invalid response format: data is not an array"))?; + + let tokens: Result, _> = tokens_data.iter() + .map(|token| { + Ok::(TokenInfo { + id: token.get("id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .ok_or_else(|| anyhow::anyhow!("Missing token id"))?, + name: token.get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed Token") + .to_string(), + token: token.get("key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + quota: token.get("remain_quota") + .and_then(|v| v.as_i64()), + used_quota: token.get("used_quota") + .and_then(|v| v.as_i64()), + status: match token.get("status").and_then(|v| v.as_i64()) { + Some(1) => "active".to_string(), + Some(0) => "disabled".to_string(), + _ => "unknown".to_string(), + }, + created_at: token.get("created_time") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + updated_at: token.get("updated_time") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + }) + }) + .collect(); + + let tokens = tokens?; + let items_len = tokens.len(); + + // YourAPI 的智能分页估算 + let has_more_pages = items_len == size; + let estimated_total = if page == 1 && !has_more_pages { + items_len as i64 + } else if has_more_pages { + (page * size + 1) as i64 // 保守估计 + } else { + ((page - 1) * size + items_len) as i64 + }; + + Ok(TokenPaginationResponse { + tokens, + total: estimated_total, + page, + size, + has_more: has_more_pages, + }) + } + + async fn create_token(&self, station: &RelayStation, name: &str, quota: Option) -> Result { + self.newapi.create_token(station, name, quota).await + } + + async fn update_token(&self, station: &RelayStation, token_id: &str, name: Option<&str>, quota: Option) -> Result { + self.newapi.update_token(station, token_id, name, quota).await + } + + async fn delete_token(&self, station: &RelayStation, token_id: &str) -> Result { + self.newapi.delete_token(station, token_id).await + } +} + +/// Custom 适配器(简化版本,仅提供基本信息) +pub struct CustomAdapter; + +#[async_trait] +impl StationAdapter for CustomAdapter { + async fn get_station_info(&self, station: &RelayStation) -> Result { + Ok(StationInfo { + name: station.name.clone(), + announcement: None, + api_url: station.api_url.clone(), + version: Some("Custom".to_string()), + metadata: Some({ + let mut map = HashMap::new(); + map.insert("adapter_type".to_string(), json!("custom")); + map.insert("note".to_string(), json!("This is a custom configuration that only provides URL and API key.")); + map + }), + quota_per_unit: None, + }) + } + + async fn get_user_info(&self, _station: &RelayStation, _user_id: &str) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.user_info_not_available"))) + } + + async fn test_connection(&self, _station: &RelayStation) -> Result { + // Custom 适配器跳过连接测试,直接返回成功 + Ok(ConnectionTestResult { + success: true, + response_time: Some(0), + message: i18n::t("relay_adapter.custom_no_test"), + error: None, + }) + } + + async fn get_usage_logs(&self, _station: &RelayStation, _user_id: &str, _page: Option, _size: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.usage_logs_not_available"))) + } + + async fn list_tokens(&self, _station: &RelayStation, _page: Option, _size: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available"))) + } + + async fn create_token(&self, _station: &RelayStation, _name: &str, _quota: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available"))) + } + + async fn update_token(&self, _station: &RelayStation, _token_id: &str, _name: Option<&str>, _quota: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available"))) + } + + async fn delete_token(&self, _station: &RelayStation, _token_id: &str) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available"))) + } +} + +/// 适配器工厂函数 +pub fn create_adapter(adapter_type: &RelayStationAdapter) -> Box { + match adapter_type { + RelayStationAdapter::Newapi => Box::new(NewApiAdapter), + RelayStationAdapter::Oneapi => Box::new(NewApiAdapter), // OneAPI 兼容 NewAPI + RelayStationAdapter::Yourapi => Box::new(YourApiAdapter::new()), + RelayStationAdapter::Custom => Box::new(CustomAdapter), + } +} + +/// 获取中转站信息 +#[command] +pub async fn relay_station_get_info( + station_id: String, + db: State<'_, AgentDb> +) -> Result { + // 获取中转站配置 + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + + // 创建适配器 + let adapter = create_adapter(&station.adapter); + + // 获取站点信息 + adapter.get_station_info(&station).await + .map_err(|e| { + log::error!("Failed to get station info: {}", e); + i18n::t("relay_adapter.get_info_failed") + }) +} + +/// 获取用户信息 +#[command] +pub async fn relay_station_get_user_info( + station_id: String, + user_id: String, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.get_user_info(&station, &user_id).await + .map_err(|e| { + log::error!("Failed to get user info: {}", e); + i18n::t("relay_adapter.get_user_info_failed") + }) +} + +/// 测试中转站连接 +#[command] +pub async fn relay_station_test_connection( + station_id: String, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.test_connection(&station).await + .map_err(|e| { + log::error!("Connection test failed: {}", e); + i18n::t("relay_adapter.connection_test_failed") + }) +} + +/// 获取使用日志 +#[command] +pub async fn relay_station_get_usage_logs( + station_id: String, + user_id: String, + page: Option, + size: Option, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.get_usage_logs(&station, &user_id, page, size).await + .map_err(|e| { + log::error!("Failed to get usage logs: {}", e); + i18n::t("relay_adapter.get_usage_logs_failed") + }) +} + +/// 列出 Token +#[command] +pub async fn relay_station_list_tokens( + station_id: String, + page: Option, + size: Option, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.list_tokens(&station, page, size).await + .map_err(|e| { + log::error!("Failed to list tokens: {}", e); + i18n::t("relay_adapter.list_tokens_failed") + }) +} + +/// 创建 Token +#[command] +pub async fn relay_station_create_token( + station_id: String, + name: String, + quota: Option, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.create_token(&station, &name, quota).await + .map_err(|e| { + log::error!("Failed to create token: {}", e); + i18n::t("relay_adapter.create_token_failed") + }) +} + +/// 更新 Token +#[command] +pub async fn relay_station_update_token( + station_id: String, + token_id: String, + name: Option, + quota: Option, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.update_token(&station, &token_id, name.as_deref(), quota).await + .map_err(|e| { + log::error!("Failed to update token: {}", e); + i18n::t("relay_adapter.update_token_failed") + }) +} + +/// 删除 Token +#[command] +pub async fn relay_station_delete_token( + station_id: String, + token_id: String, + db: State<'_, AgentDb> +) -> Result { + let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?; + let adapter = create_adapter(&station.adapter); + + adapter.delete_token(&station, &token_id).await + .map_err(|e| { + log::error!("Failed to delete token: {}", e); + i18n::t("relay_adapter.delete_token_failed") + }) +} \ No newline at end of file diff --git a/src-tauri/src/commands/relay_stations.rs b/src-tauri/src/commands/relay_stations.rs new file mode 100644 index 0000000..0c971b6 --- /dev/null +++ b/src-tauri/src/commands/relay_stations.rs @@ -0,0 +1,696 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::{command, State}; +use anyhow::Result; +use chrono::Utc; +use rusqlite::{params, Connection, Row, OptionalExtension}; +use uuid::Uuid; + +use crate::commands::agents::AgentDb; +use crate::i18n; +use crate::claude_config; + +/// 中转站适配器类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayStationAdapter { + Newapi, // NewAPI 兼容平台 + Oneapi, // OneAPI 兼容平台 + Yourapi, // YourAPI 特定平台 + Custom, // 自定义简单配置 +} + +impl RelayStationAdapter { + pub fn as_str(&self) -> &str { + match self { + RelayStationAdapter::Newapi => "newapi", + RelayStationAdapter::Oneapi => "oneapi", + RelayStationAdapter::Yourapi => "yourapi", + RelayStationAdapter::Custom => "custom", + } + } +} + +/// 认证方式 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMethod { + BearerToken, // Bearer Token 认证(推荐) + ApiKey, // API Key 认证 + Custom, // 自定义认证方式 +} + +/// 中转站配置(完整版本) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayStation { + pub id: String, // 唯一标识符 + pub name: String, // 显示名称 + pub description: Option, // 描述信息 + pub api_url: String, // API 基础 URL + pub adapter: RelayStationAdapter, // 适配器类型 + pub auth_method: AuthMethod, // 认证方式 + pub system_token: String, // 系统令牌 + pub user_id: Option, // 用户 ID(NewAPI 必需) + pub adapter_config: Option>, // 适配器特定配置 + pub enabled: bool, // 启用状态 + pub created_at: i64, // 创建时间 + pub updated_at: i64, // 更新时间 +} + +/// 创建中转站请求(无自动生成字段) +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateRelayStationRequest { + pub name: String, + pub description: Option, + pub api_url: String, + pub adapter: RelayStationAdapter, + pub auth_method: AuthMethod, + pub system_token: String, + pub user_id: Option, + pub adapter_config: Option>, + pub enabled: bool, +} + +/// 更新中转站请求 +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateRelayStationRequest { + pub id: String, + pub name: String, + pub description: Option, + pub api_url: String, + pub adapter: RelayStationAdapter, + pub auth_method: AuthMethod, + pub system_token: String, + pub user_id: Option, + pub adapter_config: Option>, + pub enabled: bool, +} + +/// 站点信息(统一格式) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StationInfo { + pub name: String, // 站点名称 + pub announcement: Option, // 公告信息 + pub api_url: String, // API 地址 + pub version: Option, // 版本信息 + pub metadata: Option>, // 扩展元数据 + pub quota_per_unit: Option, // 单位配额(用于价格转换) +} + +/// 用户信息(统一格式) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + pub user_id: String, // 用户 ID + pub username: Option, // 用户名 + pub email: Option, // 邮箱 + pub balance_remaining: Option, // 剩余余额(美元) + pub amount_used: Option, // 已用金额(美元) + pub request_count: Option, // 请求次数 + pub status: Option, // 账户状态 + pub metadata: Option>, // 原始数据 +} + +/// 连接测试结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionTestResult { + pub success: bool, // 连接是否成功 + pub response_time: Option, // 响应时间(毫秒) + pub message: String, // 结果消息 + pub error: Option, // 错误信息 +} + +/// Token 信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenInfo { + pub id: String, + pub name: String, + pub token: String, + pub quota: Option, + pub used_quota: Option, + pub status: String, + pub created_at: i64, + pub updated_at: i64, +} + +/// Token 分页响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenPaginationResponse { + pub tokens: Vec, + pub total: i64, + pub page: usize, + pub size: usize, + pub has_more: bool, +} + +impl RelayStation { + fn from_row(row: &Row) -> Result { + let adapter_str: String = row.get("adapter")?; + let auth_method_str: String = row.get("auth_method")?; + let adapter_config_str: Option = row.get("adapter_config")?; + + let adapter = serde_json::from_str(&format!("\"{}\"", adapter_str)) + .map_err(|_| rusqlite::Error::InvalidColumnType(0, "adapter".to_string(), rusqlite::types::Type::Text))?; + + let auth_method = serde_json::from_str(&format!("\"{}\"", auth_method_str)) + .map_err(|_| rusqlite::Error::InvalidColumnType(0, "auth_method".to_string(), rusqlite::types::Type::Text))?; + + let adapter_config = if let Some(config_str) = adapter_config_str { + if config_str.trim().is_empty() { + None + } else { + Some(serde_json::from_str(&config_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(0, "adapter_config".to_string(), rusqlite::types::Type::Text))?) + } + } else { + None + }; + + Ok(RelayStation { + id: row.get("id")?, + name: row.get("name")?, + description: row.get("description")?, + api_url: row.get("api_url")?, + adapter, + auth_method, + system_token: row.get("system_token")?, + user_id: row.get("user_id")?, + adapter_config, + enabled: row.get::<_, i32>("enabled")? == 1, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + }) + } +} + +/// 初始化中转站数据库表 +pub fn init_relay_stations_tables(conn: &Connection) -> Result<()> { + // 中转站表 + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS relay_stations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + api_url TEXT NOT NULL, + adapter TEXT NOT NULL, + auth_method TEXT NOT NULL, + system_token TEXT NOT NULL, + user_id TEXT, + adapter_config TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + "#, + [], + )?; + + // 中转站使用日志表 + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS relay_station_usage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + station_id TEXT NOT NULL, + request_type TEXT NOT NULL, + response_time INTEGER, + success INTEGER NOT NULL, + error_message TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (station_id) REFERENCES relay_stations (id) ON DELETE CASCADE + ) + "#, + [], + )?; + + log::info!("Relay stations database tables initialized"); + Ok(()) +} + +/// 获取所有中转站 +#[command] +pub async fn relay_stations_list(db: State<'_, AgentDb>) -> Result, String> { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 确保表存在 + init_relay_stations_tables(&conn).map_err(|e| { + log::error!("Failed to initialize relay stations tables: {}", e); + i18n::t("database.init_failed") + })?; + + let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC") + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let stations = stmt.query_map([], |row| RelayStation::from_row(row)) + .map_err(|e| { + log::error!("Failed to query relay stations: {}", e); + i18n::t("database.query_failed") + })? + .collect::, _>>() + .map_err(|e| { + log::error!("Failed to collect relay stations: {}", e); + i18n::t("database.query_failed") + })?; + + log::info!("Retrieved {} relay stations", stations.len()); + Ok(stations) +} + +/// 获取单个中转站 +#[command] +pub async fn relay_station_get( + id: String, + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + let mut stmt = conn.prepare("SELECT * FROM relay_stations WHERE id = ?1") + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let station = stmt.query_row(params![id], |row| RelayStation::from_row(row)) + .map_err(|e| { + log::error!("Failed to get relay station {}: {}", id, e); + i18n::t("relay_station.not_found") + })?; + + log::info!("Retrieved relay station: {}", id); + Ok(station) +} + +/// 创建中转站 +#[command] +pub async fn relay_station_create( + request: CreateRelayStationRequest, + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 确保表存在 + init_relay_stations_tables(&conn).map_err(|e| { + log::error!("Failed to initialize relay stations tables: {}", e); + i18n::t("database.init_failed") + })?; + + // 验证输入 + validate_relay_station_request(&request.name, &request.api_url, &request.system_token)?; + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp(); + + let adapter_str = serde_json::to_string(&request.adapter) + .map_err(|_| i18n::t("relay_station.invalid_adapter"))? + .trim_matches('"').to_string(); + + let auth_method_str = serde_json::to_string(&request.auth_method) + .map_err(|_| i18n::t("relay_station.invalid_auth_method"))? + .trim_matches('"').to_string(); + + let adapter_config_str = request.adapter_config.as_ref() + .map(|config| serde_json::to_string(config)) + .transpose() + .map_err(|_| i18n::t("relay_station.invalid_config"))?; + + // 如果要启用这个新中转站,先禁用所有其他中转站 + if request.enabled { + conn.execute( + "UPDATE relay_stations SET enabled = 0", + [], + ).map_err(|e| { + log::error!("Failed to disable other relay stations: {}", e); + i18n::t("relay_station.create_failed") + })?; + } + + conn.execute( + r#" + INSERT INTO relay_stations + (id, name, description, api_url, adapter, auth_method, system_token, user_id, adapter_config, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + params![ + id, + request.name, + request.description, + request.api_url, + adapter_str, + auth_method_str, + request.system_token, + request.user_id, + adapter_config_str, + if request.enabled { 1 } else { 0 }, + now, + now + ], + ).map_err(|e| { + log::error!("Failed to create relay station: {}", e); + i18n::t("relay_station.create_failed") + })?; + + let station = RelayStation { + id: id.clone(), + name: request.name, + description: request.description, + api_url: request.api_url, + adapter: request.adapter, + auth_method: request.auth_method, + system_token: request.system_token, + user_id: request.user_id, + adapter_config: request.adapter_config, + enabled: request.enabled, + created_at: now, + updated_at: now, + }; + + log::info!("Created relay station: {} ({})", station.name, id); + Ok(station) +} + +/// 更新中转站 +#[command] +pub async fn relay_station_update( + request: UpdateRelayStationRequest, + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 验证输入 + validate_relay_station_request(&request.name, &request.api_url, &request.system_token)?; + + let now = Utc::now().timestamp(); + + let adapter_str = serde_json::to_string(&request.adapter) + .map_err(|_| i18n::t("relay_station.invalid_adapter"))? + .trim_matches('"').to_string(); + + let auth_method_str = serde_json::to_string(&request.auth_method) + .map_err(|_| i18n::t("relay_station.invalid_auth_method"))? + .trim_matches('"').to_string(); + + let adapter_config_str = request.adapter_config.as_ref() + .map(|config| serde_json::to_string(config)) + .transpose() + .map_err(|_| i18n::t("relay_station.invalid_config"))?; + + // 如果要启用这个中转站,先禁用所有其他中转站 + if request.enabled { + conn.execute( + "UPDATE relay_stations SET enabled = 0 WHERE id != ?1", + params![request.id], + ).map_err(|e| { + log::error!("Failed to disable other relay stations: {}", e); + i18n::t("relay_station.update_failed") + })?; + } + + let rows_affected = conn.execute( + r#" + UPDATE relay_stations + SET name = ?2, description = ?3, api_url = ?4, adapter = ?5, auth_method = ?6, + system_token = ?7, user_id = ?8, adapter_config = ?9, enabled = ?10, updated_at = ?11 + WHERE id = ?1 + "#, + params![ + request.id, + request.name, + request.description, + request.api_url, + adapter_str, + auth_method_str, + request.system_token, + request.user_id, + adapter_config_str, + if request.enabled { 1 } else { 0 }, + now + ], + ).map_err(|e| { + log::error!("Failed to update relay station: {}", e); + i18n::t("relay_station.update_failed") + })?; + + if rows_affected == 0 { + return Err(i18n::t("relay_station.not_found")); + } + + let station = RelayStation { + id: request.id.clone(), + name: request.name, + description: request.description, + api_url: request.api_url, + adapter: request.adapter, + auth_method: request.auth_method, + system_token: request.system_token, + user_id: request.user_id, + adapter_config: request.adapter_config, + enabled: request.enabled, + created_at: 0, // 不重要,前端可以重新获取 + updated_at: now, + }; + + log::info!("Updated relay station: {} ({})", station.name, request.id); + Ok(station) +} + +/// 删除中转站 +#[command] +pub async fn relay_station_delete( + id: String, + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + let rows_affected = conn.execute("DELETE FROM relay_stations WHERE id = ?1", params![id]) + .map_err(|e| { + log::error!("Failed to delete relay station: {}", e); + i18n::t("relay_station.delete_failed") + })?; + + if rows_affected == 0 { + return Err(i18n::t("relay_station.not_found")); + } + + log::info!("Deleted relay station: {}", id); + Ok(i18n::t("relay_station.delete_success")) +} + +/// 切换中转站启用状态(确保只有一个中转站启用) +#[command] +pub async fn relay_station_toggle_enable( + id: String, + enabled: bool, + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + let now = Utc::now().timestamp(); + + // 如果要启用这个中转站,先禁用所有其他中转站 + if enabled { + conn.execute( + "UPDATE relay_stations SET enabled = 0, updated_at = ?1 WHERE id != ?2", + params![now, id], + ).map_err(|e| { + log::error!("Failed to disable other relay stations: {}", e); + i18n::t("relay_station.update_failed") + })?; + + // 获取要启用的中转站信息 + let station = relay_station_get_internal(&conn, &id)?; + + // 将中转站配置应用到 Claude 配置文件 + if let Err(e) = claude_config::apply_relay_station_to_config(&station) { + log::error!("Failed to apply relay station config: {}", e); + // 不中断流程,但记录错误 + } else { + log::info!("Applied relay station config to Claude settings"); + } + } else { + // 如果禁用中转站,清除 Claude 配置中的相关设置 + if let Err(e) = claude_config::clear_relay_station_from_config() { + log::error!("Failed to clear relay station config: {}", e); + } else { + log::info!("Cleared relay station config from Claude settings"); + } + } + + // 更新目标中转站的启用状态 + let rows_affected = conn.execute( + "UPDATE relay_stations SET enabled = ?1, updated_at = ?2 WHERE id = ?3", + params![if enabled { 1 } else { 0 }, now, id], + ).map_err(|e| { + log::error!("Failed to toggle relay station enable status: {}", e); + i18n::t("relay_station.update_failed") + })?; + + if rows_affected == 0 { + return Err(i18n::t("relay_station.not_found")); + } + + log::info!("Toggled relay station enable status: {} -> {}", id, enabled); + Ok(if enabled { + i18n::t("relay_station.enabled_success") + } else { + i18n::t("relay_station.disabled_success") + }) +} + +/// 内部方法:获取单个中转站 +fn relay_station_get_internal(conn: &Connection, id: &str) -> Result { + let mut stmt = conn.prepare( + "SELECT * FROM relay_stations WHERE id = ?1" + ).map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let station = stmt.query_row(params![id], |row| { + RelayStation::from_row(row) + }).map_err(|e| { + log::error!("Failed to get relay station: {}", e); + i18n::t("relay_station.not_found") + })?; + + Ok(station) +} + +/// 输入验证 +fn validate_relay_station_request(name: &str, api_url: &str, system_token: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err(i18n::t("relay_station.name_required")); + } + + if api_url.trim().is_empty() { + return Err(i18n::t("relay_station.api_url_required")); + } + + // 验证 URL 格式 + if let Err(_) = url::Url::parse(api_url) { + return Err(i18n::t("relay_station.invalid_url")); + } + + // 验证是否为 HTTPS + if !api_url.starts_with("https://") { + return Err(i18n::t("relay_station.https_required")); + } + + if system_token.trim().is_empty() { + return Err(i18n::t("relay_station.token_required")); + } + + if system_token.len() < 10 { + return Err(i18n::t("relay_station.token_too_short")); + } + + // 检查 Token 是否包含特殊字符 + if system_token.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(i18n::t("relay_station.token_invalid_chars")); + } + + Ok(()) +} + +/// Token 脱敏显示 +#[allow(dead_code)] +pub fn mask_token(token: &str) -> String { + if token.len() <= 8 { + "*".repeat(token.len()) + } else { + format!("{}...{}", &token[..4], &token[token.len()-4..]) + } +} + +/// 手动同步中转站配置到 Claude 配置文件 +#[command] +pub async fn relay_station_sync_config( + db: State<'_, AgentDb> +) -> Result { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 查找当前启用的中转站 + let mut stmt = conn.prepare( + "SELECT * FROM relay_stations WHERE enabled = 1 LIMIT 1" + ).map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let station_opt = stmt.query_row([], |row| { + RelayStation::from_row(row) + }).optional().map_err(|e| { + log::error!("Failed to query enabled relay station: {}", e); + i18n::t("database.query_failed") + })?; + + if let Some(station) = station_opt { + // 应用中转站配置 + claude_config::apply_relay_station_to_config(&station) + .map_err(|e| format!("配置同步失败: {}", e))?; + + log::info!("Synced relay station {} config to Claude settings", station.name); + Ok(format!("已同步中转站 {} 的配置到 Claude 设置", station.name)) + } else { + // 没有启用的中转站,清除配置 + claude_config::clear_relay_station_from_config() + .map_err(|e| format!("清除配置失败: {}", e))?; + + log::info!("Cleared relay station config from Claude settings"); + Ok("已清除 Claude 设置中的中转站配置".to_string()) + } +} + +/// 恢复 Claude 配置备份 +#[command] +pub async fn relay_station_restore_config() -> Result { + claude_config::restore_claude_config() + .map_err(|e| format!("恢复配置失败: {}", e))?; + + log::info!("Restored Claude config from backup"); + Ok("已从备份恢复 Claude 配置".to_string()) +} + +/// 获取当前 Claude 配置中的 API 信息 +#[command] +pub async fn relay_station_get_current_config() -> Result>, String> { + let mut config = HashMap::new(); + + config.insert( + "api_url".to_string(), + claude_config::get_current_api_url().unwrap_or(None) + ); + + config.insert( + "api_token".to_string(), + claude_config::get_current_api_token().unwrap_or(None) + .map(|token: String| { + // 脱敏显示 token + mask_token(&token) + }) + ); + + Ok(config) +} \ No newline at end of file diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 5894a29..4cd39a0 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -43,6 +43,19 @@ impl SimpleI18n { ("en-US", "agent-not-found") => "Agent not found".to_string(), ("en-US", "claude-not-installed") => "Claude Code is not installed".to_string(), + // Relay Station English translations + ("en-US", "relay_adapter.custom_no_test") => "Custom configuration, connection test skipped".to_string(), + ("en-US", "relay_adapter.user_info_not_available") => "User info not available for this configuration".to_string(), + ("en-US", "relay_adapter.usage_logs_not_available") => "Usage logs not available for this configuration".to_string(), + ("en-US", "relay_adapter.token_management_not_available") => "Token management not available for this configuration".to_string(), + ("en-US", "relay_adapter.connection_success") => "Connection successful".to_string(), + ("en-US", "relay_adapter.api_error") => "API returned error".to_string(), + ("en-US", "relay_adapter.parse_error") => "Failed to parse response".to_string(), + ("en-US", "relay_adapter.http_error") => "HTTP request failed".to_string(), + ("en-US", "relay_adapter.network_error") => "Network connection failed".to_string(), + ("en-US", "relay_station.enabled_success") => "Relay station enabled successfully".to_string(), + ("en-US", "relay_station.disabled_success") => "Relay station disabled successfully".to_string(), + // 中文翻译 ("zh-CN", "error-failed-to-create") => "创建失败".to_string(), ("zh-CN", "error-failed-to-update") => "更新失败".to_string(), @@ -50,6 +63,19 @@ impl SimpleI18n { ("zh-CN", "agent-not-found") => "未找到智能体".to_string(), ("zh-CN", "claude-not-installed") => "未安装 Claude Code".to_string(), + // Relay Station Chinese translations + ("zh-CN", "relay_adapter.custom_no_test") => "自定义配置,跳过连接测试".to_string(), + ("zh-CN", "relay_adapter.user_info_not_available") => "该配置不支持用户信息查询".to_string(), + ("zh-CN", "relay_adapter.usage_logs_not_available") => "该配置不支持使用日志查询".to_string(), + ("zh-CN", "relay_adapter.token_management_not_available") => "该配置不支持 Token 管理".to_string(), + ("zh-CN", "relay_adapter.connection_success") => "连接成功".to_string(), + ("zh-CN", "relay_adapter.api_error") => "API 返回错误".to_string(), + ("zh-CN", "relay_adapter.parse_error") => "解析响应失败".to_string(), + ("zh-CN", "relay_adapter.http_error") => "HTTP 请求失败".to_string(), + ("zh-CN", "relay_adapter.network_error") => "网络连接失败".to_string(), + ("zh-CN", "relay_station.enabled_success") => "中转站启用成功".to_string(), + ("zh-CN", "relay_station.disabled_success") => "中转站禁用成功".to_string(), + // 默认情况 _ => key.to_string(), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb2ac39..5507207 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ // Declare modules pub mod checkpoint; pub mod claude_binary; +pub mod claude_config; pub mod commands; pub mod process; pub mod i18n; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bdd4dee..c695b8d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,6 +6,7 @@ mod claude_binary; mod commands; mod process; mod i18n; +mod claude_config; use checkpoint::state::CheckpointState; use commands::agents::{ @@ -45,6 +46,16 @@ use commands::storage::{ }; use commands::proxy::{get_proxy_settings, save_proxy_settings, apply_proxy_settings}; use commands::language::{get_current_language, set_language, get_supported_languages}; +use commands::relay_stations::{ + relay_stations_list, relay_station_get, relay_station_create, relay_station_update, + relay_station_delete, relay_station_toggle_enable, relay_station_sync_config, + relay_station_restore_config, relay_station_get_current_config, +}; +use commands::relay_adapters::{ + relay_station_get_info, relay_station_get_user_info, + relay_station_test_connection, relay_station_get_usage_logs, relay_station_list_tokens, + relay_station_create_token, relay_station_update_token, relay_station_delete_token, +}; use process::ProcessRegistryState; use std::sync::Mutex; use tauri::Manager; @@ -256,6 +267,25 @@ fn main() { get_current_language, set_language, get_supported_languages, + + // Relay Stations + relay_stations_list, + relay_station_get, + relay_station_create, + relay_station_update, + relay_station_delete, + relay_station_toggle_enable, + relay_station_sync_config, + relay_station_restore_config, + relay_station_get_current_config, + relay_station_get_info, + relay_station_get_user_info, + relay_station_test_connection, + relay_station_get_usage_logs, + relay_station_list_tokens, + relay_station_create_token, + relay_station_update_token, + relay_station_delete_token, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 24e2c27..39c563d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { AnalyticsConsentBanner } from "@/components/AnalyticsConsent"; import { useAppLifecycle, useTrackEvent } from "@/hooks"; import { useTranslation } from "@/hooks/useTranslation"; import { WelcomePage } from "@/components/WelcomePage"; +import RelayStationManager from "@/components/RelayStationManager"; type View = | "welcome" @@ -41,6 +42,7 @@ type View = | "agent-execution" | "agent-run-view" | "mcp" + | "relay-stations" | "usage-dashboard" | "project-settings" | "tabs"; // New view for tab-based interface @@ -255,6 +257,11 @@ function AppContent() { /> ); + case "relay-stations": + return ( + handleViewChange("welcome")} /> + ); + case "cc-agents": return ( void; +} + +const RelayStationManager: React.FC = ({ onBack }) => { + const [stations, setStations] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedStation, setSelectedStation] = useState(null); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [connectionTests, setConnectionTests] = useState>({}); + const [testingConnections, setTestingConnections] = useState>({}); + const [togglingEnable, setTogglingEnable] = useState>({}); + const [currentConfig, setCurrentConfig] = useState>({}); + const [loadingConfig, setLoadingConfig] = useState(false); + + const { t } = useTranslation(); + + // 加载中转站列表 + const loadStations = async () => { + try { + setLoading(true); + const stationList = await api.relayStationsList(); + setStations(stationList); + } catch (error) { + console.error('Failed to load stations:', error); + alert(t('relayStation.loadFailed')); + } finally { + setLoading(false); + } + }; + + // 加载当前配置状态 + const loadCurrentConfig = async () => { + try { + setLoadingConfig(true); + const config = await api.relayStationGetCurrentConfig(); + setCurrentConfig(config); + } catch (error) { + console.error('Failed to load current config:', error); + } finally { + setLoadingConfig(false); + } + }; + + // 手动同步配置 + const syncConfig = async () => { + try { + const result = await api.relayStationSyncConfig(); + alert(result); + loadCurrentConfig(); + } catch (error) { + console.error('Failed to sync config:', error); + alert(t('relayStation.syncFailed')); + } + }; + + // 测试连接 + const testConnection = async (stationId: string) => { + try { + setTestingConnections(prev => ({ ...prev, [stationId]: true })); + const result = await api.relayStationTestConnection(stationId); + setConnectionTests(prev => ({ ...prev, [stationId]: result })); + + if (result.success) { + alert(t('relayStation.connectionSuccess')); + } else { + alert(result.message); + } + } catch (error) { + console.error('Connection test failed:', error); + alert(t('relayStation.connectionFailed')); + } finally { + setTestingConnections(prev => ({ ...prev, [stationId]: false })); + } + }; + + // 删除中转站 + const deleteStation = async (stationId: string) => { + if (!confirm(t('relayStation.deleteConfirm'))) return; + + try { + await api.relayStationDelete(stationId); + alert(t('relayStation.deleteSuccess')); + loadStations(); + } catch (error) { + console.error('Failed to delete station:', error); + alert(t('relayStation.deleteFailed')); + } + }; + + // 获取适配器类型显示名称 + const getAdapterDisplayName = (adapter: RelayStationAdapter): string => { + switch (adapter) { + case 'newapi': return 'NewAPI'; + case 'oneapi': return 'OneAPI'; + case 'yourapi': return 'YourAPI'; + case 'custom': return t('relayStation.custom'); + default: return adapter; + } + }; + + // 切换启用状态 + const toggleEnableStatus = async (stationId: string, currentEnabled: boolean) => { + try { + setTogglingEnable(prev => ({ ...prev, [stationId]: true })); + const newEnabled = !currentEnabled; + await api.relayStationToggleEnable(stationId, newEnabled); + alert(newEnabled ? t('relayStation.enabledSuccess') : t('relayStation.disabledSuccess')); + loadStations(); + loadCurrentConfig(); // 重新加载配置状态 + } catch (error) { + console.error('Failed to toggle enable status:', error); + alert(t('relayStation.toggleEnableFailed')); + } finally { + setTogglingEnable(prev => ({ ...prev, [stationId]: false })); + } + }; + + // 获取状态样式 + const getStatusBadge = (station: RelayStation) => { + const enabled = station.enabled; + const isToggling = togglingEnable[station.id]; + + return ( +
+ toggleEnableStatus(station.id, enabled)} + className="data-[state=checked]:bg-green-500" + /> + {isToggling ? ( + {t('common.updating')} + ) : enabled ? ( + {t('status.enabled')} + ) : ( + {t('status.disabled')} + )} +
+ ); + }; + + useEffect(() => { + loadStations(); + loadCurrentConfig(); + }, []); + + return ( +
+ {/* 页面标题 */} +
+
+ +
+

{t('navigation.relayStations')}

+

{t('relayStation.description')}

+
+
+ + + + + + { + setShowCreateDialog(false); + loadStations(); + }} + /> + + +
+ + {/* 当前配置状态 */} + + +
+
+ + {t('relayStation.currentConfig')} +
+ +
+
+ +
+
+ API URL: + + {currentConfig.api_url || t('relayStation.notConfigured')} + +
+
+ API Token: + + {currentConfig.api_token || t('relayStation.notConfigured')} + +
+
+ {t('relayStation.configLocation')}: ~/.claude/settings.json +
+
+
+
+ + {/* 中转站列表 */} +
+ {loading ? ( +
+
+

{t('common.loading')}

+
+ ) : stations.length === 0 ? ( +
+ +

{t('relayStation.noStations')}

+

{t('relayStation.noStationsDesc')}

+ +
+ ) : ( + stations.map((station) => ( + + +
+
+ {station.name} + + {getAdapterDisplayName(station.adapter)} + +
+ {getStatusBadge(station)} +
+
+ +
+
+ + {station.api_url} +
+ + {station.description && ( +

+ {station.description} +

+ )} + + {connectionTests[station.id] && ( +
+ {connectionTests[station.id].success ? ( + + ) : ( + + )} + + {connectionTests[station.id].message} + {connectionTests[station.id].response_time && ( + + ({connectionTests[station.id].response_time}ms) + + )} + +
+ )} + +
+ + +
+ + +
+
+
+
+
+ )) + )} +
+ + {/* 编辑对话框 */} + {selectedStation && ( + + + { + setShowEditDialog(false); + setSelectedStation(null); + loadStations(); + }} + onCancel={() => { + setShowEditDialog(false); + setSelectedStation(null); + }} + /> + + + )} +
+ ); +}; + +// 创建中转站对话框组件 +const CreateStationDialog: React.FC<{ + onSuccess: () => void; +}> = ({ onSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + api_url: '', + adapter: 'newapi', + auth_method: 'bearer_token', + system_token: '', + user_id: '', + enabled: false, // 默认不启用,需要通过主界面切换 + }); + const [submitting, setSubmitting] = useState(false); + + const { t } = useTranslation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim()) { + alert(t('relayStation.nameRequired')); + return; + } + + if (!formData.api_url.trim()) { + alert(t('relayStation.apiUrlRequired')); + return; + } + + if (!formData.system_token.trim()) { + alert(t('relayStation.tokenRequired')); + return; + } + + try { + setSubmitting(true); + await api.relayStationCreate(formData); + alert(t('relayStation.createSuccess')); + onSuccess(); + } catch (error) { + console.error('Failed to create station:', error); + alert(t('relayStation.createFailed')); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + {t('relayStation.createTitle')} + +
+
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('relayStation.namePlaceholder')} + /> +
+ +
+ + +
+
+ +
+ +