feat: 前端静态文件嵌入二进制

- 添加 rust-embed 依赖
- 创建 static_files.rs 模块,编译时嵌入 static/ 目录
- 修改 gateway 路由,默认使用嵌入文件
- 支持 STATIC_DIR 环境变量切换到磁盘文件(开发模式)
- 更新 README 说明 Web UI 和构建流程
This commit is contained in:
ooodc 2026-05-30 17:43:17 +08:00
parent 1288ba268f
commit 9b6cae0803
4 changed files with 117 additions and 9 deletions

View File

@ -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"

View File

@ -846,8 +846,22 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由
- /health健康检查
- /wsCLI 客户端连接入口
- /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

View File

@ -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()
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());
.with_state(state.clone())
};
let addr = format!("{}:{}", bind_host, bind_port);
let listener = TcpListener::bind(&addr).await?;

View File

@ -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<Body> {
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()
}
}
}