atomcode WebUI 实现全解:从 daemon 库化到 TUI⇄浏览器实时同步的进程内 Agent 入口

原创2026-10-13 17:50:062,224 阅读
文章标签:人工智能AI 应用AI Agent代码智能体CLIAI 评测

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 重要前提(计划中「已核实」的事实)

计划文档特别强调了几个决定架构走向的前提:

  1. atomcode 主程序已是 #[tokio::main](计划核实点为当时 CLI main.rs:698),依赖含 tokio(full) + reqwest——进程内起 tokio 服务无额外成本。
  2. install.sh 每平台只下载主程序、不含 daemon 二进制——故 server 必须库化供主程序进程内调用,否则普通用户装不上 webui。
  3. 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 的索引:

新建:

修改:

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 因 Mutex guard 不可跨 .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 构造,全照 daemon chat_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 入口 +
登录后查看全文
atomcode