atomcode webui (Phase 1) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
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 官方中转隧道仅留可插拔鉴权接口。
重要前提(已核实): atomcode 已是 #[tokio::main](CLI main.rs:698),依赖含 tokio(full)+reqwest;install.sh 每平台只下载主程序、不含 daemon;atomcode-cli 原本不依赖 daemon crate。故 server 必须库化供主程序进程内调用。
Tech Stack: Rust / axum 0.7 / tokio / rust-embed;前端 Preact + Vite + Tailwind;复用 atomcode-core 的 InteractivePermissionDecider、TurnRunner、open_browser。
Spec: docs/superpowers/specs/2026-05-29-webui-design.md
File Structure
新建:
webui/(前端工程根):package.json、vite.config.ts、tailwind.config.js、index.html、src/main.tsx、src/app.tsx、src/api.ts、src/components/*.tsxcrates/atomcode-daemon/src/webui.rs:rust-embed 静态资源 handler + SPA fallbackcrates/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<()>与pub fn build_app_state(...) -> AppState、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依赖;加 featurewebui(默认开)crates/atomcode-cli/Cargo.toml:加依赖atomcode-daemon = { path = "../atomcode-daemon" }crates/atomcode-cli/src/main.rs:Commands加Webui子命令;进程内 server 单例crates/atomcode-cli/src/webui_launch.rs(新建):ensure_server_and_open(port) -> Result<String>—— 进程内起 server(OnceCell守护)+ mint token + 开浏览器,TUI 与 CLI 共用crates/atomcode-tuix/src/event_loop/commands.rs:match cmd加"webui"分支(调 cli 暴露的 launcher,或把 launcher 放 core 供 tuix 调)
launcher 归属:tuix 与 cli 都要用。放
atomcode-cli会让 tuix 反向依赖 cli(不可)。故把ensure_server_and_open放atomcode-core(core 可依赖 daemon 库吗?会成环:daemon 依赖 core)。结论:放atomcode-daemon库里(pub fn ensure_server_and_open),core/tuix/cli 都能依赖 daemon 库调它。tuix 已可依赖 daemon 库(新增依赖)。实现时确认无依赖环(daemon→core 单向,tuix→daemon 新增,cli→daemon 新增,均不成环)。
关键既有接口(已核实,实现时直接用):
atomcode_core::turn::permission::{InteractivePermissionDecider, ApprovalRequest}InteractivePermissionDecider::new(request_tx: mpsc::UnboundedSender<ApprovalRequest>, response_rx: mpsc::UnboundedReceiver<PermissionDecision>, permission_store: Arc<RwLock<PermissionStore>>)ApprovalRequest { call: ToolCall, reason: String }
atomcode_core::tool::{PermissionDecision (Allow|Ask(String)|Deny), PermissionStore, ToolCall}atomcode_core::turn::event::TurnEvent::ApprovalRequested { tool_name, reason, call, messages }(daemon SSE 循环当前在main.rs:2299显式丢弃它)atomcode_core::auth::oauth::open_browser(url: &str) -> anyhow::Result<()>(已按平台 cfg)- daemon 现有:
origin_is_allowed(&str) -> bool、is_loopback_authority(&str) -> bool、activity_tracker_middleware - daemon 默认
127.0.0.1:13456;CLICommands::Daemon通过 re-execatomcode-daemon二进制启动(atomcode-cli/src/main.rs:930+) AppState是#[derive(Clone)](main.rs:230),字段均为Arc<...>共享句柄
Milestone -1 — daemon 库化(最先做,后续一切依赖它)
Task 0: 把 server 逻辑抽成 run_server 库函数
Files:
-
Create:
crates/atomcode-daemon/src/lib.rs -
Modify:
crates/atomcode-daemon/src/main.rs、crates/atomcode-daemon/Cargo.toml -
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` 无需第二个二进制。
mod api_auth;
mod api_codingplan;
mod api_config;
mod api_provider;
mod telemetry_scope;
pub mod auth_token; // Task 1 新增
pub mod permission_bridge; // Task 5 新增
pub mod webui; // Task 4 新增
// ... 把 main.rs 中 AppState 定义、所有 handler、Router 构建迁入并设为 pub(crate)/pub
/// server 启动参数。
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 中 step 1..N 的 AppState 构建 + Router::new()....with_state(state)
// + TcpListener::bind + axum::serve(...).with_graceful_shutdown(...) 全部迁来。
// ...
Ok(())
}
- Step 3:
main.rs变薄壳
main.rs 保留 parse_daemon_args() 与平台 cfg,main() 末尾改为:
#[tokio::main]
async fn main() {
let (host, port, cli_override, idle_timeout_secs, startup_mode) = parse_daemon_args();
// ... 既有的早期 bootstrap(session 迁移等)保留或一并迁入 run_server 前段
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),use crate::AppState改为use crate::AppState(lib 内)。逐个编译错误修。不改任何行为。
- Step 4: 编译 + 既有测试回归
Run: cargo build -p atomcode-daemon && cargo test -p atomcode-daemon
Expected: 二进制与库都编译通过,既有 daemon 测试全过(行为不变)。
- Step 5: 冒烟测试(行为不变)
cargo run -p atomcode-daemon -- --port 13457 &
sleep 2
curl -s http://127.0.0.1:13457/health && echo " OK"
kill %1
Expected: /health 正常响应。
- Step 6: 提交
git add crates/atomcode-daemon
git commit -m "refactor(daemon): 抽 run_server 库函数,二进制变薄壳(行为不变)"
Milestone 0 — 后端鉴权 token(先做,因为后续路由都要它)
注(Task 0 之后):以下任务中凡提到「
main.rs的 AppState / handler / Router」, 在库化后这些已迁入crates/atomcode-daemon/src/lib.rs,请在lib.rs中改动;mod xxx;声明也加在lib.rs。crate::AppState引用在 lib 内同样有效。
Task 1: 一次性 token 存储 + 校验逻辑(纯函数,可单测)
Files:
-
Create:
crates/atomcode-daemon/src/auth_token.rs -
Modify:
crates/atomcode-daemon/src/main.rs(声明mod auth_token;,AppState 加字段) -
Step 1: 写失败测试
在 crates/atomcode-daemon/src/auth_token.rs 底部:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mints_and_validates_token() {
let store = WebuiTokenStore::new();
let tok = store.mint();
assert!(store.is_valid(&tok), "freshly minted token must validate");
}
#[test]
fn rejects_unknown_token() {
let store = WebuiTokenStore::new();
store.mint();
assert!(!store.is_valid("not-a-real-token"));
assert!(!store.is_valid(""));
}
#[test]
fn mint_is_unique() {
let store = WebuiTokenStore::new();
assert_ne!(store.mint(), store.mint());
}
}
- Step 2: 运行测试确认失败
Run: cargo test -p atomcode-daemon auth_token
Expected: 编译失败(WebuiTokenStore 未定义)。
- Step 3: 实现 token 存储
在 crates/atomcode-daemon/src/auth_token.rs 顶部:
//! 本地 webui 一次性 token 存储与鉴权中间件。
//!
//! Phase 1:token 随 `/webui` 启动生成,仅存内存、随 daemon 退出失效。
//! Phase 2(官方中转隧道)会把账号 token 接入同一条 `is_valid` 校验链——
//! 故鉴权统一收口在本模块,路由层只调 `require_webui_token` 中间件。
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)
}
}
在 main.rs 顶部 mod 区加 mod auth_token;,并在 use 区加 use auth_token::WebuiTokenStore;。
- Step 4: 运行测试确认通过
Run: cargo test -p atomcode-daemon auth_token
Expected: 3 个测试 PASS。
- Step 5: 提交
git add crates/atomcode-daemon/src/auth_token.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui 一次性 token 存储"
Task 2: token 鉴权中间件 + 接进 AppState 与 Router
Files:
-
Modify:
crates/atomcode-daemon/src/auth_token.rs(加中间件函数 + 测试) -
Modify:
crates/atomcode-daemon/src/main.rs(AppState 加webui_tokens字段;初始化;Router 套层) -
Step 1: 写中间件单测
在 auth_token.rs 的 tests 模块追加(用纯函数 token_from_parts 便于单测,避免造 Request):
#[test]
fn extracts_bearer_token() {
assert_eq!(token_from_header(Some("Bearer abc123")), Some("abc123".to_string()));
assert_eq!(token_from_header(Some("bearer abc123")), Some("abc123".to_string()));
assert_eq!(token_from_header(Some("abc123")), None);
assert_eq!(token_from_header(None), None);
}
- Step 2: 运行确认失败
Run: cargo test -p atomcode-daemon auth_token
Expected: 编译失败(token_from_header 未定义)。
- Step 3: 实现 header 解析 + axum 中间件
在 auth_token.rs(tests 之前)追加:
use axum::{
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
/// 从 `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 中间件:要求请求带有效 webui token,否则 401。
/// 用 `crate::AppState` 取 `webui_tokens`。仅挂在 webui 专用路由上。
pub async fn require_webui_token<B>(
State(state): State<crate::AppState>,
req: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
let header = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
match token_from_header(header) {
Some(tok) if state.webui_tokens.is_valid(&tok) => Ok(next.run(req).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}
注:axum 0.7 中间件签名若与本仓库现有
activity_tracker_middleware(main.rs:732)不同,以现有那个为准对齐(泛型Bvsaxum::body::Body、Next是否带泛型)。先读main.rs:732的签名再照抄形状。
- Step 4: AppState 加字段并初始化
在 main.rs 的 pub struct AppState(main.rs:231)末尾加:
/// 本地 webui 一次性 token 存储(Phase 1)
pub webui_tokens: WebuiTokenStore,
在 main() 里构造 AppState { ... } 处加 webui_tokens: WebuiTokenStore::new(),。
- Step 5: 运行测试确认通过 + 编译
Run: cargo test -p atomcode-daemon auth_token && cargo build -p atomcode-daemon
Expected: 测试 PASS,daemon 编译通过。
- Step 6: 提交
git add crates/atomcode-daemon/src/auth_token.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui token 鉴权中间件 + 接入 AppState"
Milestone 1 — 静态资源嵌入
Task 3: rust-embed 依赖 + 占位前端构建产物
Files:
-
Modify:
crates/atomcode-daemon/Cargo.toml -
Create:
webui/dist/index.html(占位,后续被真实构建覆盖) -
Step 1: 加依赖
在 crates/atomcode-daemon/Cargo.toml 的 [dependencies] 加:
rust-embed = { version = "8", features = ["mime-guess"] }
- Step 2: 放占位产物
webui/dist/index.html:
<!doctype html>
<html><head><meta charset="utf-8"><title>atomcode webui</title></head>
<body><div id="app">webui placeholder</div></body></html>
- Step 3: 编译确认依赖可用
Run: cargo build -p atomcode-daemon
Expected: 成功(仅引入了依赖,未使用尚不报错)。
- Step 4: 提交
git add crates/atomcode-daemon/Cargo.toml webui/dist/index.html
git commit -m "build(daemon): 引入 rust-embed + webui 占位产物"
Task 4: 静态资源 handler + SPA fallback
Files:
-
Create:
crates/atomcode-daemon/src/webui.rs -
Modify:
crates/atomcode-daemon/src/main.rs(mod webui;,Router 加GET /与 fallback) -
Step 1: 写 handler 测试
crates/atomcode-daemon/src/webui.rs 底部:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serves_embedded_index() {
// dist/index.html 必须被嵌入
let f = WebuiAssets::get("index.html");
assert!(f.is_some(), "index.html should be embedded");
}
#[test]
fn unknown_path_falls_back_to_index_marker() {
// 未知路径应回退 index.html(SPA 路由)
assert!(asset_or_index("does/not/exist.js").is_none()
|| asset_or_index("does/not/exist.js").is_some());
// 明确:未知路径返回 index.html 内容
let body = asset_or_index("some/spa/route");
assert!(body.is_some(), "SPA route should fall back to index");
}
}
- Step 2: 运行确认失败
Run: cargo test -p atomcode-daemon webui
Expected: 编译失败(WebuiAssets / asset_or_index 未定义)。
- Step 3: 实现 embed + handler
crates/atomcode-daemon/src/webui.rs 顶部:
//! 嵌入式 webui 静态资源服务。
//!
//! 编译期把 `webui/dist/` 打进二进制;运行期 `GET /` 与未匹配的非 API 路径
//! 都回退到 `index.html`,交给前端 SPA 路由。
//!
//! dev 模式(设置 `ATOMCODE_WEBUI_DEV=http://localhost:5173`)下应改为反代/
//! 重定向到 vite dev server——见 `webui_dev_redirect`。
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
};
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(),
},
}
}
在 main.rs 加 mod webui;。
- Step 4: Router 接线
在 main.rs 的 Router::new() 链中加(放在所有 API 路由之后、.with_state 之前;fallback 捕获未匹配路径):
.route("/", get(webui::serve_webui))
.route("/webui-app/*path", get(webui::serve_webui))
.fallback(webui::serve_webui)
/与/webui-app/*是页面与资源;fallback 兜住 SPA 深链。注意不要让 fallback 吞掉已有 API 路由——axum 精确路由优先,fallback 只接未匹配的。
- Step 5: 运行测试 + 编译
Run: cargo test -p atomcode-daemon webui && cargo build -p atomcode-daemon
Expected: 测试 PASS,编译通过。
- Step 6: 手动验证
cargo run -p atomcode-daemon -- --port 13457 &
curl -s http://127.0.0.1:13457/ | grep -q "webui placeholder" && echo OK
kill %1
Expected: 打印 OK。
- Step 7: 提交
git add crates/atomcode-daemon/src/webui.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): 嵌入式 webui 静态资源服务 + SPA fallback"
Milestone 2 — 交互式权限流
Task 5: 权限桥接通道 + AppState 存储
Files:
- Create:
crates/atomcode-daemon/src/permission_bridge.rs - Modify:
crates/atomcode-daemon/src/main.rs(AppState 加pending_permissions)
桥接思路:/chat 为每个 session 建一对 channel。InteractivePermissionDecider 的 request_tx 在工具待批时发 ApprovalRequest;daemon SSE 循环转发 ApprovalRequested 给前端。前端 POST /chat/permission 时,handler 通过 pending_permissions(session_id -> mpsc::Sender<PermissionDecision>)把决定送回 decider 的 response_rx。
- Step 1: 写测试
permission_bridge.rs 底部:
#[cfg(test)]
mod tests {
use super::*;
use atomcode_core::tool::PermissionDecision;
#[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));
}
}
- Step 2: 运行确认失败
Run: cargo test -p atomcode-daemon permission_bridge
Expected: 编译失败(PermissionResponders 未定义)。
- Step 3: 实现
permission_bridge.rs 顶部:
//! 把 daemon `/chat` 的交互式权限决策桥接到 HTTP。
//!
//! `InteractivePermissionDecider` 阻塞在 `response_rx` 上等待决定;
//! `/chat/permission` 收到前端决定后,经 `PermissionResponders` 按
//! session_id 路由到对应的 `response_tx`,唤醒 decider。
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::UnboundedSender;
use atomcode_core::tool::PermissionDecision;
/// session_id -> decider 的 response 发送端。
#[derive(Clone, Default)]
pub struct PermissionResponders {
inner: Arc<RwLock<HashMap<String, UnboundedSender<PermissionDecision>>>>,
}
impl PermissionResponders {
pub fn new() -> Self {
Self::default()
}
/// 登记某 session 的决定发送端(`/chat` 启动时调用)。
pub fn register(&self, session_id: String, tx: UnboundedSender<PermissionDecision>) {
self.inner.write().unwrap().insert(session_id, tx);
}
/// `/chat` 结束时清理。
pub fn unregister(&self, session_id: &str) {
self.inner.write().unwrap().remove(session_id);
}
/// 把决定送给对应 session 的 decider。返回是否成功(session 是否在等待)。
pub fn deliver(&self, session_id: &str, decision: PermissionDecision) -> bool {
if let Some(tx) = self.inner.read().unwrap().get(session_id) {
tx.send(decision).is_ok()
} else {
false
}
}
}
在 main.rs 加 mod permission_bridge;,use permission_bridge::PermissionResponders;,AppState 加:
/// webui 交互式权限:session_id -> decider response 发送端
pub pending_permissions: PermissionResponders,
并在 main() 构造 AppState 处加 pending_permissions: PermissionResponders::new(),。
- Step 4: 运行测试 + 编译
Run: cargo test -p atomcode-daemon permission_bridge && cargo build -p atomcode-daemon
Expected: PASS + 编译通过。
- Step 5: 提交
git add crates/atomcode-daemon/src/permission_bridge.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): 权限桥接通道 PermissionResponders"
Task 6: chat_stream 改用 InteractivePermissionDecider + 转发 ApprovalRequested
Files:
-
Modify:
crates/atomcode-daemon/src/main.rs(chat_stream内 permission 构造处main.rs:2051;SSE 循环main.rs:2299) -
Step 1: 替换 permission 构造
把 main.rs:2051 的:
let permission = Box::new(AutoPermissionDecider::new(AutoPermissionMode::BypassAll));
替换为(用 session_id 注册 responder):
// 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,
));
session_id_str在chat_stream中已存在(main.rs当前用它判断 stopped)。若位置在 permission 构造之后,则上移其定义,或改用req.session_id。读上下文确认变量名后对齐。
- Step 2: SSE 循环转发 ApprovalRequested
把 main.rs:2299 的:
TurnEvent::ApprovalRequested { .. } => {
// ApprovalRequested is TUI-only (carries conversation.messages ...
整个分支替换为转发(序列化为 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实际结构为准(前面已见ToolBatchCall { id, name, arguments };ToolCall应有id与序列化后的arguments/参数字段)。实现时grep "pub struct ToolCall"确认字段,必要时调整 json key。SSEyield语法需与该函数现有 stream 宏(async_stream/手写)一致——参照同函数内其它yield(如main.rs:1853)。
- Step 3: 结束时反注册 + 兜底
在 chat_stream 的 turn 结束/错误清理处加:
state.pending_permissions.unregister(&session_id_str);
(与现有 chat_tasks/stopped_sessions 清理放一起。)
- Step 4: 编译
Run: cargo build -p atomcode-daemon
Expected: 编译通过(可能需调整 yield/字段名,按编译器提示修)。
- Step 5: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): /chat 改用交互式权限 + SSE 转发 permission_request"
Task 7: POST /chat/permission 端点
Files:
-
Modify:
crates/atomcode-daemon/src/main.rs(新 handler + 路由) -
Step 1: 写 handler
在 main.rs(chat 相关 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 处理,AlwaysAllow 语义放到 Task 11(前端记住)或
// 后续增强——此处保持决定路由职责单一。
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" }))
}
}
- Step 2: 加路由(受 token 中间件保护)
在 Router 中加:
.route("/chat/permission", post(chat_permission))
- Step 3: 编译
Run: cargo build -p atomcode-daemon
Expected: 通过。
- Step 4: 端到端手测(脚本化)
cargo run -p atomcode-daemon -- --port 13457 &
sleep 2
# 无对应 session 时应 success:false
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
Expected: OK。
- Step 5: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): POST /chat/permission 回送权限决定"
Task 8: webui 路由套上 token 鉴权中间件
Files:
- Modify:
crates/atomcode-daemon/src/main.rs(给/chat、/chat/permission、/sessions等数据路由套require_webui_token)
说明:VSCode 扩展也连同一 daemon 且当前无 token。为不破坏既有客户端,Phase 1 仅对 webui 新增的敏感写操作路由(
/chat、/chat/permission)要求 token;静态资源/不要求(首屏要能加载,token 在 URL query,由前端 JS 读取后用于后续 API)。读main.rs现有 SessionMode/client 区分逻辑,把 token 中间件做成「带 token 即放行;不带但来自 loopback + 旧客户端 header 也放行」的兼容策略,避免 break VSCode。
- Step 1: 决策点确认(无代码,读现状)
读 main.rs 的 activity_tracker_middleware(main.rs:732)与 SessionMode/--client Extension 注入处,确认 VSCode 客户端是否带可识别 header。
- Step 2: 套中间件
对 webui 敏感路由分组套层(axum 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 访问
/chat,则改为「token 有效 或 旧客户端 header 存在」的放行逻辑(在require_webui_token内加 client 判断),不要简单 401。这是兼容性关键点,实现时务必先验证 VSCode 路径。
- Step 3: 编译 + 回归手测
Run: cargo build -p atomcode-daemon
然后带/不带 token 各 curl 一次 /chat(带非法 body 即可,看是否 401 vs 400)。
Expected: 不带 token → 401;带有效 token → 进入 handler(400/业务错误)。
- Step 4: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui 敏感路由套 token 鉴权(兼容既有客户端)"
Milestone 3 — 启动入口(TUI 命令 + CLI 子命令)
Task 9: 进程内启动器 ensure_server_and_open(放 daemon 库,供 tuix/cli 共用)
Files:
- Modify:
crates/atomcode-daemon/src/lib.rs(新增ensure_server_and_open+ 进程内单例 +mint_token直调)
进程内模型:server 与调用方在同一进程,故不走 HTTP 健康检查/spawn,而是:用一个进程级 OnceCell 持有「server 已起」标记与共享的 WebuiTokenStore/AppState;首次调用时 tokio::spawn(run_server(...)),之后直接从同一个 WebuiTokenStore mint() token(无需 HTTP)。
- Step 1: 写启动器
在 lib.rs 加:
use std::sync::OnceLock;
/// 进程内 webui server 句柄(保证只起一次)。
struct WebuiHandle {
tokens: auth_token::WebuiTokenStore,
port: u16,
}
static WEBUI: OnceLock<WebuiHandle> = OnceLock::new();
/// 确保进程内 server 已起,mint 一次性 token,开浏览器。返回给用户的状态串。
/// 在 `atomcode` 主程序(已有 tokio runtime)内调用。
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()), // run_server 用传入的同一个 store
};
// 在当前 runtime 上后台运行 server。
tokio::spawn(async move {
if let Err(e) = run_server(opts).await {
eprintln!("webui server error: {e}");
}
});
WebuiHandle { tokens, port }
});
// 给 server 一点 bind 时间(仅首次需要;用短轮询 /health 更稳,见 Step 2)。
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}"),
}
}
ServerOpts需加一个pub webui_tokens: Option<auth_token::WebuiTokenStore>字段:Some时run_server用它构 AppState(与启动器共享同一 store,token 才能跨「mint 端 / 校验端」一致),None(独立二进制/VSCode)时内部WebuiTokenStore::new()。回到 Task 2 的 AppState 初始化处按此分支。
- Step 2: 起后等待 bind 就绪(避免浏览器先于 server)
在 mint 前加一个 in-process 短等待:循环 tokio::net::TcpStream::connect(("127.0.0.1", port)) 最多 ~2s 直到成功(比固定 sleep 稳)。把这段加进 ensure_server_and_open 仅在「本次是 get_or_init 新建」时执行——用一个 bool 区分是否刚初始化。
- Step 3: 编译
Run: cargo build -p atomcode-daemon
Expected: 通过。
- Step 4: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): ensure_server_and_open 进程内启动器"
Task 9b: /webui TUI 命令分支
Files:
-
Modify:
crates/atomcode-tuix/Cargo.toml(加atomcode-daemon = { path = "../atomcode-daemon" }) -
Modify:
crates/atomcode-tuix/src/event_loop/commands.rs(match cmd加"webui") -
Modify:
crates/atomcode-tuix/src/commands.rs(注册命令 + i18n) -
Step 1: tuix 依赖 daemon 库
crates/atomcode-tuix/Cargo.toml 的 [dependencies] 加 atomcode-daemon = { path = "../atomcode-daemon" }。
确认无依赖环(daemon→core 单向;tuix→daemon 新增;不成环)。cargo build -p atomcode-tuix 验证。
- Step 2:
match cmd加分支
在 commands.rs 的 match cmd { ... } 加(紧邻 "login";解析 stop 子参数留待 Task 16):
"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; 读commands.rs现有"login"分支如何处理需 async 的 OAuth,照同样模式。push_system_message名称以本文件现有用法为准。
- Step 3: 注册命令 + i18n
在 crates/atomcode-tuix/src/commands.rs(CommandRegistry::builtin)注册 webui,中英描述:中文「启动浏览器 webui」,英文 "Launch the browser webui"(参照已有命令的 Msg:: 条目;i18n 文件 crates/atomcode-core/src/i18n/{en,zh_cn,messages}.rs)。
- Step 4: 编译 + 手测
Run: cargo build -p atomcode-tuix
手测:跑 TUI,/webui,应在进程内起 server 并打开浏览器占位页(无需任何 daemon 二进制)。
Expected: 浏览器打开 127.0.0.1:13456/?token=... 占位页。
- Step 5: 提交
git add crates/atomcode-tuix/Cargo.toml crates/atomcode-tuix/src/event_loop/commands.rs crates/atomcode-tuix/src/commands.rs crates/atomcode-core/src/i18n
git commit -m "feat(tuix): /webui 命令(进程内起 server + 开浏览器)"
Task 10: atomcode webui CLI 子命令
Files:
-
Modify:
crates/atomcode-cli/Cargo.toml(加atomcode-daemon依赖) -
Modify:
crates/atomcode-cli/src/main.rs(Commands加Webui) -
Step 1: cli 依赖 daemon 库
crates/atomcode-cli/Cargo.toml 加 atomcode-daemon = { path = "../atomcode-daemon" }。
- Step 2: 加子命令变体
在 Commands enum(紧邻 Daemon)加:
/// 启动本地浏览器 webui(进程内起 server,无需额外二进制)
Webui {
/// 端口(默认 13456)
#[arg(long, default_value = "13456")]
port: u16,
},
- Step 3: 加分支(main 已是
#[tokio::main],可直接 await)
在 match 处理(紧邻 Commands::Daemon)加:
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);
}
与 Task 9 共用同一个
ensure_server_and_open(DRY,无重复逻辑)。
- Step 4: 编译 + 手测
Run: cargo build -p atomcode-cli
手测:cargo run -p atomcode-cli -- webui,应进程内起 server 并开浏览器占位页。
Expected: 浏览器打开占位页;Ctrl+C 退出。
- Step 5: 提交
git add crates/atomcode-cli/Cargo.toml crates/atomcode-cli/src/main.rs
git commit -m "feat(cli): atomcode webui 子命令(进程内)"
Milestone 4 — 前端 SPA
前端不走 TDD(无单测基建),以「构建通过 + 手动验证清单」为准。每个 Task 末尾
npm run build产物落到webui/dist/(Task 4 的 embed 源目录),随后cargo build -p atomcode-daemon重新嵌入。
Task 11: 前端工程脚手架(Preact + Vite + Tailwind)
Files:
-
Create:
webui/package.json、webui/vite.config.ts、webui/tsconfig.json、webui/tailwind.config.js、webui/index.html、webui/src/main.tsx、webui/src/app.tsx -
Step 1: 初始化工程
cd webui
npm init -y
npm i preact
npm i -D vite @preact/preset-vite typescript tailwindcss postcss autoprefixer
npx tailwindcss init -p
- Step 2: 配置 vite(产物到 dist,base 相对)
webui/vite.config.ts:
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:
<!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>
webui/src/main.tsx:
import { render } from 'preact';
import { App } from './app';
import './index.css';
render(<App />, document.getElementById('app')!);
webui/src/app.tsx(占位,下一 Task 填充):
export function App() {
return <div class="p-4 text-lg">atomcode webui — loading…</div>;
}
webui/src/index.css:@tailwind base; @tailwind components; @tailwind utilities;
tailwind.config.js 的 content: ["./index.html","./src/**/*.{ts,tsx}"]。
- Step 3: 构建并嵌入
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
Expected: webui/dist/ 生成,daemon 编译通过。
- Step 4: 手测
cargo run -p atomcode-daemon -- --port 13457,浏览器开 127.0.0.1:13457/,应显示 "atomcode webui — loading…"。
- Step 5: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 前端脚手架 Preact+Vite+Tailwind"
Task 12: API 客户端 + token 引导
Files:
-
Create:
webui/src/api.ts -
Step 1: 实现 api 模块
webui/src/api.ts:
// 从 URL ?token= 读取一次性 token,存内存(不落 localStorage)。
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 }),
});
}
- Step 2: 构建确认无类型错误
Run: cd webui && npm run build
Expected: 构建通过。
- Step 3: 提交
git add webui/src/api.ts
git commit -m "feat(webui): API 客户端 + token 引导 + SSE 解析"
Task 13: 聊天界面 + 流式渲染
Files:
-
Create:
webui/src/components/Chat.tsx -
Modify:
webui/src/app.tsx -
Step 1: Chat 组件
webui/src/components/Chat.tsx:实现一个输入框 + 消息列表 + 发送。发送时调 streamChat,对事件按 type 累积:
TextDelta/文本增量 → 追加到当前 assistant 气泡ToolCallStarted/ToolCallResult→ 渲染工具行permission_request→ 通过 props 回调上抛给 App(Task 14 弹卡片)TokenUsage/Error→ 状态栏/错误提示
import { useState, useRef } from 'preact/hooks';
import { streamChat } from '../api';
type Msg = { role: 'user' | 'assistant'; text: string };
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 }, // cwd 由 props 传入(Task 15c);字段名以 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); }
}
return (
<div class="flex flex-col h-full">
<div class="flex-1 overflow-y-auto p-4 space-y-3">
{msgs.map((m, i) => (
<div key={i} class={m.role === 'user' ? 'text-right' : 'text-left'}>
<span class={`inline-block px-3 py-2 rounded-lg ${m.role === 'user' ? 'bg-blue-100' : 'bg-gray-100'}`}>
{m.text || '…'}
</span>
</div>
))}
</div>
<div class="p-3 border-t flex gap-2">
<input class="flex-1 border rounded px-3 py-2" value={input}
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="输入消息…" />
<button class="px-4 py-2 bg-blue-600 text-white rounded" onClick={send} disabled={busy}>
{busy ? '…' : '发送'}
</button>
</div>
</div>
);
}
/chat请求体字段(message/session_id/working_dir等)以 daemonchat_stream的请求 DTO 为准——实现前读main.rs中chat_stream的req反序列化结构,对齐字段名。
- Step 2: App 接入
app.tsx 渲染 <Chat onPermission={setPendingReq} />,pendingReq 状态留给 Task 14。
- Step 3: 构建 + 嵌入 + 手测
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
手测:跑 daemon(需配好 provider),TUI /webui 打开,发一句话,应看到流式回复。
Expected: 文本流式出现。
- Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 聊天界面 + 流式渲染"
Task 14: 工具批准卡片
Files:
-
Create:
webui/src/components/PermissionCard.tsx -
Modify:
webui/src/app.tsx -
Step 1: 卡片组件
webui/src/components/PermissionCard.tsx:
import { respondPermission } from '../api';
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>
);
}
- Step 2: App 状态接线
app.tsx:const [pendingReq, setPendingReq] = useState<any|null>(null); 当 pendingReq 非空时渲染 <PermissionCard req={pendingReq} onDone={() => setPendingReq(null)} />。
- Step 3: 构建 + 嵌入 + 端到端手测
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
手测:让 agent 触发一个需批准的工具(如写文件/跑命令),应弹卡片;点「批准」后 agent 继续执行;点「拒绝」agent 收到 Deny。
Expected: 批准/拒绝都能正确驱动 agent。
- Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 交互式工具批准卡片"
Task 15: 会话侧栏 + 配置查看
Files:
-
Create:
webui/src/components/Sidebar.tsx、webui/src/components/ConfigPanel.tsx -
Modify:
webui/src/app.tsx -
Step 1: 侧栏(会话列表 + 切换)
Sidebar.tsx:调 listSessions() 渲染历史会话;点击切换当前会话(把 session_id 传给 Chat,发送时带上)。新建会话按钮清空当前。
- Step 2: 配置面板(只读展示)
ConfigPanel.tsx:调 getConfig() 展示当前 provider/model/workdir(/config 已脱敏,无 api_key)。Phase 1 只读,编辑能力后续再加。
- Step 3: App 布局组装
app.tsx:左侧 Sidebar,主区 Chat,顶部一个「配置」按钮开 ConfigPanel。整体两栏布局(Tailwind flex)。
- Step 4: 构建 + 嵌入 + 手测
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
手测:侧栏显示历史会话、能切换、能看到 provider 配置。
Expected: 三项都正常。
- Step 5: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 会话侧栏 + 配置查看"
Milestone 4.5 — 工作目录切换
Task 15a: GET /fs/list 目录列举端点
Files:
-
Modify:
crates/atomcode-daemon/src/lib.rs(新 handler + 路由) -
Step 1: 写测试(路径规范化纯函数)
把规范化逻辑抽成可测纯函数,在 lib.rs(或 webui.rs)测:
#[cfg(test)]
mod fs_list_tests {
use super::*;
#[test]
fn expands_tilde() {
let home = atomcode_core::tool::real_home_dir().unwrap();
assert_eq!(normalize_dir_arg("~"), home);
assert_eq!(normalize_dir_arg("~/x"), home.join("x"));
}
#[test]
fn rejects_non_dir_gracefully() {
// 不存在的路径返回 None / Err,不 panic
assert!(list_subdirs(std::path::Path::new("/no/such/dir/xyz")).is_err());
}
}
- Step 2: 运行确认失败
Run: cargo test -p atomcode-daemon fs_list
Expected: 编译失败(normalize_dir_arg/list_subdirs 未定义)。
- Step 3: 实现
use std::path::{Path, PathBuf};
/// 展开 `~`,返回绝对路径(不校验存在性)。复用与 /cd 一致的展开规则。
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)
}
#[derive(Deserialize)]
pub struct FsListQuery { pub path: String }
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 中间件保护,见 Task 8):.route("/fs/list", get(fs_list))。
安全:仅 loopback 绑定 + token 保护;只返回目录名不返回文件内容。
canonicalize消解../符号链接。Phase 1 不额外限制根(本地工具,用户本就有 FS 权限),但隐藏 dotfiles 减少噪声。
- Step 4: 测试 + 手测
Run: cargo test -p atomcode-daemon fs_list && cargo build -p atomcode-daemon
手测:起 server 后 curl 'http://127.0.0.1:13457/fs/list?path=~' -H 'Authorization: Bearer <tok>' 返回子目录列表。
- Step 5: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): GET /fs/list 目录列举端点"
Task 15b: 前端目录切换器组件
Files:
-
Create:
webui/src/components/CwdPicker.tsx -
Modify:
webui/src/api.ts(加listDir/getProjects/changeDir)、webui/src/app.tsx -
Step 1: api 加目录相关调用
api.ts 追加:
export async function listDir(path: string) {
const r = await fetch(`/fs/list?path=${encodeURIComponent(path)}`, { headers: authHeaders() });
return r.json() as Promise<{ path: string; dirs: string[] }>;
}
export async function getProjects() {
const r = await fetch('/projects', { headers: authHeaders() });
return r.json();
}
export async function setDefaultDir(path: string) { // 可选「设为 daemon 默认」
await fetch('/cd', { method: 'POST', headers: { 'content-type': 'application/json', ...authHeaders() }, body: JSON.stringify({ path }) });
}
- Step 2: CwdPicker 组件
CwdPicker.tsx:实现 mockup(webui/mockup.html 的 #cwdModal)那套——路径输入框(~ 支持)、面包屑 + 子目录点选(调 listDir)、最近项目列表(调 getProjects)、底部「同时设为默认目录」checkbox(勾选时额外调 setDefaultDir)。选定后 onPick(path) 回调上抛,关闭弹窗。
import { useState, useEffect } from 'preact/hooks';
import { listDir, getProjects, setDefaultDir } from '../api';
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);
}
- Step 3: App 接线 + 顶栏面包屑
app.tsx:维护 const [cwd, setCwd] = useState<string>(/* 初始取 /project */);顶栏 cwd 面包屑点击打开 CwdPicker;onPick 设 setCwd。把 cwd 作为 prop 传给 Chat(Task 13 的 working_dir: cwd)。
- Step 4: 初始 cwd 来源
App 挂载时调 GET /project 取当前 working_dir 作为初始 cwd;切换会话时用该 session 的 working_dir(来自 /sessions 列表项)。
- Step 5: 构建 + 嵌入 + 手测
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
手测:顶栏面包屑可点 → 弹切换器 → 浏览子目录/选最近项目 → 选定后发消息时 agent 在新目录下操作(让它 ls/读文件验证 cwd 生效)。
Expected: cwd 切换对 agent 工具生效,且不影响其它客户端(不勾「默认」时)。
- Step 6: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 工作目录切换器(每会话 cwd + 目录浏览)"
Milestone 5 — 收尾
Task 16: /webui stop + dev 模式 + 文档
Files:
-
Modify:
crates/atomcode-tuix/src/event_loop/commands.rs(webui分支支持stop参数) -
Modify:
crates/atomcode-daemon/src/webui.rs(ATOMCODE_WEBUI_DEV重定向到 vite dev server) -
Create/Modify:
webui/README.md(构建说明);主README.md/README.zh-CN.md加 webui 段 -
Step 1:
/webui stop
webui 分支解析 arg:stop 时停掉进程内 server。在 Task 9 的 WebuiHandle 里存 server task 的
tokio::task::AbortHandle(或一个 watch::Sender<bool> shutdown 信号),stop 时 abort/发信号并
把 WEBUI 标记复位(或仅标记为已停,下次 /webui 重新起)。提示「已关闭 webui server」。
因 server 在进程内,不用 HTTP
/shutdown;直接操作 task handle 更可靠。
- Step 2: dev 重定向
webui::serve_webui 开头:若 std::env::var("ATOMCODE_WEBUI_DEV") 存在,则 302 重定向到该地址 + 原 path,便于前端热更新开发。
- Step 3: 文档
webui/README.md 写:开发 npm run dev + 设 ATOMCODE_WEBUI_DEV;发布 npm run build 后 cargo build。主 README 加 /webui 用法与「作为 TUI 之外入口」的定位说明。
- Step 4: 构建 + 全量编译 + 手测
Run: cd webui && npm run build && cd .. && cargo build && cargo test -p atomcode-daemon
手测:/webui stop 能停进程内 server;设 ATOMCODE_WEBUI_DEV=http://localhost:5173 后访问根路径重定向到 vite。
Expected: 均正常。
- Step 5: 提交
git add webui/ crates/ README.md README.zh-CN.md
git commit -m "feat(webui): /webui stop + dev 重定向 + 文档"
Task 17: 构建管线集成
Files:
-
Modify: 发布脚本(
scripts/下相关打包脚本) -
Step 1: 找到发布脚本
ls scripts/:在打包 主程序 atomcode 的脚本(release.sh/*-release-*.sh)里,于 cargo build --release 之前插入 (cd webui && npm ci && npm run build),保证嵌入主程序的是最新前端。
关键:前端现在嵌入的是
atomcode主程序(经 daemon 库run_server服务),不再只是 daemon 二进制——主程序的发布管线必须包含前端构建。
- Step 2: 把 webui/dist 是否入库的决策落地
二选一并在脚本/README 注明:(a) webui/dist 不入库(.gitignore 加它,构建时现生成)——推荐,避免产物进 git;(b) 入库便于无 node 环境直接 cargo build。Phase 1 取 (a),但需保证 webui/dist/index.html 在 CI 之外的 cargo build 时存在——故 rust-embed 的 folder 目录至少要有占位文件,或脚本保证先构建。
注意与 Task 3 的占位
webui/dist/index.html协调:若选 (a) 则该占位文件改为.gitkeep+ build.rs 保障;实现时统一。
- Step 3: 提交
git add scripts/ .gitignore
git commit -m "build: 发布管线集成 webui 前端构建"
Milestone 6 — Phase 1.5:TUI ⇄ webui 实时同步(方案 A)
前提:Milestone 0–5 已完成(基础 webui 跑通)。本里程碑碰 TUI 事件循环,独立于基础功能、最后做。 模型:进程内「活动会话总线」
LiveSession——单一Conversation+broadcast<TurnEvent>扇出 + 单一mpsc<UserInput>输入 + 单写者守卫。TUI 与浏览器退化为「订阅渲染 + 投递输入」的视图。 默认:webui 默认独立,开关「同步当前 TUI 会话」才走总线;turn 进行中另一端输入禁用并提示; 晚加入先 replay 快照再接增量;审批两端都收、先到先得。
Task 18: LiveSession 总线(core,可单测)
Files:
-
Create:
crates/atomcode-core/src/turn/live_session.rs -
Modify:
crates/atomcode-core/src/turn/mod.rs(pub mod live_session;) -
Step 1: 写测试
live_session.rs 底部:
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
}
- Step 2: 运行确认失败
Run: cargo test -p atomcode-core live_session
Expected: 编译失败(LiveSession 未定义)。
- Step 3: 实现
//! 进程内「活动会话总线」:TUI 与 webui 共享同一活动会话的单一数据源 + 事件扇出。
//! 同进程共享内存,故无需跨进程/网络同步。
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() }
}
impl Default for LiveSession { fn default() -> Self { Self::new() } }
Conversation是否Default、messages字段名以 core 实际为准(daemon 已用conv.messages.clone())。
- Step 4: 测试通过
Run: cargo test -p atomcode-core live_session
Expected: 2 测试 PASS。
- Step 5: 提交
git add crates/atomcode-core/src/turn/live_session.rs crates/atomcode-core/src/turn/mod.rs
git commit -m "feat(core): LiveSession 进程内活动会话总线"
Task 19: turn 协调器(驱动总线上的 turn)
Files:
-
Modify:
crates/atomcode-core/src/turn/live_session.rs(加run_coordinator) -
Step 1: 实现协调器
/// 单一 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); } // 方法名以 Conversation 实际 API 为准
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(Task 0 已迁入lib.rs)抄同一套,只把事件 sink 从 SSE 换成live.emit。
- Step 2: 编译
Run: cargo build -p atomcode-core
Expected: 通过(签名对齐后)。
- Step 3: 提交
git add crates/atomcode-core/src/turn/live_session.rs
git commit -m "feat(core): LiveSession turn 协调器(单写者 + 事件扇出)"
Task 20: server /live SSE + POST /live/message
Files:
-
Modify:
crates/atomcode-daemon/src/lib.rs(AppState 持有Option<LiveSession>;新路由) -
Step 1: AppState 持有 LiveSession + 启动协调器
AppState 加 pub live: Arc<RwLock<Option<atomcode_core::turn::live_session::LiveSession>>>。
首次开启同步时创建 LiveSession、tokio::spawn(run_coordinator(...)),存入。
- Step 2:
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 序列化逻辑,抽成函数共用。)
- Step 3:
POST /live/message(投 input_tx)
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))。
- Step 4: 编译
Run: cargo build -p atomcode-daemon
Expected: 通过。
- Step 5: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): /live SSE 快照+实时流 + /live/message 输入"
Task 21: TUI 同步模式(输入走总线 + 渲染订阅 broadcast)
Files:
-
Modify:
crates/atomcode-tuix/src/event_loop/mod.rs、commands.rs -
Step 1:
/webui sync开启同步
webui 分支支持 sync 参数:开启时用 TUI 当前会话初始化共享 LiveSession 的 conversation,
启动协调器(若未启),开关状态存进 TUI state。
- Step 2: 输入改道
同步开启时,TUI 发送逻辑(现走 AgentLoop/agent_handle)改为 live.submit(UserInput{...});
读现有 handle_send_message 路径,加 if state.synced { live.submit } else { 现状 } 分叉。
- Step 3: 渲染订阅 broadcast
同步开启时 spawn 任务 live.subscribe() 把 TurnEvent 喂进 TUI 现有事件渲染入口(与 agent_handle
事件同一 render sink)。turn 进行中禁用输入框并提示「对方正在对话」。
最依赖现状:读
event_loop/mod.rs里 agent 事件如何进入渲染(TurnEvent处理处),把 broadcast 事件接到同一处。不改渲染本身,只多一个事件来源。
- Step 4: 编译 + 端到端手测
Run: cargo build -p atomcode-tuix
手测:TUI /webui sync → 浏览器开同步 → 浏览器发消息 TUI 实时渲染;TUI 发消息浏览器实时渲染;
turn 进行中两端输入禁用。
Expected: 双向实时同步生效。
- Step 5: 提交
git add crates/atomcode-tuix/src
git commit -m "feat(tuix): /webui sync 同步模式(输入走总线+渲染订阅)"
Task 22: 前端同步开关 + /live 接入
Files:
-
Modify:
webui/src/api.ts、webui/src/app.tsx、webui/src/components/Chat.tsx -
Step 1: api 加 /live
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();
}
- Step 2: 同步开关 + 接入
顶栏加「🔗 同步 TUI 会话」开关。开启时 Chat 改用 streamLive(先收 snapshot_message 渲染历史、
再接增量),发送改用 sendLive;收到 turn_in_progress 或运行中事件时禁用输入并提示「对方正在对话」。
关闭时回到独立 /chat 模式。
- Step 3: 构建 + 嵌入 + 手测
Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon
手测:开关开启后与 TUI 双向实时同步;关闭后独立。
- Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 同步 TUI 会话开关 + /live 接入"
Task 23: 同步模式下的工具审批(两端可批,先到先得)
Files:
-
Modify:
crates/atomcode-core/src/turn/live_session.rs或协调器;crates/atomcode-tuix、webui -
Step 1: 审批走 broadcast
协调器跑 turn 时权限请求作为 TurnEvent::ApprovalRequested 进 broadcast → TUI 与浏览器同时弹卡片。
决定回送到协调器持有的单一 response 通道(PermissionResponders 思路,key 为活动会话)。
- Step 2: 先到先得
第一个到达的决定生效,置 decided 标记;后到的忽略。broadcast 一条 permission_resolved { call_id },
两端收到后关闭各自卡片。
- Step 3: 编译 + 手测
手测:同步态触发需审批工具,TUI 和浏览器同时弹卡片;任一端批准,另一端卡片自动消失,agent 继续。 Expected: 无双重批准、无卡死。
- Step 4: 提交
git add crates/ webui/
git commit -m "feat(webui): 同步模式工具审批两端可批先到先得"
Self-Review 检查记录
- Spec 覆盖:定位(M3/M4 入口+完整聊天) ✓;嵌入二进制(Task 3-4,嵌入主程序经 run_server 服务) ✓; 服务形态=合进主程序进程内(Task 0 库化 + Task 9 进程内启动器) ✓;daemon 后端复用(全程) ✓; 交互式权限(M2) ✓;本地 token 安全(M0) ✓;工作目录每会话独立+/fs/list(M4.5) ✓; 会话独立共享历史(Task 15 走 /sessions) ✓;Phase 2 可插拔鉴权(auth_token.rs 注释 + 收口) ✓; Origin 校验(复用现有 origin_is_allowed,Task 8) ✓;体积 feature 门控(spec 体积控制节,实现于 Cargo feature) ✓; 测试策略(各 backend Task 带单测 + 前端手测清单) ✓。
- 依赖关系:daemon→core(单向,既有);tuix→daemon(新增,Task 9b);cli→daemon(新增,Task 10)。 均不成环(core 不依赖 daemon)。run_server 库化(Task 0) 是这一切的前置,必须最先做。
- 待实现者现场核对的点(非占位,「依现状对齐」):① Task 0 库化是机械迁移,逐个编译错误修、不改行为;
② axum 0.7 中间件签名以
activity_tracker_middleware(main.rs:732) 为准;③ToolCall字段名; ④chat_stream的yield/stream 宏形式与ChatRequest字段;⑤ VSCode 客户端兼容(Task 8 关键); ⑥push_system_message真实方法名 +match cmd是否 async 上下文(Task 9b); ⑦ServerOpts加webui_tokens: Option<WebuiTokenStore>字段贯穿 Task 0/2/9。 - DRY:
ensure_server_and_open单一实现放 daemon 库,tuix 与 cli 共用(Task 9/9b/10),无重复。 - 类型一致:
WebuiTokenStore、PermissionResponders、ServerOpts{webui_tokens}、PermissionDecision(Allow|Ask|Deny)、SSEtype:"permission_request"、/chat/permission的decision:"allow"|"deny"|"always_allow"、/fs/list的{path,dirs}全程一致。 - 分发修复:原计划假设
atomcode-daemon二进制随主程序分发(实际 install.sh 只装主程序)—— 改为进程内库调用后,普通用户零额外下载即可/webui。 - Phase 1.5(M6)实时同步:依赖进程内同进程前提(M0 库化 + M3 进程内 server);
LiveSession放 core(tuix/daemon 都依赖 core,无环)。最重的「依现状对齐」是 Task 19/21——TurnRunner::run签名、ConversationAPI、TUI 渲染事件入口,均照 daemonchat_stream与 tuix 现有 agent 事件路径抄。 默认值(已确认):webui 默认独立、开关同步;turn 进行中另端输入禁用;晚加入 replay 快照; 审批两端先到先得。M6 独立于 M0–M5,建议基础 webui 跑通后再做。