atomcode WebUI 实现全解:从 daemon 库化到 TUI⇄浏览器实时同步的进程内 Agent 入口
atomcode WebUI 实现全解:从 daemon 库化到 TUI⇄浏览器实时同步的进程内 Agent 入口
本文基于 atomcode 仓库中的实施计划 2026-05-29-webui.md 展开:它完整还原了「让用户在 TUI 输入 /webui 或命令行执行 atomcode webui 即可在本地浏览器中聊天、运行 agent、交互式批准工具调用」这一并行入口的设计目标、六大里程碑任务拆分(daemon 库化、一次性 token 鉴权、静态资源嵌入、交互式权限流、启动入口、前端 SPA、工作目录切换、TUI⇄webui 实时同步),并结合当前仓库中 atomcode-daemon 与 webui/ 的真实落地源码,逐项印证计划如何演进为可用实现。读完后你能掌握「把独立服务下沉为库函数、在同一进程内起 HTTP 服务」的完整方法,以及 SSE 双向权限审批、rust-embed 资源嵌入、一次性 token→HttpOnly Cookie 鉴权交接等可复用工程手段。
一、定位、总体架构与已核实前提
1.1 目标与架构一句话
计划文档开篇给出的 Goal 是:让用户在 TUI 输入 /webui(或命令行 atomcode webui)即可在本地浏览器中聊天、运行 agent、交互式批准工具调用——作为 TUI 之外的并行入口。
Architecture 核心决策(摘自计划文档):
- 把
atomcode-daemon的服务逻辑下沉为库函数run_server(...)(原二进制变薄壳),atomcode主程序依赖它,/webui在进程内tokio::spawn(run_server)起服务——不 spawn 子进程、不依赖独立 daemon 二进制(普通用户curl|sh只装主程序)。 - 前端用 Preact + Vite + Tailwind 构建为静态资源,经
rust-embed嵌入并由run_server在/提供。 /chat把权限从BypassAll换成已有的InteractivePermissionDecider,SSE 推permission_request、新增POST /chat/permission回送决定。- 每会话独立
working_dir(随/chat请求带),新增GET /fs/list供目录浏览。 /webui命令:起 server(若未起)+ mint token + 开浏览器。- Phase 2 官方中转隧道仅留可插拔鉴权接口。
Tech Stack:Rust / axum 0.7 / tokio / rust-embed;前端 Preact + Vite + Tailwind;复用核心库的 InteractivePermissionDecider、TurnRunner、open_browser。
设计文档位于 2026-05-29-webui-design.md,与本篇计划文档配套。
1.2 重要前提(计划中「已核实」的事实)
计划文档特别强调了几个决定架构走向的前提:
atomcode主程序已是#[tokio::main](计划核实点为当时 CLImain.rs:698),依赖含tokio(full)+reqwest——进程内起 tokio 服务无额外成本。install.sh每平台只下载主程序、不含 daemon 二进制——故 server 必须库化供主程序进程内调用,否则普通用户装不上 webui。atomcode-cli原本不依赖 daemon crate;daemon→core 是既有单向依赖,新增 tuix→daemon、cli→daemon 依赖后均不成环。
这三点解释了「为什么是进程内库调用而不是 spawn 子进程」——它是分发修复的核心:原假设 atomcode-daemon 二进制随主程序分发并不成立,改为进程内库调用后,普通用户零额外下载即可使用 /webui。
1.3 计划中的关键既有接口
计划文档列出了实现时直接复用的接口(当时路径 atomcode-core,仓库后续重构为 atomcode-capabilities,计划中的 atomcode_core::tool::PermissionDecision 对应今天的 atomcode_capabilities::tools::PermissionDecision,见 permission_bridge.rs):
InteractivePermissionDecider::new(request_tx, response_rx, permission_store),ApprovalRequest { call: ToolCall, reason: String };PermissionDecision (Allow | Ask(String) | Deny)、PermissionStore、ToolCall;TurnEvent::ApprovalRequested { tool_name, reason, call, messages }——daemon SSE 循环当时显式丢弃它,webui 把它「捡回来」转发;open_browser(url)(已按平台 cfg);- daemon 现有
origin_is_allowed、is_loopback_authority、activity_tracker_middleware; - daemon 默认
127.0.0.1:13456;AppState为#[derive(Clone)],字段均为Arc<...>共享句柄。
二、文件结构规划
计划文档给出的文件清单(新建/修改)完整如下,是后续所有 Task 的索引:
新建:
webui/(前端工程根):package.json、vite.config.ts、tailwind.config.js、index.html、src/main.tsx、src/app.tsx、src/api.ts、src/components/*.tsx- crates/atomcode-daemon/src/webui.rs:rust-embed 静态资源 handler + SPA fallback
- crates/atomcode-daemon/src/auth_token.rs:一次性 token 存储 + 鉴权中间件
- crates/atomcode-daemon/src/permission_bridge.rs:把 daemon
/chat的待批工具桥接到 SSE +/chat/permission
修改:
- crates/atomcode-daemon/src/lib.rs:暴露
pub async fn run_server(opts: ServerOpts) -> anyhow::Result<()>与build_app_state(...)、pub usetoken/权限类型;把main.rs中 Router 构建 + bind/serve 主体迁进来 - crates/atomcode-daemon/src/main.rs:变薄壳,解析参数后调
atomcode_daemon::run_server(...) - crates/atomcode-daemon/Cargo.toml:加
[lib]、rust-embed依赖 - crates/atomcode-cli/Cargo.toml:加依赖
atomcode-daemon = { path = "../atomcode-daemon" } - crates/atomcode-cli/src/main.rs:
Commands加Webui子命令;进程内 server 单例 - crates/atomcode-tuix/src/event_loop/commands.rs:
match cmd加"webui"分支
launcher 归属决策(计划中的重要推演):tuix 与 cli 都要用 ensure_server_and_open。放 atomcode-cli 会让 tuix 反向依赖 cli(不可);放 core 会成环(daemon 依赖 core)。结论:放 atomcode-daemon 库里(pub fn ensure_server_and_open),core/tuix/cli 都能依赖 daemon 库调它。实现中该函数确实位于 lib.rs 的 ensure_server_and_open。
三、Milestone -1:daemon 库化(最先做,后续一切依赖它)
3.1 Task 0:把 server 逻辑抽成 run_server 库函数
计划要求三步走:
Step 1:加 [lib] 段。 crates/atomcode-daemon/Cargo.toml 在 [package] 后加:
[lib]
name = "atomcode_daemon"
path = "src/lib.rs"
[[bin]]
name = "atomcode-daemon"
path = "src/main.rs"
Step 2:抽 run_server。 把 main.rs 中「构建 Router + AppState + bind + axum::serve」的主体迁到 lib.rs:
//! atomcode-daemon 既是独立二进制,也是库:`run_server` 供主程序进程内调用,
//! 使 `/webui` 无需第二个二进制。
pub struct ServerOpts {
pub host: String,
pub port: u16,
pub cli_override: Option<CliOverride>,
pub idle_timeout_secs: Option<u64>,
pub startup_mode: SessionMode,
}
/// 构建并运行 axum server,直到收到 shutdown。供二进制 main 与主程序 /webui 共用。
pub async fn run_server(opts: ServerOpts) -> anyhow::Result<()> {
// 原 main.rs 中 AppState 构建 + Router::new()....with_state(state)
// + TcpListener::bind + axum::serve(...).with_graceful_shutdown(...) 全部迁来。
Ok(())
}
Step 3:main.rs 变薄壳。 main() 保留 parse_daemon_args() 与平台 cfg,末尾改为:
#[tokio::main]
async fn main() {
let (host, port, cli_override, idle_timeout_secs, startup_mode) = parse_daemon_args();
if let Err(e) = atomcode_daemon::run_server(atomcode_daemon::ServerOpts {
host, port, cli_override, idle_timeout_secs, startup_mode,
}).await {
eprintln!("daemon error: {e}");
std::process::exit(1);
}
}
迁移是机械重构:把私有
fn/struct标pub/pub(crate),逐个编译错误修,不改任何行为。回归方式:cargo build -p atomcode-daemon && cargo test -p atomcode-daemon,并用curl -s http://127.0.0.1:13457/health冒烟验证行为不变。
3.2 实现落地的 ServerOpts:计划骨架 + 现场扩展
从源码结构看,库化完成后 ServerOpts 已比计划初稿长出多个字段,逐一支撑了后续需求(见 lib.rs#L6532-L6562):
pub struct ServerOpts {
pub host: String, // 非回环地址会触发安全告警
pub port: u16,
pub cli_override: CliOverride,
pub idle_timeout_secs: u64, // 0 关闭空闲看门狗
pub startup_mode: SessionMode,
/// WebUI token store。`None` 禁用 API token 鉴权。
pub webui_tokens: Option<auth_token::WebuiTokenStore>,
/// 启动时工作目录覆盖:进程内 `atomcode webui` 传入其启动 cwd。
pub working_dir_override: Option<PathBuf>,
/// 安静模式:不打印启动横幅(TUI 进程内启动时避免污染画面)。
pub quiet: bool,
/// 预绑定监听器:启动器先绑定端口(拿到真实端口、支持动态端口)再传入。
pub prebound_listener: Option<tokio::net::TcpListener>,
pub app_user_id: Option<String>,
pub daemon_token_file: Option<String>,
}
其中 webui_tokens: Option<WebuiTokenStore> 正是计划 Task 9 注中要求的贯穿字段:Some 时 run_server 用它构建 AppState(与启动器共享同一 store,token 才能跨「mint 端 / 校验端」一致),None(独立二进制/VSCode)时内部新建。run_server 入口还先执行一段「拒绝在未认证的远程绑定上启动」的 guard(lib.rs#L6615-L6639),这是计划之外补上的安全护栏。
四、Milestone 0:后端鉴权 token(先做,因为后续路由都要它)
4.1 Task 1:一次性 token 存储(纯函数,可单测)
计划先用 TDD 写失败测试:mints_and_validates_token、rejects_unknown_token(空串始终无效)、mint_is_unique,再实现:
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
/// 进程内有效 webui token 集合。线程安全,可放进 `AppState`。
#[derive(Clone, Default)]
pub struct WebuiTokenStore {
inner: Arc<RwLock<HashSet<String>>>,
}
impl WebuiTokenStore {
pub fn new() -> Self { Self::default() }
/// 生成并登记一个新 token,返回其字符串。
pub fn mint(&self) -> String {
let token = Uuid::new_v4().simple().to_string();
self.inner.write().unwrap().insert(token.clone());
token
}
/// 校验 token 是否有效。空串始终无效。
pub fn is_valid(&self, token: &str) -> bool {
if token.is_empty() { return false; }
self.inner.read().unwrap().contains(token)
}
}
Phase 1 语义:token 随 /webui 启动生成,仅存内存、随进程退出失效;Phase 2(官方中转隧道)会把账号 token 接入同一条 is_valid 校验链——鉴权统一收口在本模块,路由层只调 require_webui_token 中间件。
4.2 Task 2:token 鉴权中间件 + 接进 AppState 与 Router
纯函数 token_from_header 便于单测(避免造 Request):
/// 从 `Authorization` 头解析 Bearer token(大小写不敏感前缀)。
pub fn token_from_header(value: Option<&str>) -> Option<String> {
let v = value?;
let rest = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))?;
let rest = rest.trim();
if rest.is_empty() { None } else { Some(rest.to_string()) }
}
axum 0.7 中间件 require_webui_token 要求请求带有效 webui token,否则 401;计划特别提示:中间件签名若与仓库现有 activity_tracker_middleware 不一致,先读现状再照抄形状。AppState 加 webui_tokens: WebuiTokenStore 字段并初始化。
4.3 实现演进:?token= → HttpOnly Cookie 交接与按端口命名的 Cookie
实现落地时,计划中「token 放 URL query、由前端 JS 读取」的方案升级为更安全的交接链,这是计划文本里没有、值得单独讲的两处演进:
(1)?token= 一次性交接为 HttpOnly Cookie。 源码注释(lib.rs#L1903-L1920)明确:若直接把 index.html 从 http://host:port/?token=<uuid> 这个 URL 供出,token 会留在地址栏与浏览器历史里,恶意扩展可读 location.search(CWE-598)。因此 serve_webui_index(lib.rs#L1921-L1951)在检测到有效 ?token= 时,把 token 写进 HttpOnly Cookie 并 302 到无 token 的 URL:
let cookie = format!(
"{}={}; Path=/; HttpOnly; SameSite=Strict",
state.webui_cookie_name, token
);
return axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, cookie)
.body(axum::body::Body::empty())
.map(IntoResponse::into_response)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
Secure 属性被有意省略——webui 跑在 localhost/LAN 的纯 HTTP 上,Secure cookie 根本不会被发送;HttpOnly(挡 JS/扩展读取)+ SameSite=Strict(挡跨站发送)承担保护。
(2)按端口作用域的 Cookie 名。 auth_token.rs#L34-L44 记录了另一个真实 bug 的修复:RFC 6265 下 localhost 的 Cookie 不区分端口,裸名 atomcode_webui 会被所有 localhost 实例共享;第二个 /webui 实例的 /?token= 交接会覆盖第一个页面的 Cookie,导致第一页所有请求 401。解法是把 Cookie 名按实例实际绑定端口作用域化:
pub fn webui_cookie_name(port: u16) -> String {
format!("{WEBUI_COOKIE}_{port}") // 如 atomcode_webui_13457
}
配套测试 two_instances_on_different_ports_do_not_collide(auth_token.rs#L286-L304)验证:同一 Cookie jar 里 atomcode_webui_1111=tokenA; atomcode_webui_2222=tokenB,两个实例各读各的,第三方实例读到 None(→ 401,正确)。
(3)中间件双通道校验。 最终版 require_webui_token(auth_token.rs#L115-L144)接受 Authorization: Bearer 头(编程客户端、旧版 URL-token 前端)或 HttpOnly Cookie(/?token= 交接后的主载体,同源 fetch/EventSource 自动携带),头优先;并新增 state.enforce_token 开关——只有 --no-auth 或 App 中继路径(webui_tokens=None)才直接放行,默认独立 daemon / IDE 守护进程 enforce_token=true 继续做 token 校验。这与计划 Task 8「兼容既有 VSCode 客户端、不要简单 401」的兼容策略要求一致。
五、Milestone 1:静态资源嵌入
5.1 Task 3:rust-embed 依赖 + 占位产物
crates/atomcode-daemon/Cargo.toml 加 rust-embed = { version = "8", features = ["mime-guess"] },并放占位 webui/dist/index.html(webui placeholder),保证 cargo build 在真实前端构建之前也能通过。
5.2 Task 4:静态资源 handler + SPA fallback
计划给出的核心实现(与当前 webui.rs 高度一致):
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../webui/dist/"]
pub struct WebuiAssets;
/// 取静态资源;未命中时回退 index.html 的内容(SPA)。
pub fn asset_or_index(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
let p = path.trim_start_matches('/');
if let Some(f) = WebuiAssets::get(p) {
return Some(f.data);
}
WebuiAssets::get("index.html").map(|f| f.data)
}
/// axum handler:服务任意 webui 路径。
pub async fn serve_webui(uri: Uri) -> Response {
let path = uri.path();
let p = path.trim_start_matches('/');
let lookup = if p.is_empty() { "index.html" } else { p };
match WebuiAssets::get(lookup) {
Some(content) => {
let mime = mime_guess::from_path(lookup).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
}
None => match WebuiAssets::get("index.html") {
Some(index) => ([(header::CONTENT_TYPE, "text/html")], index.data).into_response(),
None => (StatusCode::NOT_FOUND, "webui not built").into_response(),
},
}
}
Router 接线(放在所有 API 路由之后、.with_state 之前):
.route("/", get(webui::serve_webui))
.route("/webui-app/*path", get(webui::serve_webui))
.fallback(webui::serve_webui)
/与/webui-app/*是页面与资源;fallback 兜住 SPA 深链。axum 精确路由优先,fallback 只接未匹配的,不会吞掉 API 路由。手动验证:curl -s http://127.0.0.1:13457/ | grep -q "webui placeholder" && echo OK。
5.3 实现演进:#[allow_missing] + 启动前 is_built() 检查
落地版补上了「资源缺失时不要让用户开浏览器才看到报错」的体验(webui.rs#L15-L34):
#[allow_missing = true]:webui/dist/缺席时照样编译通过,缺失只在运行期以 404 暴露;is_built():WebuiAssets::get("index.html").is_some(),命令启动前就能说清楚;NOT_BUILT_HELP常量给出修复步骤(cd webui && npm install && npm run build后cargo build -p atomcode),命令启动前与运行期 404 共用同一段文案。
CLI 侧确实照此执行(main.rs#L2001-L2007):is_built() 为假时直接打印帮助并以退出码 1 失败,在绑定端口和开浏览器之前——因为「每一页都是 404」的成因是构建步骤,不是运行中的 server 能恢复的。dev 模式也在 handler 开头实现了计划的 ATOMCODE_WEBUI_DEV 重定向:设置后 302 到 vite dev server 的原 path,享受热更新(webui.rs#L49-L52)。
六、Milestone 2:交互式权限流(webui 的灵魂)
6.1 Task 5:权限桥接通道 PermissionResponders
桥接思路(计划原文):/chat 为每个 session 建一对 channel。InteractivePermissionDecider 的 request_tx 在工具待批时发 ApprovalRequest;daemon SSE 循环转发 ApprovalRequested 给前端;前端 POST /chat/permission 时,handler 通过 pending_permissions(session_id -> mpsc::Sender<PermissionDecision>)把决定送回 decider 的 response_rx。
测试先行:
#[tokio::test]
async fn routes_decision_to_registered_session() {
let reg = PermissionResponders::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
reg.register("sess-1".into(), tx);
assert!(reg.deliver("sess-1", PermissionDecision::Allow));
assert!(matches!(rx.recv().await, Some(PermissionDecision::Allow)));
}
#[test]
fn deliver_to_unknown_session_returns_false() {
let reg = PermissionResponders::new();
assert!(!reg.deliver("nope", PermissionDecision::Deny));
}
实现:PermissionResponders 以 Arc<RwLock<HashMap<String, UnboundedSender<...>>>> 持有 register / unregister / deliver 三操作;AppState 加 pending_permissions: PermissionResponders。当前源码(permission_bridge.rs#L32-L109)保持了这一骨架,并演进为携带语义更丰富的答案类型:
/// 人的批准答案:决定 + 是否「本会话总是允许该 MCP 工具」。
/// 等待答案的 runtime 亲自执行,故标志随决定一起走。
pub struct ChatPermission {
pub decision: PermissionDecision,
pub persist_mcp_tool: bool,
}
此外还新增了 UserInputResponders(permission_bridge.rs#L40-L84):key 为 (session_id, request_id) 的 oneshot 通道,deliver 在发送前先 remove——只送达一次,拒绝重复/过期 POST,这是计划中「structured user-input」扩展的落地形态。
6.2 Task 6:chat_stream 改用 InteractivePermissionDecider + 转发 ApprovalRequested
计划给出的替换代码(把 AutoPermissionDecider::new(AutoPermissionMode::BypassAll) 换成交互式构造):
// webui 交互式权限:建一对 channel,request 经 SSE 推给前端,
// 决定经 /chat/permission 回送。非交互客户端(无人应答)会在
// PERMISSION_TIMEOUT 后默认拒绝(见 SSE 循环转发处)。
use atomcode_core::turn::permission::{ApprovalRequest, InteractivePermissionDecider};
use atomcode_core::tool::PermissionStore;
let (perm_req_tx, mut perm_req_rx) =
tokio::sync::mpsc::unbounded_channel::<ApprovalRequest>();
let (perm_resp_tx, perm_resp_rx) =
tokio::sync::mpsc::unbounded_channel::<atomcode_core::tool::PermissionDecision>();
let perm_store = std::sync::Arc::new(std::sync::RwLock::new(PermissionStore::new()));
state.pending_permissions.register(session_id_str.clone(), perm_resp_tx);
let permission = Box::new(InteractivePermissionDecider::new(
perm_req_tx, perm_resp_rx, perm_store,
));
SSE 循环中原本被丢弃的 ApprovalRequested 分支替换为转发(序列化为 permission_request SSE 事件,不带 messages 这类大字段):
TurnEvent::ApprovalRequested { tool_name, reason, call, .. } => {
let payload = serde_json::json!({
"type": "permission_request",
"session_id": session_id_str,
"tool_name": tool_name,
"reason": reason,
"call_id": call.id,
"arguments": call.arguments,
});
yield Ok::<_, std::convert::Infallible>(
axum::response::sse::Event::default().data(payload.to_string())
);
}
计划同时提醒:ToolCall 字段名、yield 的 stream 宏形式需现场对齐(grep "pub struct ToolCall" 确认字段);turn 结束/错误清理处加 state.pending_permissions.unregister(&session_id_str),与现有 chat_tasks/stopped_sessions 清理放一起。
6.3 Task 7:POST /chat/permission 端点
计划初版 handler:
#[derive(Debug, Deserialize)]
pub struct PermissionDecisionRequest {
pub session_id: String,
/// "allow" | "deny" | "always_allow"
pub decision: String,
}
async fn chat_permission(
State(state): State<AppState>,
Json(req): Json<PermissionDecisionRequest>,
) -> impl IntoResponse {
use atomcode_core::tool::PermissionDecision;
let decision = match req.decision.as_str() {
"allow" => PermissionDecision::Allow,
"always_allow" => PermissionDecision::Allow, // 见注
_ => PermissionDecision::Deny,
};
// 注:always_allow 的「本会话该工具不再问」由 PermissionStore 承担;
// Phase 1 先按 Allow 处理——此处保持决定路由职责单一。
if state.pending_permissions.deliver(&req.session_id, decision) {
Json(serde_json::json!({ "success": true }))
} else {
Json(serde_json::json!({ "success": false, "error": "no pending permission for session" }))
}
}
端到端手测(脚本化):起 server 后,对不存在的 session 发 deny,应得 "success":false:
cargo run -p atomcode-daemon -- --port 13457 &
sleep 2
curl -s -X POST http://127.0.0.1:13457/chat/permission \
-H 'content-type: application/json' \
-d '{"session_id":"ghost","decision":"deny"}' | grep -q '"success":false' && echo OK
kill %1
实现演进:当前 chat_permission 支持第四种决定 allow_persist,且「总是允许 MCP 工具」的语义不再由 daemon 侧处理,而是随 ChatPermission { decision: AllowOnce, persist_mcp_tool: true } 交给提问的 runtime 经其自身 registry 执行——保持了计划注中「决定路由职责单一」的原则:
if req.decision == "allow_persist" {
let ok = state.pending_permissions.deliver(
&req.session_id,
permission_bridge::ChatPermission {
decision: PermissionDecision::AllowOnce,
persist_mcp_tool: true,
},
);
return Json(serde_json::json!({ "success": ok }));
}
let decision = parse_permission_decision(&req.decision);
if state.pending_permissions.deliver(&req.session_id, decision) {
Json(serde_json::json!({ "success": true }))
} else {
Json(serde_json::json!({ "success": false, "error": "no pending permission for session" }))
}
前端 respondPermission 的决策类型也已扩到 'allow' | 'deny' | 'always_allow' | 'allow_persist' | 'allow_all_bash'(api.ts#L257-L276),并刻意区分两类失败:请求本身未送达要抛错(卡片保持打开可再答);success: false 表示问题已被别处答掉或超时(关闭卡片)。
6.4 Task 8:webui 路由套上 token 鉴权中间件
计划强调兼容性关键点:VSCode 扩展也连同一 daemon 且当前无 token,为不破坏既有客户端,Phase 1 仅对 webui 新增的敏感写操作路由(/chat、/chat/permission)要求 token;静态资源 / 不要求(首屏要能加载)。套中间件的方式是对敏感路由分组 route_layer:
let webui_protected = Router::new()
.route("/chat", post(chat_stream))
.route("/chat/permission", post(chat_permission))
.route_layer(axum::middleware::from_fn_with_state(
state.clone(),
auth_token::require_webui_token,
));
再把这些路由从主 Router 移到 webui_protected,.merge(webui_protected)。若 VSCode 仍需无 token 访问,则改为「token 有效 或 旧客户端 header 存在」的放行逻辑(在 require_webui_token 内加 client 判断),不要简单 401。验证标准:不带 token → 401;带有效 token → 进入 handler(400/业务错误)。
落地版的路由分层比计划更彻底(lib.rs#L6739-L6799):公开层只有 /health、/(token 交接)与 SPA fallback;受保护层收编了全部数据端点(/sessions、/project、/cd、/models、/chat、/chat/permission、/chat/user-input、/live、/live/message 等),且 enforce_token 由 webui_tokens.is_some() 决定——独立 daemon 与 IDE 守护进程默认也自持 token(写 daemon-<port>.json,客户端读文件后作为 Bearer 携带),与 webui 共用同一中间件。
七、Milestone 3:启动入口(TUI 命令 + CLI 子命令)
7.1 Task 9:进程内启动器 ensure_server_and_open(放 daemon 库,供 tuix/cli 共用)
进程内模型:server 与调用方在同一进程,故不走 HTTP 健康检查/spawn,而是:用一个进程级句柄持有「server 已起」标记与共享的 WebuiTokenStore;首次调用时 tokio::spawn(run_server(...)),之后直接从同一个 WebuiTokenStore mint() token(无需 HTTP)。计划给出的初版:
use std::sync::OnceLock;
struct WebuiHandle {
tokens: auth_token::WebuiTokenStore,
port: u16,
}
static WEBUI: OnceLock<WebuiHandle> = OnceLock::new();
/// 确保进程内 server 已起,mint 一次性 token,开浏览器。返回给用户的状态串。
pub async fn ensure_server_and_open(port: u16) -> String {
let handle = WEBUI.get_or_init(|| {
let tokens = auth_token::WebuiTokenStore::new();
let opts = ServerOpts {
host: "127.0.0.1".to_string(),
port,
cli_override: None,
idle_timeout_secs: None, // webui 进程内:不空闲自杀
startup_mode: Default::default(),
webui_tokens: Some(tokens.clone()),
};
tokio::spawn(async move {
if let Err(e) = run_server(opts).await {
eprintln!("webui server error: {e}");
}
});
WebuiHandle { tokens, port }
});
let token = handle.tokens.mint();
let url = format!("http://127.0.0.1:{}/?token={}", handle.port, token);
match atomcode_core::auth::oauth::open_browser(&url) {
Ok(()) => format!("已在浏览器打开 webui:{url}"),
Err(_) => format!("请手动在浏览器打开:{url}"),
}
}
计划 Step 2 还要求:起后等待 bind 就绪——循环 TcpStream::connect(("127.0.0.1", port)) 最多约 2 秒(比固定 sleep 稳),仅在本次是 get_or_init 新建时执行。
7.2 实现演进:预绑定 + 端口扫描 + 安全提示
落地版(lib.rs#L5641-L5900)把「轮询等待」进一步简化为同步预绑定,并长出多个实用能力:
- 默认端口刻意错开:
WEBUI_DEFAULT_PORT(lib.rs#L5739-L5749)刻意区别于 IDE 守护进程的 13456。注释解释了根因:进程内 webui 与 IDE 守护进程都默认enforce_token=true且各自把 token 写到按端口命名的daemon-<port>.json;共用 13456 既争抢同一 socket,又让两侧写入同一 token 文件相互覆盖,表现为「daemon started but not responding」。错开到 13457 后端口与 token 文件一起分离。 - 端口扫描:
bind_scanning(host, start_port, 100)(lib.rs#L5693-L5723)从首选端口起遇AddrInUse递增,最多试 100 个端口;真实端口取自local_addr(start_port == 0时也回填 OS 分配值)。非占用类错误(如 Windows 保留端口段的 WSAEACCES)立即返回并附诊断提示。 - 句柄结构:
OnceLock因Mutexguard 不可跨.await、且stop需复位,落地为static WEBUI: std::sync::Mutex<Option<WebuiHandle>>,WebuiHandle增加abort: tokio::task::AbortHandle供/webui stop使用;webui_port()暴露当前服务端口,供atomcode webui在 bind 失败时以非零码退出(避免 launcher 脚本堆积空闲进程)。 - URL 地址选择:回环绑定用
127.0.0.1;通配绑定(0.0.0.0/::,意在暴露到网络)时探测本机局域网 IPv4(UDP connect 选路,不实际发包)作为可跨设备访问的主 URL;绑定具体非回环地址(如 Tailscale 100.x)时必须用该地址,否则浏览器ERR_CONNECTION_REFUSED。同步模式 URL 追加&sync=1。
7.3 Task 9b:/webui TUI 命令分支
计划:crates/atomcode-tuix/Cargo.toml 加 atomcode-daemon 依赖(确认无依赖环);commands.rs 的 match cmd 加:
"webui" => {
// tuix 已在 tokio runtime 内(atomcode_tuix::run 是 async)。
let msg = atomcode_daemon::ensure_server_and_open(13456).await;
ctx.push_system_message(msg); // 显示方式照同文件 "status"/"help" 分支
}
若该
match不在 async 上下文,则改为发事件到事件循环的 async 处理处再.await;push_system_message名称以本文件现有用法为准。命令注册 + 中英 i18n(「启动浏览器 webui」/ "Launch the browser webui")。
落地版(commands.rs#L2330-L2368)确实采用 block_in_place + block_on 包裹 await,解析 --host/lan/0.0.0.0 参数决定绑定地址,并传 sync = true 打开同步模式。
7.4 Task 10:atomcode webui CLI 子命令
计划:Commands enum(紧邻 Daemon)加 Webui { port: u16 }(默认 13456);match 分支:
Commands::Webui { port } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
let msg = atomcode_daemon::ensure_server_and_open(port).await;
eprintln!("{msg}");
// CLI 模式下 server 是后台 task;保持进程存活直到用户 Ctrl+C。
tokio::signal::ctrl_c().await.ok();
return Ok(0);
}
落地版签名扩为 Commands::Webui { port, host, no_open }(main.rs#L1995-L2029),并补了计划之外的两类健壮性:启动前 is_built() 预检(失败即退出码 1);webui_port() 为空时说明 server 未起来,直接以退出码 1 结束——「等 Ctrl+C 而已」正是过去让启动脚本堆积一堆不服务的空闲 atomcode webui 进程的形态。无控制台进程无法监听 Ctrl+C 时记日志并继续服务,而非把进程退出当成失败。
八、Milestone 4:前端 SPA
前端不走 TDD(无单测基建),以「构建通过 + 手动验证清单」为准。每个 Task 末尾
npm run build产物落到webui/dist/(Task 4 的 embed 源目录),随后cargo build -p atomcode-daemon重新嵌入。
8.1 Task 11:工程脚手架(Preact + Vite + Tailwind)
cd webui
npm init -y
npm i preact
npm i -D vite @preact/preset-vite typescript tailwindcss postcss autoprefixer
npx tailwindcss init -p
webui/vite.config.ts(产物到 dist,base 相对):
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
base: './',
build: { outDir: 'dist', emptyOutDir: true },
server: { port: 5173 },
});
webui/index.html 与 src/main.tsx:
<!doctype html>
<html lang="zh">
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>atomcode webui</title></head>
<body><div id="app"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
import { render } from 'preact';
import { App } from './app';
import './index.css';
render(<App />, document.getElementById('app')!);
index.css 三行 Tailwind 指令;tailwind.config.js 的 content: ["./index.html","./src/**/*.{ts,tsx}"]。构建并嵌入:cd webui && npm run build && cd .. && cargo build -p atomcode-daemon。
实现现状:当前 webui/package.json 即该脚手架的演化——依赖 preact ^10.24、vite ^8、tailwindcss ^3.4,另加 marked + dompurify(Markdown 渲染 + 消毒),engines.node >= 22.6,scripts 含 dev/build/preview/typecheck/test。
8.2 Task 12:API 客户端 + token 引导
计划给出的 api.ts 核心(token 从 URL ?token= 读取、存内存不落 localStorage;SSE 流式解析):
const token = new URLSearchParams(location.search).get('token') ?? '';
function authHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function listSessions() {
const r = await fetch('/sessions', { headers: authHeaders() });
return r.json();
}
export async function getConfig() {
const r = await fetch('/config', { headers: authHeaders() });
return r.json();
}
// 发送聊天消息,返回 SSE 流读取器。events 回调收到每条解析后的事件。
export async function streamChat(body: unknown, onEvent: (e: any) => void, signal?: AbortSignal) {
const resp = await fetch('/chat', {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
signal,
});
const reader = resp.body!.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const part of parts) {
const line = part.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (!data) continue;
try { onEvent(JSON.parse(data)); } catch { /* 非 JSON 心跳,忽略 */ }
}
}
}
export async function respondPermission(sessionId: string, decision: 'allow' | 'deny' | 'always_allow') {
await fetch('/chat/permission', {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders() },
body: JSON.stringify({ session_id: sessionId, decision }),
});
}
实现演进(api.ts):
authHeaders无条件附带X-AtomCode-Client: webui,让 daemon 把遥测归因为 webui 来源(resolve_client_mode → SessionMode::Webui);SSEEvent从any演进为判别联合类型,覆盖runtime_info / session_assigned / text / reasoning / tool_start / tool_output / tool_progress / tool_result / tokens / permission_request / user_input_request / done / stopped / error / warning / rate_limited / policy_intervention / artifact_* / command_output等全部线上事件(api.ts#L65-L91)——注释特别指出 artifact 事件族是 daemon 侧ArtifactDetector把围栏代码块从TextDelta拆出后单独发的,不处理就会在 WebUI 静默丢代码块;streamChat增加终态校验:流结束前必须见到done/stopped/error之一,否则抛「chat stream ended before an authoritative terminal event」,防止把半截流当完成态;- 错误处理升级为
failure()+ApiCallError:把 daemon 的{error, code}原文带出来,而不是裸状态码,让设置页等调用方能直接展示可行动的句子。
8.3 Task 13:聊天界面 + 流式渲染
计划:Chat.tsx 实现输入框 + 消息列表 + 发送;对 SSE 事件按 type 累积:
TextDelta/文本增量 → 追加到当前 assistant 气泡;ToolCallStarted/ToolCallResult→ 渲染工具行;permission_request→ 通过 props 回调上抛给 App(Task 14 弹卡片);TokenUsage/Error→ 状态栏/错误提示。
计划给出的骨架组件(含 AbortController、busy 态、Enter 发送、气泡渲染):
export function Chat({ onPermission }: { onPermission: (req: any) => void }) {
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const abort = useRef<AbortController | null>(null);
async function send() {
if (!input.trim() || busy) return;
const userText = input;
setMsgs((m) => [...m, { role: 'user', text: userText }, { role: 'assistant', text: '' }]);
setInput('');
setBusy(true);
abort.current = new AbortController();
try {
await streamChat(
{ message: userText, session_id: sessionId, working_dir: cwd }, // 字段名以 daemon ChatRequest DTO 为准
(e) => {
if (e.type === 'permission_request') { onPermission(e); return; }
const delta = e.text ?? e.delta ?? '';
if (delta) {
setMsgs((m) => {
const copy = [...m];
copy[copy.length - 1] = { role: 'assistant', text: copy[copy.length - 1].text + delta };
return copy;
});
}
},
abort.current.signal,
);
} finally { setBusy(false); }
}
// … 消息列表 + 输入框渲染(Tailwind 气泡样式,Enter 发送,busy 时禁用)
}
/chat 请求体字段以 daemon chat_stream 的请求 DTO 为准(实现中为 StreamChatBody { message, session_id?, request_id?, working_dir?, provider?, images?, approval_mode? },见 api.ts#L123-L131)。App 接入:<Chat onPermission={setPendingReq} />,pendingReq 状态留给 Task 14。验证标准:发一句话应看到文本流式出现。
8.4 Task 14:工具批准卡片
计划:PermissionCard.tsx 渲染待批工具名、reason、参数 JSON,三个按钮分别发 deny/allow/always_allow:
export function PermissionCard({ req, onDone }: { req: any; onDone: () => void }) {
async function decide(d: 'allow' | 'deny' | 'always_allow') {
await respondPermission(req.session_id, d);
onDone();
}
return (
<div class="fixed inset-0 bg-black/40 flex items-center justify-center">
<div class="bg-white rounded-lg p-5 max-w-lg w-full space-y-3">
<h3 class="font-semibold text-lg">工具请求批准:{req.tool_name}</h3>
<p class="text-sm text-gray-600">{req.reason}</p>
<pre class="bg-gray-50 p-2 rounded text-xs overflow-x-auto">{JSON.stringify(req.arguments, null, 2)}</pre>
<div class="flex gap-2 justify-end">
<button class="px-3 py-1.5 rounded bg-gray-200" onClick={() => decide('deny')}>拒绝</button>
<button class="px-3 py-1.5 rounded bg-blue-600 text-white" onClick={() => decide('allow')}>批准</button>
<button class="px-3 py-1.5 rounded bg-green-600 text-white" onClick={() => decide('always_allow')}>本会话总是允许</button>
</div>
</div>
</div>
);
}
App 状态接线:const [pendingReq, setPendingReq] = useState<any|null>(null); 非空时渲染卡片。端到端手测:让 agent 触发一个需批准的工具(写文件/跑命令),应弹卡片;点「批准」后 agent 继续执行;点「拒绝」agent 收到 Deny。
8.5 Task 15:会话侧栏 + 配置查看
- Sidebar:调
listSessions()渲染历史会话;点击切换当前会话(session_id 传给 Chat 发送时带上);新建会话按钮清空当前。 - ConfigPanel:调
getConfig()展示当前 provider/model/workdir(/config已脱敏,无 api_key);Phase 1 只读,编辑能力后续再加。 - 布局:左侧 Sidebar、主区 Chat、顶部「配置」按钮开 ConfigPanel,整体两栏 Tailwind flex 布局。
实现侧的会话数据面比计划更全:listSessions()(跨项目 50 条上限)、listProjectSessions(projectHash)(单项目无上限,侧栏用它,避免被其他项目的新会话饿死)、searchSessions(q)(搜索弹窗跨项目找会话)、resolveSession(id)(URL 恢复短 id),见 api.ts#L337-L376。
九、Milestone 4.5:工作目录切换
9.1 Task 15a:GET /fs/list 目录列举端点
计划先抽可测纯函数:normalize_dir_arg(展开 ~,不校验存在性,复用与 /cd 一致的展开规则)、list_subdirs(只列子目录、跳过隐藏目录、排序),测试覆盖 expands_tilde 与 rejects_non_dir_gracefully:
/// 展开 `~`,返回绝对路径(不校验存在性)。
pub fn normalize_dir_arg(arg: &str) -> PathBuf {
if let Some(rest) = arg.strip_prefix('~') {
if let Some(home) = atomcode_core::tool::real_home_dir() {
return home.join(rest.trim_start_matches('/'));
}
}
PathBuf::from(arg)
}
/// 列出某目录下的子目录名(不含文件、不递归)。
pub fn list_subdirs(dir: &Path) -> anyhow::Result<Vec<String>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
if !name.starts_with('.') { // 跳过隐藏目录
out.push(name.to_string());
}
}
}
}
out.sort();
Ok(out)
}
async fn fs_list(State(_state): State<AppState>, Query(q): Query<FsListQuery>) -> impl IntoResponse {
let dir = normalize_dir_arg(&q.path).canonicalize()
.unwrap_or_else(|_| normalize_dir_arg(&q.path));
match list_subdirs(&dir) {
Ok(dirs) => Json(serde_json::json!({ "path": dir.to_string_lossy(), "dirs": dirs })).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
路由受 token 中间件保护:.route("/fs/list", get(fs_list))。安全说明(计划原文):仅 loopback 绑定 + token 保护;只返回目录名不返回文件内容;canonicalize 消解 ../符号链接。Phase 1 不额外限制根(本地工具,用户本就有 FS 权限),但隐藏 dotfiles 减少噪声。手测:curl 'http://127.0.0.1:13457/fs/list?path=~' -H 'Authorization: Bearer <tok>' 返回子目录列表。实现侧还配套了 /fs/mkdir(lib.rs#L6510-L6526,返回路径会剥掉 Windows \\?\ 前缀以防会话哈希分桶被拆分)、/fs/search(gitignore 感知的 @-mention 搜索)与 /fs/open。
9.2 Task 15b:前端目录切换器
api.ts 追加 listDir / getProjects / setDefaultDir;CwdPicker.tsx 实现计划 mockup(webui/mockup.html 的 #cwdModal)那套交互:路径输入框(~ 支持)、面包屑 + 子目录点选(调 listDir)、最近项目列表(调 getProjects)、底部「同时设为默认目录」checkbox(勾选时额外调 setDefaultDir);选定后 onPick(path) 上抛关闭弹窗。计划给出的组件骨架:
export function CwdPicker({ current, onPick, onClose }:
{ current: string; onPick: (p: string) => void; onClose: () => void }) {
const [path, setPath] = useState(current);
const [dirs, setDirs] = useState<string[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [asDefault, setAsDefault] = useState(false);
useEffect(() => { listDir(path).then((r) => setDirs(r.dirs)).catch(() => setDirs([])); }, [path]);
useEffect(() => { getProjects().then((r) => setProjects(r.projects ?? r ?? [])).catch(() => {}); }, []);
async function confirm(p: string) {
if (asDefault) await setDefaultDir(p);
onPick(p);
onClose();
}
// 渲染:输入框 + 子目录列表(点击 setPath(`${path}/${d}`)) + 最近项目 + 确定/取消
// 样式照 mockup #cwdModal。
return (/* … 见 mockup … */ null as any);
}
App 接线:维护 cwd state(初始取 GET /project 的 working_dir,切换会话时用该 session 的 working_dir);顶栏 cwd 面包屑点击打开 CwdPicker;cwd 作为 prop 传给 Chat(即 working_dir: cwd)。手测验收:切换目录后让 agent ls/读文件验证 cwd 生效,且不影响其它客户端(不勾「默认」时)。实现中 /cd 响应结构已明确为 { success, message, current_dir, project_hash }(api.ts#L896-L916),set_default 才持久化跨重启。
十、Milestone 5:收尾
Task 16:/webui stop + dev 模式 + 文档。
/webui stop:webui分支解析stop参数,停掉进程内 server。落地即 Task 9 的WebuiHandle里存的tokio::task::AbortHandle——stop时 abort 并复位WEBUI标记,下次/webui重新起(「因 server 在进程内,不用 HTTP/shutdown;直接操作 task handle 更可靠」)。- dev 重定向:
webui::serve_webui开头若ATOMCODE_WEBUI_DEV存在,302 重定向到该地址 + 原 path(已在 webui.rs#L47-L52 落地)。 - 文档:
webui/README.md写开发(npm run dev+ 设ATOMCODE_WEBUI_DEV)与发布(npm run build后cargo build)流程;主 README 加/webui用法与「作为 TUI 之外入口」的定位说明。 - 回归:
/webui stop能停进程内 server;设ATOMCODE_WEBUI_DEV后访问根路径重定向到 vite。
Task 17:构建管线集成。 在打包主程序 atomcode 的发布脚本(scripts/ 下 release.sh/*-release-*.sh)里,于 cargo build --release 之前插入 (cd webui && npm ci && npm run build)——关键认知:前端现在嵌入的是 atomcode 主程序(经 daemon 库 run_server 服务),不再只是 daemon 二进制,主程序发布管线必须包含前端构建。webui/dist 是否入库二选一:(a) 不入库(.gitignore 加它,构建时现生成)——推荐,避免产物进 git;(b) 入库便于无 node 环境直接 cargo build。Phase 1 取 (a),但需与 Task 3 的占位 webui/dist/index.html 协调(改 .gitkeep + build.rs 保障,或脚本保证先构建)。落地选了 #[allow_missing] 路线:dist 缺席时编译仍过,由 is_built() 在运行入口拦截。
十一、Milestone 6:Phase 1.5 — TUI ⇄ webui 实时同步(方案 A)
前提:Milestone 0–5 已完成(基础 webui 跑通)。本里程碑碰 TUI 事件循环,独立于基础功能、最后做。
模型:进程内「活动会话总线」LiveSession——单一 Conversation + broadcast<TurnEvent> 扇出 + 单一 mpsc<UserInput> 输入 + 单写者守卫。TUI 与浏览器退化为「订阅渲染 + 投递输入」的视图。默认值(计划已确认):webui 默认独立,开关「同步当前 TUI 会话」才走总线;turn 进行中另一端输入禁用并提示;晚加入先 replay 快照再接增量;审批两端都收、先到先得。
11.1 Task 18:LiveSession 总线(core,可单测)
测试先行(广播多订阅者 + turn 守卫):
#[tokio::test]
async fn broadcast_reaches_multiple_subscribers() {
let live = LiveSession::new();
let mut a = live.subscribe();
let mut b = live.subscribe();
live.emit(TurnEvent::TextDelta("hi".into()));
assert!(matches!(a.recv().await.unwrap(), TurnEvent::TextDelta(t) if t == "hi"));
assert!(matches!(b.recv().await.unwrap(), TurnEvent::TextDelta(t) if t == "hi"));
}
#[test]
fn turn_guard_blocks_second_start() {
let live = LiveSession::new();
assert!(live.try_begin_turn(), "first begin should succeed");
assert!(!live.try_begin_turn(), "second begin must be rejected while running");
live.end_turn();
assert!(live.try_begin_turn(), "begin after end should succeed");
}
实现(计划给出的完整骨架):
use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc};
use crate::conversation::Conversation;
use crate::turn::event::TurnEvent;
#[derive(Debug, Clone)]
pub struct UserInput {
pub text: String,
pub working_dir: Option<std::path::PathBuf>,
}
#[derive(Clone, Copy, PartialEq)]
pub enum TurnState { Idle, Running }
#[derive(Clone)]
pub struct LiveSession {
conversation: Arc<Mutex<Conversation>>,
events: broadcast::Sender<TurnEvent>,
input_tx: mpsc::UnboundedSender<UserInput>,
input_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<UserInput>>>>, // 协调器取走
turn_state: Arc<Mutex<TurnState>>,
}
impl LiveSession {
pub fn new() -> Self {
let (events, _) = broadcast::channel(256);
let (input_tx, input_rx) = mpsc::unbounded_channel();
Self {
conversation: Arc::new(Mutex::new(Conversation::default())),
events, input_tx,
input_rx: Arc::new(Mutex::new(Some(input_rx))),
turn_state: Arc::new(Mutex::new(TurnState::Idle)),
}
}
/// 视图订阅事件流(TUI 渲染 / webui SSE)。
pub fn subscribe(&self) -> broadcast::Receiver<TurnEvent> { self.events.subscribe() }
/// 协调器/handler 向所有视图广播一个事件。
pub fn emit(&self, ev: TurnEvent) { let _ = self.events.send(ev); }
/// 任一视图投递用户输入。
pub fn submit(&self, input: UserInput) -> bool { self.input_tx.send(input).is_ok() }
/// 协调器启动时取走唯一的输入接收端。
pub fn take_input_rx(&self) -> Option<mpsc::UnboundedReceiver<UserInput>> {
self.input_rx.lock().unwrap().take()
}
/// 单写者守卫:开始一个 turn(已在跑则返回 false)。
pub fn try_begin_turn(&self) -> bool {
let mut s = self.turn_state.lock().unwrap();
if *s == TurnState::Running { return false; }
*s = TurnState::Running; true
}
pub fn end_turn(&self) { *self.turn_state.lock().unwrap() = TurnState::Idle; }
pub fn is_running(&self) -> bool { *self.turn_state.lock().unwrap() == TurnState::Running }
/// 当前会话消息快照(webui 晚加入时先 replay)。
pub fn snapshot(&self) -> Vec<crate::conversation::message::Message> {
self.conversation.lock().unwrap().messages.clone()
}
pub fn conversation(&self) -> Arc<Mutex<Conversation>> { self.conversation.clone() }
}
11.2 Task 19:turn 协调器(驱动总线上的 turn)
/// 单一 turn 协调器:从 input 取消息,单写者跑 turn,事件发 broadcast。
/// turn 执行复用 TurnRunner——构造方式对齐 daemon chat_stream(provider/tools/ctx/permission 同源)。
pub async fn run_coordinator(
live: LiveSession,
mut build_runner: impl FnMut() -> crate::turn::runner::TurnRunner,
) {
let Some(mut input_rx) = live.take_input_rx() else { return };
while let Some(input) = input_rx.recv().await {
if !live.try_begin_turn() {
live.emit(TurnEvent::Warning("已有对话进行中,已忽略本次输入".into()));
continue;
}
{ let mut conv = live.conversation().lock().unwrap();
conv.push_user_text(&input.text); }
let (turn_tx, mut turn_rx) = mpsc::unbounded_channel::<TurnEvent>();
let live2 = live.clone();
let pump = tokio::spawn(async move {
while let Some(ev) = turn_rx.recv().await { live2.emit(ev); }
});
let mut runner = build_runner();
// runner.run(&mut conv, &system_prompt, &turn_tx, cancel) —— 签名对齐 daemon chat_stream
let _ = pump.await;
live.end_turn();
}
}
本步「依现状对齐」最重:
TurnRunner::run精确签名、Conversation追加/锁用法、system_prompt 构造,全照 daemonchat_stream抄同一套,只把事件 sink 从 SSE 换成live.emit。
11.3 Task 20:server /live SSE + POST /live/message
AppState 加 pub live: Arc<RwLock<Option<LiveSession>>>;首次开启同步时创建 LiveSession、tokio::spawn(run_coordinator(...))。
GET /live(快照 replay + broadcast 增量):
async fn live_stream(State(state): State<AppState>) -> impl IntoResponse {
let live = state.live.read().await.clone();
let stream = async_stream::stream! {
if let Some(live) = live {
for msg in live.snapshot() {
yield Ok::<_, std::convert::Infallible>(sse_json(&serde_json::json!({
"type": "snapshot_message", "message": msg })));
}
let mut rx = live.subscribe();
while let Ok(ev) = rx.recv().await { yield Ok(sse_json(&turn_event_to_json(&ev))); }
}
};
Sse::new(stream).keep_alive(Default::default())
}
(sse_json/turn_event_to_json 复用 Task 6 的 SSE 序列化逻辑,抽成函数共用。)
POST /live/message(投 input_tx,turn 进行中拒绝):
async fn live_message(State(state): State<AppState>, Json(req): Json<ChatRequest>) -> impl IntoResponse {
if let Some(live) = state.live.read().await.clone() {
if live.is_running() {
return Json(serde_json::json!({ "success": false, "error": "turn_in_progress" }));
}
live.submit(atomcode_core::turn::live_session::UserInput {
text: req.message, working_dir: req.working_dir,
});
Json(serde_json::json!({ "success": true }))
} else {
Json(serde_json::json!({ "success": false, "error": "no_live_session" }))
}
}
路由(受 token 中间件):.route("/live", get(live_stream)).route("/live/message", post(live_message))。当前源码中这两个端点已在 lib.rs 受保护路由表 注册,实现位于 live_api.rs,线上事件协议演进为 snapshot / user / text / tool_* / state / permission_request / user_input_request / mode / provider / rate_limited 等(见 api.ts 的 LiveWireEvent)。
11.4 Task 21:TUI 同步模式(输入走总线 + 渲染订阅 broadcast)
/webui sync开启同步:用 TUI 当前会话初始化共享LiveSession的conversation,启动协调器(若未启),开关状态存进 TUI state;- 输入改道:同步开启时 TUI 发送逻辑从
AgentLoop/agent_handle改为live.submit(UserInput{...})——读现有handle_send_message路径,加if state.synced { live.submit } else { 现状 }分叉; - 渲染订阅:spawn 任务
live.subscribe()把TurnEvent喂进 TUI 现有事件渲染入口(与agent_handle事件同一 render sink);turn 进行中禁用输入框并提示「对方正在对话」。 - 验收:浏览器发消息 TUI 实时渲染;TUI 发消息浏览器实时渲染;turn 进行中两端输入禁用。
落地版 TUI 侧对应 /webui(sync 默认开)、/sync off 等命令分支(commands.rs#L2369-L2379 的 sync 分支经 attach_live_runtime/detach_live_runtime 挂接与退出共享),ensure_server_and_open 的 URL 在 sync 模式下自动携带 &sync=1。
11.5 Task 22:前端同步开关 + /live 接入
api.ts 加:
export function streamLive(onEvent: (e: any) => void, signal?: AbortSignal) {
return streamGet('/live', onEvent, signal); // GET SSE,解析逻辑同 streamChat
}
export async function sendLive(message: string, workingDir?: string) {
const r = await fetch('/live/message', { method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders() },
body: JSON.stringify({ message, working_dir: workingDir }) });
return r.json();
}
顶栏加「🔗 同步 TUI 会话」开关:开启时 Chat 改用 streamLive(先收 snapshot_message 渲染历史、再接增量),发送改用 sendLive;收到 turn_in_progress 或运行中事件时禁用输入并提示「对方正在对话」。关闭时回到独立 /chat 模式。
11.6 Task 23:同步模式下的工具审批(两端可批,先到先得)
- 协调器跑 turn 时权限请求作为
TurnEvent::ApprovalRequested进 broadcast → TUI 与浏览器同时弹卡片; - 决定回送到协调器持有的单一 response 通道(
PermissionResponders思路,key 为活动会话); - 先到先得:第一个到达的决定生效,置
decided标记,后到的忽略;broadcast 一条permission_resolved { call_id },两端收到后关闭各自卡片; - 验收:任一端批准,另一端卡片自动消失,agent 继续;无双重批准、无卡死。
实现侧 UserInputResponders 的「remove-before-send」一次性送达语义(permission_bridge.rs#L76-L83)正是「先到先得、拒绝迟到 POST」这一要求的底层保障。
十二、计划的 Self-Review 要点(实施核对清单)
计划文档末尾的自检记录是排查实现偏离时的对照表,摘要如下:
- Spec 覆盖:定位(M3/M4 入口 +