diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a980d05c..93c380363 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ axum = { version = "0.7", features = ["ws"] } axum-server = { version = "0.7", features = ["tls-rustls"] } rustls-pemfile = "2" tower = "0.4" -tower-http = { version = "0.5", features = ["limit"] } +tower-http = { version = "0.5", features = ["limit", "cors"] } reqwest = { version = "0.12", features = ["json", "stream"] } uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs new file mode 100644 index 000000000..6cfa4d591 --- /dev/null +++ b/src-tauri/src/dev_bridge.rs @@ -0,0 +1,137 @@ +//! HTTP 桥接模块 +//! +//! 仅在开发模式下启用,允许浏览器 dev server 通过 HTTP 调用 Tauri 命令。 +//! +//! 这是一个独立的开发服务器,运行在 3030 端口,与主应用服务器(8999)分离。 + +#[cfg(debug_assertions)] +pub mod dispatcher; + +#[cfg(debug_assertions)] +use axum::{ + extract::State, + http::HeaderValue, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +#[cfg(debug_assertions)] +use serde::{Deserialize, Serialize}; +#[cfg(debug_assertions)] +use std::sync::Arc; +#[cfg(debug_assertions)] +use tokio::sync::RwLock; +#[cfg(debug_assertions)] +use tower_http::cors::CorsLayer; + +use crate::server::AppState; + +#[cfg(debug_assertions)] +#[derive(Debug, Deserialize)] +pub struct InvokeRequest { + pub cmd: String, + #[serde(default)] + pub args: Option, +} + +#[cfg(debug_assertions)] +#[derive(Debug, Serialize)] +pub struct InvokeResponse { + pub result: Option, + pub error: Option, +} + +/// 开发桥接服务器配置 +#[cfg(debug_assertions)] +pub struct DevBridgeConfig { + /// 监听地址 + pub host: String, + /// 监听端口 + pub port: u16, +} + +#[cfg(debug_assertions)] +impl Default for DevBridgeConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 3030, + } + } +} + +/// 开发桥接服务器 +#[cfg(debug_assertions)] +pub struct DevBridgeServer; + +#[cfg(debug_assertions)] +impl DevBridgeServer { + /// 启动开发桥接服务器 + /// + /// 这是一个独立的 HTTP 服务器,仅用于开发模式, + /// 允许浏览器 dev server 通过 HTTP 调用 Tauri 命令。 + /// + /// 服务器会在后台持续运行,直到应用退出。 + pub async fn start( + app_state: Arc>, + config: Option, + ) -> Result<(), Box> { + let config = config.unwrap_or_default(); + + let app = Router::new() + .route("/invoke", post(invoke_command)) + .route("/health", post(health_check)) + .layer( + // CORS 配置 - 允许 localhost:1420 访问 + CorsLayer::new() + .allow_origin("http://localhost:1420".parse::().unwrap()) + .allow_methods([axum::http::Method::POST, axum::http::Method::GET]) + .allow_headers([axum::http::header::CONTENT_TYPE]), + ) + .with_state(app_state); + + let addr = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + eprintln!("[DevBridge] 正在监听: http://{}", addr); + + // 直接运行服务器(不使用 graceful_shutdown) + // 服务器将持续运行直到应用退出 + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + Ok(()) + } +} + +#[cfg(debug_assertions)] +async fn invoke_command( + State(state): State>>, + Json(req): Json, +) -> Response { + // 获取 AppState 的读锁 + let state_ref = state.read().await; + // 调用命令分发器 + match dispatcher::handle_command(&state_ref, &req.cmd, req.args).await { + Ok(result) => Json(InvokeResponse { + result: Some(result), + error: None, + }) + .into_response(), + Err(e) => Json(InvokeResponse { + result: None, + error: Some(e.to_string()), + }) + .into_response(), + } +} + +#[cfg(debug_assertions)] +async fn health_check() -> impl IntoResponse { + Json(serde_json::json!({ + "status": "ok", + "service": "DevBridge", + "version": "1.0.0" + })) +} diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs new file mode 100644 index 000000000..93cb1ff98 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -0,0 +1,197 @@ +//! 命令分发器 +//! +//! 将 HTTP 请求路由到现有的 Tauri 命令函数。 + +use crate::server::AppState; +use serde_json::Value as JsonValue; + +/// 处理 HTTP 桥接命令请求 +/// +/// 将命令名和参数分发到对应的命令处理函数 +pub async fn handle_command( + state: &AppState, + cmd: &str, + args: Option, +) -> Result> { + match cmd { + // ========== P0 - 核心配置 ========== + "get_config" => { + // 从配置文件读取 + let config_path = crate::config::ConfigManager::default_config_path(); + let manager = crate::config::ConfigManager::load(&config_path)?; + let config = manager.config(); + Ok(serde_json::to_value(config)?) + } + + "save_config" => { + // 保存配置到文件 + let config: crate::config::Config = serde_json::from_value(args.unwrap_or_default())?; + crate::config::save_config(&config)?; + Ok(serde_json::json!({ "success": true })) + } + + "get_default_provider" => { + let provider = state.default_provider.read().await.clone(); + // 直接返回字符串值,不是对象 + Ok(serde_json::json!(provider)) + } + + "get_endpoint_providers" => { + let providers = state.endpoint_providers.read().await; + Ok(serde_json::to_value(&*providers)?) + } + + // ========== P0 - 服务器状态 ========== + "get_server_status" => { + // 解析 base_url 获取 host 和 port + let url_parts: Vec<&str> = state.base_url.split(':').collect(); + let host = url_parts.get(2).unwrap_or(&"127.0.0.1"); + let port = url_parts.get(3).and_then(|p| p.parse::().ok()).unwrap_or(3030); + + let status = serde_json::json!({ + "running": true, // HTTP 桥接可用说明服务器在运行 + "host": host, + "port": port, + "api_key": "***", // 不暴露真实 API key + }); + Ok(status) + } + + // ========== P1 - 日志相关 ========== + "get_logs" => { + let logs = state.logs.read().await; + let entries = logs.get_logs(); + // 限制返回最近 100 条 + let limit = entries.len().min(100); + let recent: Vec<_> = entries.into_iter().rev().take(limit).map(|e| serde_json::json!({ + "timestamp": e.timestamp, + "level": e.level, + "message": e.message, + })).collect(); + Ok(serde_json::to_value(recent)?) + } + + "clear_logs" => { + state.logs.write().await.clear(); + Ok(serde_json::json!({ "success": true })) + } + + // ========== Provider Pool ========== + "get_provider_pool_overview" => { + // 从数据库获取凭证池概览 + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| e.to_string())?; + let credentials = crate::database::dao::provider_pool::ProviderPoolDao::get_all(&conn) + .unwrap_or_default(); + + let overview: Vec = credentials + .into_iter() + .map(|cred| serde_json::json!({ + "uuid": cred.uuid, + "name": cred.name, + "provider_type": cred.provider_type, + "enabled": true, + })) + .collect(); + Ok(serde_json::to_value(overview)?) + } else { + Ok(serde_json::json!([])) + } + } + + "get_api_key_providers" => { + // 从 API Key Provider 服务获取 + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| e.to_string())?; + let providers = crate::database::dao::api_key_provider::ApiKeyProviderDao::get_all_providers(&conn) + .unwrap_or_default(); + Ok(serde_json::to_value(providers)?) + } else { + Ok(serde_json::json!([])) + } + } + + "get_provider_pool_credentials" => { + // 获取所有凭证详细信息 + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| e.to_string())?; + let credentials = crate::database::dao::provider_pool::ProviderPoolDao::get_all(&conn) + .unwrap_or_default(); + Ok(serde_json::to_value(credentials)?) + } else { + Ok(serde_json::json!([])) + } + } + + "test_api" => { + // 测试 API 连接 + // 从 args 获取 provider + let args = args.ok_or("缺少参数")?; + let provider = args + .get("provider") + .and_then(|v| v.as_str()) + .ok_or("缺少 provider 参数")?; + + // 选择凭证 + let credential = if let Some(db) = &state.db { + state + .pool_service + .select_credential(db, provider, None) + .ok() + .flatten() + } else { + None + }; + + match credential { + Some(cred) => { + state + .logs + .write() + .await + .add("info", &format!("[DevBridge] 测试 API 使用凭证: {:?}", cred.name)); + + Ok(serde_json::json!({ + "success": true, + "credential_name": cred.name, + "provider_type": cred.provider_type, + })) + } + None => Ok(serde_json::json!({ + "success": false, + "error": "未找到可用凭证" + })), + } + } + + // ========== 模型相关 ========== + "get_models" => { + // 返回可用模型列表 + Ok(serde_json::json!({ + "data": [ + {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "claude-opus-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "claude-haiku-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "gpt-4o", "object": "model", "owned_by": "openai"}, + {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, + ] + })) + } + + // ========== 网络信息 ========== + "get_network_info" => { + // 返回网络信息 + Ok(serde_json::json!({ + "localhost": "127.0.0.1", + "lan_ip": null, + "all_ips": ["127.0.0.1"] + })) + } + + _ => Err(format!( + "[DevBridge] 未知命令: '{}'. 如需此命令,请将其添加到 dispatcher.rs 的 handle_command 函数中。", + cmd + ) + .into()), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 244f1c624..99576579f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,8 @@ mod commands; mod config; mod converter; mod data; +#[cfg(debug_assertions)] +mod dev_bridge; mod logger; mod models; mod providers; diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index fcc4d5668..27635ce1b 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -866,6 +866,28 @@ async fn run_server( api_key_service, }; + // ========== 开发模式:启动独立的 HTTP 桥接服务器 ========== + // 仅在 debug 模式下,启动一个独立的开发服务器在端口 3030 + // 允许浏览器 dev server 通过 HTTP 调用 Tauri 命令 + #[cfg(debug_assertions)] + { + eprintln!("[DevBridge] ===== 准备启动开发桥接服务器 ====="); + use tokio::sync::RwLock as TokioRwLock; + let dev_bridge_state = Arc::new(TokioRwLock::new(state.clone())); + eprintln!("[DevBridge] 状态已克隆,准备启动"); + tokio::spawn(async move { + eprintln!("[DevBridge] spawn 任务开始执行"); + match crate::dev_bridge::DevBridgeServer::start(dev_bridge_state, None).await { + Ok(_) => { + eprintln!("[DevBridge] 启动完成"); + } + Err(e) => { + eprintln!("[DevBridge] 启动失败: {}", e); + } + } + }); + } + // 启动配置文件监控 let _file_watcher = if let Some(path) = config_path { start_config_watcher( diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 854b4e196..9cd5bd2e1 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -1,10 +1,12 @@ // Import mock invoke directly - Vite alias will replace @tauri-apps/api/core // with the mock module in web mode import { invoke as baseInvoke } from "@tauri-apps/api/core"; +import { invokeViaHttp, isDevBridgeAvailable } from "@/lib/dev-bridge"; // Safe Tauri invoke wrapper for web mode compatibility +// 支持三种模式:Tauri IPC → HTTP Bridge → Mock const safeInvoke = async (cmd: string, args?: any): Promise => { - // Check if Tauri is available via window.__TAURI__ + // 1. 优先使用 Tauri IPC (生产环境或 Tauri webview 可用时) if ( typeof window !== "undefined" && (window as any).__TAURI__?.core?.invoke @@ -17,7 +19,21 @@ const safeInvoke = async (cmd: string, args?: any): Promise => { return (window as any).__TAURI__.invoke(cmd, args); } - // Not in Tauri environment - use the imported invoke (mock in web mode) + // 2. Dev 模式下尝试 HTTP 桥接(浏览器环境,Tauri 后端在运行) + if (isDevBridgeAvailable()) { + try { + console.log(`[DevBridge] 尝试 HTTP 桥接: ${cmd}`); + const result = await invokeViaHttp(cmd, args); + console.log(`[DevBridge] HTTP 桥接成功: ${cmd}`); + return result; + } catch (e) { + console.warn(`[DevBridge] HTTP 桥接失败: ${cmd}`, e); + // 继续尝试 mock + } + } + + // 3. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入) + console.log(`[DevBridge] 使用 mock: ${cmd}`); return baseInvoke(cmd, args); }; diff --git a/src/lib/dev-bridge/http-client.ts b/src/lib/dev-bridge/http-client.ts new file mode 100644 index 000000000..7b2340d28 --- /dev/null +++ b/src/lib/dev-bridge/http-client.ts @@ -0,0 +1,120 @@ +/** + * 开发桥接 HTTP 客户端 + * + * 在开发模式下,当 Tauri IPC 不可用时(浏览器环境), + * 通过 HTTP 与运行中的 Tauri 后端通信。 + */ + +const BRIDGE_URL = "http://127.0.0.1:3030/invoke"; + +export interface InvokeRequest { + cmd: string; + args?: unknown; +} + +export interface InvokeResponse { + result?: unknown; + error?: string; +} + +/** + * 检查开发桥接是否可用 + * + * @returns true 如果在 dev 模式且 Tauri 不可用 + */ +export function isDevBridgeAvailable(): boolean { + // 检查是否在浏览器环境(非 Tauri webview) + const isBrowser = + typeof window !== "undefined" && + !(window as any).__TAURI__ && + // 进一步检查是否在开发模式 + (import.meta.env.DEV || + location.hostname === "localhost" || + location.hostname === "127.0.0.1"); + + return isBrowser; +} + +/** + * 通过 HTTP 桥接调用 Tauri 命令 + * + * @param cmd - 命令名称 + * @param args - 命令参数 + * @returns Promise 命令执行结果 + */ +export async function invokeViaHttp( + cmd: string, + args?: unknown, +): Promise { + console.log(`[DevBridge] HTTP 调用: ${cmd}`, args); + + try { + const response = await fetch(BRIDGE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ cmd, args } satisfies InvokeRequest), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data: InvokeResponse = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + return data.result as T; + } catch (e) { + console.error(`[DevBridge] HTTP 调用失败: ${cmd}`, e); + throw e; + } +} + +/** + * 健康检查 - 测试与后端的连接 + * + * @returns Promise true 如果连接成功 + */ +export async function healthCheck(): Promise { + try { + const response = await fetch(BRIDGE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + cmd: "get_server_status", + } satisfies InvokeRequest), + }); + return response.ok; + } catch { + return false; + } +} + +/** + * 获取桥接状态信息 + */ +export interface BridgeStatus { + available: boolean; + connected: boolean; + mode: "tauri" | "http" | "mock"; +} + +/** + * 获取当前桥接状态 + */ +export function getBridgeStatus(): BridgeStatus { + const hasTauri = typeof window !== "undefined" && (window as any).__TAURI__; + const devAvailable = isDevBridgeAvailable(); + + return { + available: hasTauri || devAvailable, + connected: hasTauri, // Tauri 总是连接的,HTTP 需要运行时检查 + mode: hasTauri ? "tauri" : devAvailable ? "http" : "mock", + }; +} diff --git a/src/lib/dev-bridge/index.ts b/src/lib/dev-bridge/index.ts new file mode 100644 index 000000000..9096d0404 --- /dev/null +++ b/src/lib/dev-bridge/index.ts @@ -0,0 +1,19 @@ +/** + * 开发桥接模块 + * + * 提供浏览器开发服务器与 Tauri 后端的 HTTP 通信桥接。 + * + * @module dev-bridge + */ + +export { + invokeViaHttp, + isDevBridgeAvailable, + healthCheck, + getBridgeStatus, +} from "./http-client"; +export type { + InvokeRequest, + InvokeResponse, + BridgeStatus, +} from "./http-client";