PicoBot/docs/superpowers/plans/2026-07-23-p0-webui-foundation.md

617 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 5runes、Bits UI、Vite、CSS custom propertiesRust/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.js`
- `webui/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.js` handlerinclude_str!
- `src/gateway/mod.rs` — 公开静态路由组追加 `/fonts/{name}``/theme-init.js`
- `build.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版本锁定、可复现
```bash
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 行)追加:
```rust
"webui/public",
```
- [ ] **Step 3: http.rs 增加字体 handler**
`src/gateway/http.rs``webui_styles` 之后)新增:
```rust
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` 处)追加:
```rust
.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**
```bash
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 调整):
```css
@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 的颜色实际来自这里)。
- 新增工具类(供组件使用):
```css
.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**
```bash
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: 实现**
```js
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: 实现**
```js
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 先显示版本与连接态。
```svelte
<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 静默重连。
```js
$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>`。改为独立同源脚本文件:
1. Create `webui/public/theme-init.js`vite publicDir 会原样复制到 `OUT_DIR/webui/theme-init.js`
```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) {}
```
2. `src/gateway/http.rs` 新增 handler与 `webui_script` 同构):
```rust
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
```
3. `src/gateway/mod.rs` 公开静态路由组追加 `.route("/theme-init.js", routing::get(http::webui_theme_init))`。
4. `webui/index.html``<meta name="theme-color">` 的 `#0d1117` 改为 `#0b1017``<head>` 内加解析阻塞引用 `<script src="/theme-init.js"></script>`(同源,被 `script-src 'self'` 允许)。
5. 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` 行为完全一致**
```js
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` 风格摘要;工具调用沿用 ToolCallCardTask 3.2 重制)。
- 流式 turnTurnView下方显示 `▲ 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** — 套用 tokensToast 用 `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.json` bump 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配置编辑器将各自编写独立计划。