28 KiB
P0 地基 Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 建立 Signal Deck 设计系统(双主题 tokens + 内嵌字体)、应用外壳(扁平导航 + 全局聊天 WS + 活动脊 + 主题/鉴权),并按新设计重构聊天页,产出可工作的聊天优先控制台地基。
Architecture: 用 CSS 自定义属性表达 Signal Deck tokens(:root 暗色 / :root[data-theme="light"] 亮色),整体重写 styles.css。将聊天 WebSocket 连接从 ChatPage 提升为模块级单例 lib/chat.svelte.js,由 App 外壳统一持有,使活动脊在所有页面可用;ChatPage 订阅帧并保留全部现有逻辑(会话/消息/turn 快照/计划/上传/斜杠命令)。两个拉丁字体经 vite publicDir 以固定名输出,http.rs 用 include_bytes! 内嵌并提供同源路由,维持单二进制与现有 CSP。
Tech Stack: Svelte 5(runes)、Bits UI、Vite、CSS custom properties;Rust/Axum(字体路由)、build.rs + vite(嵌入管线)。
验证约定(重要): 本仓库前端没有单元测试框架。前端任务以 npm run check(svelte-check)+ npm run build + 浏览器目检为验证手段(见 AGENTS.md);涉及 Rust 的任务以 cargo build + cargo test --lib + cargo clippy --all-targets --all-features -- -D warnings 验证。不要虚构前端测试。
参考文档: 设计规格 docs/superpowers/specs/2026-07-23-webui-refactor-design.md(§4 设计系统、§5 信息架构、§6.1 聊天页、§8 前端架构)。配色/组件 mockup 见 .superpowers/brainstorm/111044-1784795642/(design-system.html、page-chat.html)。
File Structure
Create:
webui/public/fonts/space-grotesk-500.woff2、space-grotesk-700.woff2、jetbrains-mono-400.woff2、jetbrains-mono-700.woff2— 内嵌拉丁字体(vite publicDir 原样复制到产物根)webui/public/theme-init.js— 首屏防闪烁主题初始化脚本(CSP 安全,经/theme-init.js路由提供)webui/src/lib/theme.js— 主题检测/应用/持久化webui/src/lib/chat.svelte.js— 全局聊天 WS 单例(连接/重连/订阅/发送/最新 turn 快照)webui/src/lib/components/ActivitySpine.svelte— 全局活动脊
Modify:
webui/src/styles.css— 全面重写为 Signal Deck tokens + @font-face + 组件样式webui/src/App.svelte— 外壳:扁平导航、全局 WS、活动脊、主题切换、鉴权webui/src/pages/ChatPage.svelte— 改用全局 chat client + Signal Deck 三栏布局(保留全部逻辑)webui/src/pages/PairingPage.svelte— 套用新 tokens(结构不变)webui/index.html— theme-color 更新为#0b1017+<head>引入/theme-init.jswebui/src/lib/ToolCallCard.svelte、TurnView.svelte、Markdown.svelte、Toast.svelte— 套用新 tokens/类名(StatusBadge 无独立样式,随 styles.css 的.badge.*更新)src/gateway/http.rs— 字体路由(include_bytes! + font/woff2)+/theme-init.jshandler(include_str!)src/gateway/mod.rs— 公开静态路由组追加/fonts/{name}与/theme-init.jsbuild.rs—rerun-if-changed增加webui/public
不动: 后端聊天/配置/记忆等现有端点(P0 纯前端 + 字体路由)。
Chunk 1: 设计 tokens 与字体内嵌管线
Task 1.1: 内嵌字体(publicDir + http.rs 路由)
Files:
-
Create:
webui/public/fonts/{space-grotesk-500,space-grotesk-700,jetbrains-mono-400,jetbrains-mono-700}.woff2 -
Modify:
src/gateway/http.rs(新增字体 handler 与路由) -
Modify:
src/gateway/mod.rs(注册/fonts/{name}路由,公开静态资源层) -
Modify:
build.rs(rerun-if-changed=webui/public) -
Step 1: 获取并提交字体文件
从 @fontsource 取 latin 子集 woff2(版本锁定、可复现):
cd webui
npm i -D @fontsource/space-grotesk @fontsource/jetbrains-mono
mkdir -p public/fonts
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-500-normal.woff2 public/fonts/space-grotesk-500.woff2
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2 public/fonts/space-grotesk-700.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2 public/fonts/jetbrains-mono-400.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2 public/fonts/jetbrains-mono-700.woff2
若 @fontsource 文件路径/命名随版本不同,用 ls node_modules/@fontsource/*/files/ | grep latin 找到对应 latin 500/700/400 的 normal woff2。确认 4 个文件均为非空 woff2。@fontsource 仅为取字体的 devDependency,运行时不依赖。
- Step 2: build.rs 监听 public 目录
在 build.rs 的 build_webui 的监听列表(约 64-73 行)追加:
"webui/public",
- Step 3: http.rs 增加字体 handler
在 src/gateway/http.rs(webui_styles 之后)新增:
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"space-grotesk-500.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-500.woff2")),
),
(
"space-grotesk-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-700.woff2")),
),
(
"jetbrains-mono-400.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
),
(
"jetbrains-mono-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
),
];
pub async fn webui_font(Path(name): Path<String>) -> Response {
let bytes = EMBEDDED_FONTS
.iter()
.find(|(font_name, _)| *font_name == name)
.map(|(_, bytes)| *bytes);
let Some(bytes) = bytes else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.header(header::CONTENT_TYPE, "font/woff2")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(bytes))
.expect("valid font response")
}
(Path 已在文件顶部 axum::extract 导入。)
- Step 4: mod.rs 注册字体路由(公开层,随静态资源)
在 src/gateway/mod.rs 的公开静态路由组(约 592-596 行,/、/app.js、/styles.css 处)追加:
.route("/fonts/{name}", routing::get(http::webui_font))
字体属静态资源层,不进设备鉴权(与 app.js/styles.css 同级;CSP default-src 'self' 已允许同源 font)。
- Step 5: 构建验证
Run: cargo build(会自动触发 vite 构建,public/fonts 复制到 OUT_DIR/webui/fonts)
Expected: 编译成功,无 clippy 级错误。
Run: cargo clippy --all-targets --all-features -- -D warnings
Expected: 无警告。
- Step 6: Commit
git add webui/public/fonts build.rs src/gateway/http.rs src/gateway/mod.rs webui/package.json webui/package-lock.json
git commit -m "feat(webui): embed latin fonts and serve via /fonts route"
Task 1.2: 重写 styles.css 为 Signal Deck tokens
Files:
-
Modify:
webui/src/styles.css(整体重写) -
Step 1: 写入 @font-face 与 tokens
将 styles.css 顶部的 :root / :root[data-theme="light"] 块整体替换为(保留文件其余组件类,随后在 Step 2 调整):
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-500.woff2") format("woff2");
font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-400.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
:root {
--font-ui: "Space Grotesk", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
color-scheme: dark;
font-family: var(--font-ui);
color: #e7ecf3;
background: #0b1017;
--bg: #0b1017;
--panel: #0e1520;
--panel-2: #131c29;
--sidebar: #0d131c;
--header: rgb(11 16 23 / 84%);
--line: #1d2733;
--line-strong: #2c3a4c;
--muted: #8fa3b8;
--faint: #5b6b7e;
--text: #e7ecf3;
--text-soft: #b8c4d4;
--accent: #ffb454; /* amber = 活动 */
--accent-hover: #ffc370;
--accent-contrast: #1a1206;
--accent-soft: rgb(255 180 84 / 12%);
--accent-border: rgb(255 180 84 / 35%);
--signal: #2dd4bf; /* teal = 健康 */
--signal-soft: rgb(45 212 191 / 12%);
--signal-border: rgb(45 212 191 / 35%);
--info: #6aa6ff;
--info-soft: rgb(106 166 255 / 12%);
--danger: #ff7b86;
--danger-soft: rgb(255 123 134 / 12%);
--danger-border: rgb(255 123 134 / 35%);
--warning: #ffb454;
--warning-soft: rgb(255 180 84 / 10%);
--success-soft: rgb(45 212 191 / 12%);
--overlay: #101826;
--code-bg: #080c12;
--user-bubble: #221d38;
--spine-bg: #0e1520; /* 活动脊:亮色下也保持深色 */
--shadow: 0 16px 45px rgb(0 0 0 / 35%);
--radius: 11px;
}
:root[data-theme="light"] {
color-scheme: light;
color: #1a2230;
background: #eef1f5;
--bg: #eef1f5;
--panel: #ffffff;
--panel-2: #f4f6f9;
--sidebar: #f7f9fc;
--header: rgb(238 241 245 / 86%);
--line: #d8dee8;
--line-strong: #c2ccd9;
--muted: #5b6b7e;
--faint: #8494a8;
--text: #1a2230;
--text-soft: #3d4b5e;
--accent: #c47400;
--accent-hover: #a86300;
--accent-contrast: #ffffff;
--accent-soft: rgb(196 116 0 / 10%);
--accent-border: rgb(196 116 0 / 35%);
--signal: #0d9488;
--signal-soft: rgb(13 148 136 / 10%);
--signal-border: rgb(13 148 136 / 35%);
--info: #2f6fd0;
--info-soft: rgb(47 111 208 / 10%);
--danger: #d94354;
--danger-soft: rgb(217 67 84 / 10%);
--danger-border: rgb(217 67 84 / 35%);
--warning: #c47400;
--warning-soft: rgb(196 116 0 / 8%);
--success-soft: rgb(13 148 136 / 10%);
--overlay: #ffffff;
--code-bg: #f7f9fc;
--user-bubble: #ece7fb;
--spine-bg: #0e1520; /* 亮色下活动脊仍是深色 LED 条 */
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
}
- Step 2: 调整组件类以适配新 tokens
逐个检查并更新其余组件类(原文件 60 行起):
- 所有
font-family硬编码处改用var(--font-ui);数据/日志/时间戳/code/.mono类用var(--font-mono)。 - 原紫色相关(
--accent旧值、--user-bubble)已由 tokens 替换,确认无残留硬编码 hex。 .primary按钮:background: var(--accent); color: var(--accent-contrast);(暗色下琥珀底深字,亮色下深琥珀底白字)。- 状态点/在线指示:健康用
var(--signal),活动/进行中用var(--accent),错误用var(--danger)。 - 两处硬编码绿色必须手动改为
var(--signal)(否则不随 tokens 更新):.gateway-status i.online { color: #48b985 }(约 93 行)与.badge.ok { color: #38a877 }(约 267 行,StatusBadge 的颜色实际来自这里)。 - 新增工具类(供组件使用):
.mono { font-family: var(--font-mono); }
.label-caps { font-family: var(--font-mono); font-size: 9px; letter-spacing: .16em; color: var(--faint); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.cap { display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; font-weight: 600; border-radius: 6px; padding: 2.5px 8px; }
.cap.signal { color: var(--signal); background: var(--signal-soft); border: 1px solid var(--signal-border); }
.cap.accent { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); }
.cap.danger { color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger-border); }
.cap.info { color: var(--info); background: var(--info-soft); border: 1px solid var(--line); }
@keyframes spine-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; animation: spine-pulse 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
- Step 3: 验证
Run: cd webui && npm run check && npm run build
Expected: svelte-check 无错误;构建成功。
- Step 4: Commit
git add webui/src/styles.css
git commit -m "feat(webui): Signal Deck design tokens and base styles"
Chunk 2: 核心 lib 与应用外壳
Task 2.1: theme.js 主题管理
Files:
-
Create:
webui/src/lib/theme.js -
Step 1: 实现
const STORAGE_KEY = "picobot-theme";
export function preferredTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark") return saved;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", theme === "dark" ? "#0b1017" : "#eef1f5");
localStorage.setItem(STORAGE_KEY, theme);
}
- Step 2: 验证 —
cd webui && npm run check(无错误) - Step 3: Commit —
git add webui/src/lib/theme.js && git commit -m "feat(webui): theme helpers"
Task 2.2: chat.svelte.js 全局聊天客户端
Files:
- Create:
webui/src/lib/chat.svelte.js
将 ChatPage 的连接/重连生命周期提取为模块级单例。帧分发保留给订阅者(ChatPage 搬入其 handleFrame 逻辑);客户端额外暴露最新 turn 快照供活动脊使用。
- Step 1: 实现
import { clientId } from "./api.js";
class ChatClient {
connected = $state(false);
turn = $state(null); // 最新 turn 快照(任意 session),供活动脊
#socket = null;
#handlers = new Set();
#reconnectTimer = null;
#stopped = false;
connect() {
if (this.#socket) return;
this.#stopped = false;
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
this.#socket = ws;
ws.onopen = () => {
this.connected = true;
this.#dispatch({ type: "_open" });
};
ws.onerror = () => ws.close();
ws.onclose = () => {
this.connected = false;
this.#socket = null;
this.#dispatch({ type: "_close" });
if (!this.#stopped) this.#reconnectTimer = setTimeout(() => this.connect(), 1800);
};
ws.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
this.#dispatch(frame);
};
}
disconnect() {
this.#stopped = true;
clearTimeout(this.#reconnectTimer);
this.#socket?.close();
this.#socket = null;
}
send(frame) {
if (this.#socket?.readyState === WebSocket.OPEN) {
this.#socket.send(JSON.stringify(frame));
return true;
}
return false;
}
subscribe(handler) {
this.#handlers.add(handler);
return () => this.#handlers.delete(handler);
}
#dispatch(frame) {
for (const handler of this.#handlers) handler(frame);
}
}
export const chat = new ChatClient();
- Step 2: 验证 —
cd webui && npm run check - Step 3: Commit —
git add webui/src/lib/chat.svelte.js && git commit -m "feat(webui): global chat websocket client"
Task 2.3: ActivitySpine.svelte 活动脊
Files:
-
Create:
webui/src/lib/components/ActivitySpine.svelte -
Step 1: 实现
活动脊显示:Turn 实时状态(来自 chat.turn 快照)+ 吞吐(前端对相邻帧 usage.completion_tokens 差值求导)+ 连接状态。gen/uptime/metrics 等字段在 P1 由 /api/status 补充,P0 先显示版本与连接态。
<script>
import { chat } from "../chat.svelte.js";
let { version = "" } = $props();
let lastTokens = $state(null); // { at, completion }
let rate = $state(null);
$effect(() => {
const turn = chat.turn;
if (!turn || turn.status !== "running") { rate = null; return; }
const completion = turn.usage?.completion_tokens;
const now = Date.now();
if (completion != null && lastTokens && now > lastTokens.at) {
const delta = completion - lastTokens.completion;
const secs = (now - lastTokens.at) / 1000;
if (delta >= 0 && secs > 0) rate = Math.round(delta / secs);
}
if (completion != null) lastTokens = { at: now, completion };
});
const running = $derived(chat.turn?.status === "running");
const turnLabel = $derived(chat.turn ? `TURN ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : "");
const ctx = $derived(chat.turn?.usage?.prompt_tokens != null
? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
</script>
<div class="spine mono">
{#if running}
<span class="spine-turn active"><i class="pulse-dot" style="background:var(--accent);box-shadow:0 0 10px var(--accent)"></i>{turnLabel} · STREAMING</span>
{#if rate != null}<span class="spine-rate">▲ {rate} tok/s</span>{/if}
{#if ctx}<span>ctx {ctx}</span>{/if}
{:else if chat.turn}
<span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>IDLE</span>
<span>最近 {turnLabel}</span>
{:else}
<span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>READY</span>
{/if}
<span class="spine-right">
<span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span>
{#if version}<span>{version}</span>{/if}
</span>
</div>
<style>
.spine { display: flex; align-items: center; gap: 14px; font-size: 10.5px; color: var(--muted);
background: var(--spine-bg); border-bottom: 1px solid var(--line); padding: 8px 16px; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
.spine-turn.active { color: var(--accent); }
.spine-turn.idle { color: var(--signal); }
.spine-rate { color: var(--signal); }
.spine-right { margin-left: auto; display: inline-flex; gap: 14px; color: var(--faint); }
.spine-ok { color: var(--signal); }
.spine-down { color: var(--warning); }
</style>
(.mono、.pulse-dot 来自 styles.css 工具类。)
- Step 2: 验证 —
cd webui && npm run check - Step 3: Commit —
git add webui/src/lib/components/ActivitySpine.svelte && git commit -m "feat(webui): global activity spine"
Task 2.4: App.svelte 外壳重构
Files:
-
Modify:
webui/src/App.svelte -
Step 1: 重构
要点(保留现有鉴权/配对/health 逻辑,替换导航与布局):
-
onMount中:applyTheme(preferredTheme());health 轮询保留(取 version 传给 ActivitySpine)。 -
WS 生命周期跟随"已鉴权外壳"而非根 onMount:用
$effect监听authenticated——authenticated为真时chat.connect(),为假(如凭据被撤销、外壳卸载回配对页)时chat.disconnect()。避免鉴权失效后客户端仍在后台每 1.8s 静默重连。$effect(() => { if (authenticated) { chat.connect(); } else { chat.disconnect(); } }); -
页面数组改为扁平导航(图标 + 名称):
["chat","◫","聊天"]、["overview","◉","概览"]、["tools","🧰","工具&Skills"]、["logs","≋","日志"]、["memory","◇","记忆"]、["tasks","⌁","任务"]、["settings","⚙","配置"]。P0 中 overview/tools 页面尚未实现,先渲染占位<div class="empty-card">即将上线</div>(P1/P2 补齐);logs/memory/tasks/settings 复用现有页面组件。 -
结构:
<aside class="sidebar">(品牌 + 扁平 nav + 底部网关状态/主题切换)+<main>内<ActivitySpine {version} />置顶 + 页面区。 -
主题切换按钮调用
applyTheme(theme === "dark" ? "light" : "dark")并更新theme状态。 -
需要的新 import:
import { chat } from "./lib/chat.svelte.js"、import { applyTheme, preferredTheme } from "./lib/theme.js"、import ActivitySpine from "./lib/components/ActivitySpine.svelte"。 -
WS 断开由上面的
$effect负责(authenticated=false时 disconnect);如需双保险,onMount清理函数return () => chat.disconnect()亦可,两者不冲突。 -
Step 2: index.html 防主题闪烁(CSP 安全方案)
现有 CSP 为 script-src 'self'(无 'unsafe-inline'),不能写内联 <script>。改为独立同源脚本文件:
- Create
webui/public/theme-init.js(vite publicDir 会原样复制到OUT_DIR/webui/theme-init.js):
try {
var t = localStorage.getItem("picobot-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
} catch (e) {}
src/gateway/http.rs新增 handler(与webui_script同构):
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
src/gateway/mod.rs公开静态路由组追加.route("/theme-init.js", routing::get(http::webui_theme_init))。webui/index.html:<meta name="theme-color">的#0d1117改为#0b1017;<head>内加解析阻塞引用<script src="/theme-init.js"></script>(同源,被script-src 'self'允许)。- File Structure 与 Task 1.1 的 build.rs 监听已含
webui/public(theme-init.js 随之复制)。
- Step 3: 验证 —
cd webui && npm run check && npm run build - Step 4: 目检 —
cargo run -- gateway后打开 http://127.0.0.1:19876/,确认:暗/亮主题切换生效且持久化、无首屏闪烁;活动脊显示"已连接/READY";导航 7 项齐全;未实现页面显示占位。 - Step 5: Commit —
git add webui/public/theme-init.js src/gateway/http.rs src/gateway/mod.rs webui/src/App.svelte webui/index.html && git commit -m "feat(webui): app shell with flat nav, activity spine, and theme init"
Task 2.5: PairingPage 套用新 tokens
Files:
-
Modify:
webui/src/pages/PairingPage.svelte -
Step 1: 将硬编码颜色替换为新 tokens(结构与逻辑不变,仅样式对齐 Signal Deck)。
-
Step 2: 验证 —
npm run check;未配对状态下目检配对页。 -
Step 3: Commit —
git add webui/src/pages/PairingPage.svelte && git commit -m "style(webui): pairing page Signal Deck tokens"
Chunk 3: 聊天页重构
Task 3.1: ChatPage 接入全局客户端 + 三栏布局
Files:
- Modify:
webui/src/pages/ChatPage.svelte
这是 P0 最大的改动。原则:全部现有业务逻辑(handleFrame 各分支、上传、斜杠补全、计划侧栏、历史校准)原样保留,只做两件事:(a) 连接生命周期改用 chat 单例;(b) 套用 Signal Deck 类名/三栏布局。
-
Step 1: 连接改造
-
删除组件内
connect()/socket/reconnectTimer/stopped与onMount中的连接代码。 -
onMount中改为(注意:重连时必须重置计划相关状态,与重构前connect()的onopen行为完全一致):const unsubscribe = chat.subscribe(handleFrame); const onOpen = (frame) => { if (frame.type !== "_open") return; // 与重构前一致:每次(重)连接都重置计划状态再拉取 plansBySession = {}; unseenPlanSessions = {}; todoOpen = false; chat.send({ type: "list_sessions", include_archived: false }); chat.send({ type: "get_slash_commands" }); }; const unsubOpen = chat.subscribe(onOpen); if (chat.connected) onOpen({ type: "_open" }); // 已连接时首次挂载也走同一逻辑 return () => { unsubscribe(); unsubOpen(); clearPendingUploads(); }; -
所有
send(...)调用改为chat.send(...);connected改读chat.connected。 -
handleFrame中原session_established/session_list/... 分支逻辑不变。 -
Step 2: 布局与样式改造
-
顶层
<section class="page chat-layout">三栏:sessions-panel(左)|chat-panel(中)|todo-panel(右,{#if todoOpen && currentPlan})。 -
消息气泡:用户用
var(--user-bubble)+ 右下小圆角;助手无气泡底色、正文var(--text-soft)。 -
reasoning
<details>用.cap.info风格摘要;工具调用沿用 ToolCallCard(Task 3.2 重制)。 -
流式 turn(TurnView)下方显示
▲ tok/s(可复用 ActivitySpine 的速率逻辑,或简单显示status)。 -
输入区(composer):容器
var(--panel)+var(--line-strong)边框;发送按钮.primary(琥珀);连接状态点用--signal/--warning。 -
会话项激活态:左边框
var(--accent)+var(--panel-2)底。 -
头部操作、Todo 侧栏沿用现有结构,仅换 tokens。
-
Step 3: 验证 —
cd webui && npm run check && npm run build -
Step 4: 端到端目检 —
cargo run -- gateway+ 浏览器:新建对话、发消息、收到流式回复(活动脊出现 STREAMING)、工具卡片折叠展开、/ 命令补全、附件上传、Todo 侧栏随 plan_updated 弹出、切换亮/暗主题聊天页正常。 -
Step 5: Commit —
git add webui/src/pages/ChatPage.svelte && git commit -m "feat(webui): refactor chat page onto global client and Signal Deck"
Task 3.2: 重制共享组件(ToolCallCard / TurnView / Toast / Markdown)
Files:
- Modify:
webui/src/lib/ToolCallCard.svelte、TurnView.svelte、Toast.svelte、Markdown.svelte
(StatusBadge.svelte 本身无 <style>,其颜色来自 styles.css 的 .badge.*,已在 Task 1.2 处理,不在此列。)
- Step 1: ToolCallCard — 默认折叠卡片:左边框运行中=
var(--accent)(脉冲点)、完成=var(--signal)、失败=var(--danger);名称/耗时用.mono;展开显示参数与结果(<details>)。 - Step 2: TurnView — 流式渲染 reasoning(折叠)+ 正文 + 工具卡片 + 光标(
.pulse-dot或方块闪烁)+▲ tok/s。 - Step 3: Toast / Markdown — 套用 tokens:Toast 用
var(--overlay)+ 对应语义色边框;Markdown 的 code/pre 用var(--code-bg)+var(--font-mono),链接用var(--info)。 - Step 4: 验证 —
npm run check && npm run build;目检聊天流中的卡片/Toast/代码块。 - Step 5: Commit —
git add webui/src/lib && git commit -m "style(webui): shared components Signal Deck"
Task 3.3: P0 收尾验证
- Step 1: 全量构建与测试
Run: cd webui && npm run check && npm run build
Run: cargo build
Run: cargo test --lib
Run: cargo clippy --all-targets --all-features -- -D warnings
Expected: 全部通过。
- Step 2: 回归目检清单 — 配对流程、主题切换持久化、活动脊实时性、聊天全链路(含附件/命令/计划)、既有 logs/memory/tasks/settings 页面在新 tokens 下无样式崩坏。
- Step 3: 版本号 — 按 AGENTS.md「功能变化后更新版本号」,在
Cargo.toml与webui/package.jsonbump minor(如 1.3.0 → 1.4.0),并同步 README 中对 WebUI 的描述(如有)。 - Step 4: Commit —
git add -A && git commit -m "chore(release): P0 webui foundation"
P0 完成标志
- 单二进制
cargo build成功,字体经/fonts/*同源提供,无 CDN。 - 亮/暗双主题覆盖外壳与聊天页,活动脊全局可见且随 turn 实时变化。
- 聊天页功能与重构前完全一致(会话/消息/流式/工具/附件/命令/计划),仅视觉与连接归属变化。
npm run check、npm run build、cargo build、cargo test --lib、cargo clippy -- -D warnings全绿。
后续 P1(观测:Metrics + /api/status + 概览页 + 工具&Skills 页)、P2(日志流式 + 记忆可写 + 任务页)、P3(配置编辑器)将各自编写独立计划。