Merge pull request #105 from lwmacct/dev/260111-2255

feat: 添加开发模式 HTTP 桥接,支持浏览器 dev server 与 Tauri 后端通信
This commit is contained in:
coso
2026-01-12 00:06:43 +08:00
committed by GitHub
8 changed files with 516 additions and 3 deletions
+1 -1
View File
@@ -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"] }
+137
View File
@@ -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<serde_json::Value>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Serialize)]
pub struct InvokeResponse {
pub result: Option<serde_json::Value>,
pub error: Option<String>,
}
/// 开发桥接服务器配置
#[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<RwLock<AppState>>,
config: Option<DevBridgeConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
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::<HeaderValue>().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<Arc<RwLock<AppState>>>,
Json(req): Json<InvokeRequest>,
) -> 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"
}))
}
+197
View File
@@ -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<serde_json::Value>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
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::<u16>().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<JsonValue> = 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()),
}
}
+2
View File
@@ -38,6 +38,8 @@ mod commands;
mod config;
mod converter;
mod data;
#[cfg(debug_assertions)]
mod dev_bridge;
mod logger;
mod models;
mod providers;
+22
View File
@@ -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(
+18 -2
View File
@@ -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<any> => {
// 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<any> => {
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);
};
+120
View File
@@ -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<T> 命令执行结果
*/
export async function invokeViaHttp<T = unknown>(
cmd: string,
args?: unknown,
): Promise<T> {
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<boolean> true 如果连接成功
*/
export async function healthCheck(): Promise<boolean> {
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",
};
}
+19
View File
@@ -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";