diff --git a/Cargo.toml b/Cargo.toml index 7da5d2d..da075da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,4 @@ rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "mai schemars = "1.0" http = "1" tower-http = { version = "0.6", features = ["fs"] } +rust-embed = "8" diff --git a/README.md b/README.md index 6f9db5b..3129e73 100644 --- a/README.md +++ b/README.md @@ -846,8 +846,22 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由 - /health:健康检查 - /ws:CLI 客户端连接入口 +- /:Web UI 前端(已嵌入二进制,无需外部文件) -### 11.3 CLI 使用方式 +### 11.3 Web UI + +PicoBot 内置 Web 前端,在编译时已打包进二进制文件。启动网关后可直接访问: + +```text +http://127.0.0.1:19876/ +``` + +特性: +- 前端代码编译时嵌入 exe,无需携带 `static/` 目录 +- 支持 SPA 前端路由 +- 开发模式下可通过 `STATIC_DIR` 环境变量使用磁盘文件(支持热更新) + +### 11.4 CLI 使用方式 程序提供两个主命令: @@ -989,10 +1003,33 @@ CLI 中已实现的交互命令包括: } ``` -### 13.2 启动网关 +### 13.2 构建与启动 + +**构建(包含前端):** ```bash -cargo run -- gateway +# 使用 Makefile(推荐) +make build + +# 或手动构建 +cd web && npm install && npm run build +cargo build --release +``` + +构建产物: +- 前端输出到 `static/` 目录 +- 编译时通过 `rust-embed` 嵌入二进制文件 +- 最终 exe 约 25MB,包含完整前端 + +**启动网关:** + +```bash +# 生产模式(使用嵌入的前端) +cargo run --release -- gateway + +# 开发模式(使用磁盘文件,支持前端热更新) +cd web && npm run dev & +STATIC_DIR=static cargo run -- gateway ``` ### 13.3 启动本地 CLI diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 0dd73f5..c5d560b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -22,6 +22,7 @@ pub mod session_lifecycle; pub mod session_message_sender; pub mod session_message_service; pub mod session_pool; +pub mod static_files; pub mod tool_registry_factory; pub mod ws; @@ -46,6 +47,7 @@ use processor::InboundProcessor; use runtime::build_session_manager_with_sender; use session_message_sender::BusSessionMessageSender; use session::SessionManager; +use static_files::static_handler; pub struct GatewayState { pub config: Config, @@ -184,13 +186,24 @@ pub async fn run( let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone()); let bind_port = port.unwrap_or(state.config.gateway.port); - let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()); + // 使用嵌入的静态文件(编译时打包进二进制) + // 开发模式下可通过 STATIC_DIR 环境变量使用磁盘文件 + let use_embedded = std::env::var("STATIC_DIR").is_err(); - let app = Router::new() - .route("/health", routing::get(http::health)) - .route("/ws", routing::get(ws::ws_handler)) - .fallback_service(ServeDir::new(&static_dir)) - .with_state(state.clone()); + let app = if use_embedded { + Router::new() + .route("/health", routing::get(http::health)) + .route("/ws", routing::get(ws::ws_handler)) + .fallback(static_handler) + .with_state(state.clone()) + } else { + let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()); + Router::new() + .route("/health", routing::get(http::health)) + .route("/ws", routing::get(ws::ws_handler)) + .fallback_service(ServeDir::new(&static_dir)) + .with_state(state.clone()) + }; let addr = format!("{}:{}", bind_host, bind_port); let listener = TcpListener::bind(&addr).await?; diff --git a/src/gateway/static_files.rs b/src/gateway/static_files.rs new file mode 100644 index 0000000..1b6b790 --- /dev/null +++ b/src/gateway/static_files.rs @@ -0,0 +1,57 @@ +use axum::{ + body::Body, + http::{header, Response, StatusCode, Uri}, +}; +use rust_embed::RustEmbed; + +/// 嵌入的静态文件资源 +/// 在编译时将 static 目录下的所有文件打包进二进制文件 +#[derive(RustEmbed)] +#[folder = "static/"] +pub struct StaticAssets; + +/// 处理静态文件请求 +/// 从嵌入的资源中读取文件并返回 HTTP 响应 +pub async fn static_handler(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + + // 处理根路径,返回 index.html + let path = if path.is_empty() { + "index.html" + } else { + path + }; + + match StaticAssets::get(path) { + Some(content) => { + let mime_type = mime_guess::from_path(path) + .first_or_octet_stream() + .as_ref() + .to_string(); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type) + .body(Body::from(content.data.into_owned())) + .unwrap() + } + None => { + // 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html + // 静态资源通常包含 . (如 .js, .css, .png) + if !path.contains('.') { + if let Some(index) = StaticAssets::get("index.html") { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from(index.data.into_owned())) + .unwrap(); + } + } + + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("404 Not Found")) + .unwrap() + } + } +} \ No newline at end of file