docs: add WebUI refactor spec and P0 implementation plan

This commit is contained in:
xiaoxixi 2026-07-24 08:54:43 +08:00
parent f7891806ab
commit 83d1d792bb
2 changed files with 920 additions and 0 deletions

View File

@ -0,0 +1,616 @@
# 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配置编辑器将各自编写独立计划。

View File

@ -0,0 +1,304 @@
# PicoBot WebUI 全面重构设计
- 状态:设计已确认,待实现
- 日期2026-07-23
- 范围:前端(`webui/`)全面重构 + 必要的后端接口新增/调整(`src/gateway/`
## 1. 背景与目标
现有 WebUISvelte 5 + Bits UI随二进制嵌入已具备聊天、配置、记忆、任务、日志、主题切换等基础能力但视觉与交互体验一般且缺少运行状况观测、工具/Skill 浏览、实时日志等能力。本次重构目标:
1. 前端可直接与 PicoBot 沟通(聊天,已有,增强体验)
2. 可修改 PicoBot 各项配置(已有,增强)
3. 可观察 PicoBot 运行情况(**新增**:运行状况仪表盘)
4. 可查看工具列表、Skill 列表(**新增**
5. 可查看实时日志(已有轮询,**升级为流式**
6. 支持亮色/暗色的美观且易用的 UI**全面重设计**
7. 可查看并管理记忆、定时任务等信息(记忆**新增可编辑/可删除**
## 2. 约束与不变量
- **单二进制发布**:前端构建产物仍打包进二进制,运行时从内存提供(`build.rs` → Cargo `OUT_DIR``include_str!`/`include_bytes!`)。最终用户无需 Node.js。
- **无外部 CDN**:生产页面不加载任何 CDN 资源。现有 CSP 为 `default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'`。字体等资产必须同源内嵌。
- **设备鉴权**:所有管理 API 与 `/ws``AuthManager` 保护;新增端点与 `/ws/logs` 同样走现有设备鉴权。
- **聊天复用现有链路**:浏览器聊天继续使用 `/ws``cli_chat` 渠道,复用 dialog scope、每会话串行 worker、历史持久化、出站 lane 与 turn 快照。WebUI 不直接调用 Provider 或 SessionManager。
- **密钥安全**`/api/status` 等任何新响应不得包含密钥;日志脱敏在源头(不在日志中记录 secret
- **只读优先**:除配置(现有可写)与记忆写入(新增)外,其余新能力均为只读。
## 3. 总体方案
- **定位**:均衡控制台,但**聊天优先**——打开即聊天,其余功能区通过扁平导航平等直达;全局"活动脊"提供常驻运行感知。
- **技术栈**:继续使用 Svelte 5 + Bits UI + Vite不引入新框架或状态管理库。
- **推进方式**:一份总体设计 + 分阶段实现(见 §9
## 4. 设计系统Signal Deck
视觉方向为"仪表盘 / 工程仪器":石墨蓝基底 + 琥珀(活动)/青绿(健康)双信号色,数据全部等宽字体,顶部一条永远在呼吸的"活动脊"作为签名元素。
### 4.1 色彩 Tokens
暗色(石墨蓝基底):
| Token | Hex | 用途 |
|-------|-----|------|
| bg | `#0B1017` | 页面背景 |
| panel | `#0E1520` | 面板/卡片 |
| panel-2 | `#131C29` | 次级面板/悬停 |
| border | `#1D2733` | 边框 |
| border-strong | `#2C3A4C` | 强调边框/输入框 |
| text | `#E7ECF3` | 主文本 |
| text-soft | `#B8C4D4` | 次级文本 |
| muted | `#8FA3B8` | 辅助文本 |
| faint | `#5B6B7E` | 最弱文本/时间戳 |
| amber | `#FFB454` | 活动/进行中/警告 |
| teal | `#2DD4BF` | 健康/成功/只读 |
| danger | `#FF7B86` | 错误/危险/独占 |
| info | `#6AA6FF` | 信息/Timeline/思考 |
| code-bg | `#080C12` | 代码/日志底 |
亮色(冷纸白,信号色加深保证对比):
| Token | Hex | Token | Hex |
|-------|-----|-------|-----|
| bg | `#EEF1F5` | text | `#1A2230` |
| panel | `#FFFFFF` | text-soft | `#3D4B5E` |
| panel-2 | `#F4F6F9` | muted | `#5B6B7E` |
| border | `#D8DEE8` | faint | `#8494A8` |
| border-strong | `#C2CCD9` | amber | `#C47400`(填充 `#E08600` |
| teal | `#0D9488` | danger | `#D94354` |
| info | `#2F6FD0` | code-bg | `#F7F9FC` |
**关键规则**:亮色模式下"活动脊"仍为深色条(`#0E1520`),像物理仪器上的 LED 读数——两种主题下同一个记忆点,不做简单反色。
### 4.2 字体
- 展示 / UISpace Grotesk内嵌 woff2仅拉丁中文回落系统字体PingFang SC / Microsoft YaHei / Noto Sans SC
- 数据 / 等宽JetBrains Mono内嵌 woff2用于所有指标、日志、时间戳、small-caps 标签。
- 字号阶梯9pxsmall-caps 标签letter-spacing .12.16em/ 11pxcaption、日志/ 12.514px正文/ 16px小标题/ 19px标题/ 2432px指标数字
- 生产环境不加载 CDN字体以内嵌二进制资产提供见 §8.3)。
### 4.3 签名元素活动脊Activity Spine
全局置于每个页面顶部的等宽状态条,两种状态:
- **有 Turn 在跑**:琥珀脉冲点 + `TURN 042 · STREAMING` + 实时 `▲ tok/s``ctx``queue``ws`,右侧 `gen #N · uptime · version`
- **空闲**:青绿常亮点 + `IDLE` + 最近 turn 摘要。
Turn 实时状态来自聊天 WS 已有的 `turn_updated` 快照(本就实时推送,`WsOutbound::TurnUpdated`gen/uptime/version 等来自 `/api/status` 轮询。各字段来源:`▲ tok/s` 由前端对相邻 `turn_updated` 帧的 `usage.completion_tokens` 差值求导(快照本身不含速率字段);`ctx` 取自 `usage.prompt_tokens``queue`/`ws` 取自 `/api/status`
### 4.4 核心组件
按钮primary=amber / secondary / ghost / danger、状态徽标正常/活动中/异常/离线)、指标块(大等宽数字 + sparkline + 分段容量条、日志行level 着色)、输入框、工具调用卡片(默认折叠,运行中=琥珀脉冲、完成=青绿、表格行、标签页、Toast。图表统一手写 SVG sparkline / 分段仪表,不引入图表库。
## 5. 信息架构与应用外壳
- **导航**:左侧扁平导航——聊天(落地页)、概览、工具&Skills、日志、记忆、任务、配置底部网关状态 + 主题切换。
- **应用外壳**:全局持有聊天 WS 连接(使活动脊在每个页面可用)、主题状态(`localStorage` 持久化 + `prefers-color-scheme` 默认)、设备鉴权状态(未配对显示 PairingPage
- **页面清单**:聊天 / 概览 / 工具&Skills / 日志 / 记忆 / 任务 / 配置,外加 PairingPage鉴权
## 6. 页面设计
### 6.1 聊天页(落地页)
三栏布局:会话列表(搜索/新建/按日期分组/未读点)| 消息流 Todo 计划侧栏(默认收起,按需展开)。
- reasoning 与工具调用默认折叠为紧凑卡片(运行中=琥珀脉冲,完成=青绿)。
- 流式 turn 显示光标与 `▲ tok/s`,输入区出现"停止"按钮。
- 斜杠命令补全来自后端 `get_slash_commands`(不在前端硬编码命令表)。
- 附件走 HTTP 上传(`POST /api/chat/{client_id}/uploads`WS 只传 `upload_id`;历史附件经 `GET .../attachments/{index}` 下载,安全 MIME 白名单内联预览。
- 正常完成合并 `turn_committed` 增量校准历史,不整段重载;断线/失败/取消用 `SessionHistory` 校准。
### 6.2 概览页(运行仪表盘)
- 主状态条:`RUNNING`、运行代、uptime、版本、WS 连接数、后台任务数、上次重载。
- 指标块(带 sparkline会话数、今日 Token+费用)、工具调用(+运行中)、今日 Turns+p95 延迟)。
- Provider 表:名称、模型、状态、延迟 sparkline、今日用量、费用。
- 消息总线inbound/outbound/control 队列深度分段容量条、活跃 lane 数、调度器状态、MCP 连接。
- 渠道状态feishu / cli_chat 等连接状态。
- 调度器任务数、下次运行、7 天失败数。
- 实时活动流:最近 turn/memory/job 事件。
- 数据来自 `/api/status`,默认每 2s 轮询。
### 6.3 工具 & Skills 页
- 三个标签页:工具 / Skills / MCP。
- 工具卡片名称、来源builtin/mcp、描述、调用次数、**能力徽标**、可展开参数 schema。
- **能力标识**(来自 `Tool` trait
- `◇ 只读`teal= `read_only()`
- `⇉ 可并发`info= `read_only() && !exclusive()`(即 `concurrency_safe()`
- `△ 有副作用`amber= `!read_only()`
- `■ 独占`danger= `exclusive()`(如 bash
- 支持搜索 + 按能力筛选(全部/只读/可并发/有副作用/独占)+ 图例。
- Skills 标签名称、描述、always、来源目录。MCP 标签:服务器名 + 连接状态。
### 6.4 日志页(实时流式)
- 工具栏level 过滤(全部/INF/WRN/ERR、关键字搜索、暂停滚动、下载。
- 日志行:时间戳 + level 着色 + target + 消息,自动跟随尾部。
- 进入页面:`GET /api/logs`(保留)拉历史尾 → `/ws/logs` 接管实时;断线重连重新拉尾对齐。
- 顶部显示连接状态(实时推送中 · 行/分)。
### 6.5 记忆页(可编辑 / 可删除)
- **权限**Knowledge 与 Timeline 均可编辑、可删除。
- **大量条目展示**:统计条(总量/Knowledge/Timeline/覆盖会话)→ 语义搜索优先 → 分类/会话/排序筛选 → 日期分组高密度行(列表/卡片视图可切换)→ 虚拟滚动 + 分页加载("已显示 100 / 1,284 · 加载更多")。
- 行内编辑textarea + importance 调节 + 保存/取消(按 key upsert`updated_at` 自动刷新)。
- **删除警告分级**
- Knowledge普通确认"删除后不可恢复,影响后续召回")。
- Timeline**强警告**——"Timeline 是压缩后的历史上下文,删除后模型将永久失去该时段长期记忆且无法自动重建;原始消息仍保留在聊天历史,但不再进入模型上下文",按钮文案"我了解,确认删除"。
### 6.6 任务页
- 两个标签页:定时任务 / 后台任务。
- 定时任务表名称、cron 表达式、下次运行倒计时、上次运行、最近 10 次运行状态点(绿/琥珀/红)、启用状态;可展开运行记录(时间/耗时/摘要)。
- 后台子任务列表:名称、来源 session、运行中脉冲/完成、耗时。
- 只读浏览。
### 6.7 配置页(唯一可写页面之一)
- 标签页config.json / USER.md / AGENTS.md。
- config.jsonJSON 编辑器,密钥掩码(`********`,原样提交自动还原)、实时 JSON 校验 + default agent 有效性、未保存修改提示。
- 右侧配置大纲gateway/providers/agent/channels/memory/scheduler标注"重启生效""含密钥"。
- 重载状态卡:运行代、相位、上次重载结果。
- 操作:保存 / 保存并热重载 / 放弃修改。
- 复用现有 `GET/PUT /api/config``GET/PUT /api/profiles/{name}``POST /api/config/reload``GET /api/config/reload/status`
- 提示 host/port/workspace 与存储路径为进程级不变量,修改后热重载被拒绝、需重启。
## 7. 后端接口设计
### 7.1 新增端点总览
| 方法 | 路径 | 用途 | 数据来源 |
|------|------|------|----------|
| GET | `/api/status` | 运行状况快照 | `Metrics` + 各服务只读查询 |
| GET | `/api/tools` | 工具列表(含能力字段) | `ToolRegistry` |
| GET | `/api/skills` | Skill 列表 | `SkillsLoader` |
| PUT | `/api/memories/{key}` | 更新记忆 content/importance | `Storage::upsert_memory` |
| DELETE | `/api/memories/{key}` | 删除记忆 | `Storage::delete_memory` |
| WS | `/ws/logs` | 实时日志流 | tracing 广播层 |
所有新端点走现有设备鉴权,注册在 `src/gateway/mod.rs` 的 protected router。
### 7.2 `GET /api/status`
返回单一 JSON 快照,概览页每 2s 轮询:
```json
{
"generation": 7, "version": "1.3.0", "uptime_secs": 266400, "phase": "steady",
"ws_connections": 2, "background_tasks": 3,
"sessions": { "total": 14, "active_turns": 1 },
"metrics": { "tokens_today": 1204882, "cost_today": 0.84,
"tool_calls_today": 312, "turns_today": 87, "turn_latency_p95_ms": 4200 },
"bus": { "inbound": {"depth":0,"cap":32}, "outbound": {"depth":1,"cap":64},
"control": {"depth":0,"cap":64}, "active_lanes": 4 },
"providers": [ {"name":"openai","model":"gpt-4o","status":"ok",
"latency_ms":820,"tokens":980000,"cost":0.61} ],
"channels": [ {"name":"feishu","status":"connected","detail":"3 群"} ],
"scheduler": { "enabled": true, "jobs": 5, "failed_7d": 0 },
"mcp": [ {"name":"github","status":"connected"} ]
}
```
聚合来源分两类——**已有查询**与**需新增的内省接口**(后者是 P1 的真实后端工作量,不可当作现成只读查询):
已有 / 低成本可得:
- `reload`generation、相位现有 `ReloadStatus`
- `mcp::get_mcp_status()`MCP 服务器连接状态(现有全局状态注册表)
- `Scheduler` / Storage任务数、下次运行、7 天失败数(现有 Storage API
- `ChannelManager`:各渠道连接状态
- `Metrics`token/费用/工具调用/turn/延迟(见 §7.3,新增)
**需新增的内省接口**(当前代码无对应查询面):
- `MessageBus`:三条队列的深度与容量。现状只有 publish/consume且未保留配置容量`src/bus/mod.rs`)。实现上让 bus 保留各队列 `mpsc::Sender`/容量,深度由 `max_capacity() - capacity()` 派生tokio `mpsc::Sender` 提供这两个方法)。
- `OutboundDispatcher`:活跃 lane 数。现状只有 `new`/`run``src/bus/dispatcher.rs`),需新增计数查询。
- `TaskSupervisor`:运行中任务数。现状无查询面(`src/task_supervisor.rs`),需新增。
- `SessionManager`:会话总数与活动 Turn 数(确认现有方法是否足够,不足则补只读统计)。
- WebSocket 连接数(`ws_connections`):当前无连接计数器,需在 `ws_handler` 用一个 `Arc<AtomicUsize>` 在连接建立/断开时增减。
这些内省方法必须轻量、非阻塞(不加锁等待慢操作),以支撑每 2s 轮询。
**不含任何密钥**provider api_key 等一律不出现)。
### 7.3 指标采集(`Metrics`
- 新增 `Metrics` 结构原子计数为主tokens in/out、cost、per-tool 调用数、turns、per-provider 延迟与错误滚动窗口。
- 由 `AgentLoop` / Provider 在每次 turn / 工具调用时经 `Arc<Metrics>` 更新。
- 纯内存、不持久化、重启归零。"今日"统计为自进程启动起的滚动窗口(文档与 UI 注明,不暗示自然日)。
- provider 状态ok/降级)由最近错误率派生。
### 7.4 `WS /ws/logs`
- 给 tracing 增加一个广播层:格式化日志记录后发送到 `tokio::sync::broadcast`(容量约 1024慢客户端丢旧lag不反压。无订阅者时发送为 no-op近乎零开销。
- handler 连接后订阅,按查询参数 `level` / `search` 过滤,推送 `{ts, level, target, message}` 帧。
- 修改 `src/logging` 的订阅器初始化以挂载该广播层(保持文件轮转不变)。
- `GET /api/logs`(文件尾)保留,用于进入页面时拉取历史与重连对齐。
### 7.5 记忆写入端点
- `PUT /api/memories/{key}`body `{content, importance?}`,按 key upsert复用 `Storage::upsert_memory``updated_at` 自动刷新。
- `DELETE /api/memories/{key}`:复用 `Storage::delete_memory`
- path 中的 key 需 URL 解码;实现时校验 key 存在性,返回 404 若不存在。
- 现有 `GET /api/memories`list/search含 category/session/limit/query保留不变。
### 7.6 工具 / Skills 端点
- `GET /api/tools`:遍历 `ToolRegistry`,每项返回 `name, description, parameters_schema, source(builtin|mcp), read_only, exclusive, concurrency_safe, call_count``call_count` 取自 `Metrics` 的 per-tool 计数。
- `GET /api/skills`:数据源为 `SkillsLoader::get_loaded_skills()`(返回完整 `Skill { name, description, content, always, path }``src/skills/mod.rs`**不要**用 `list_skills()`,它只返回 `(name, description)` 二元组,缺少 `always`/`path`。返回 `name, description, always, source`,其中 `source``Skill.path` 所在目录派生(无独立来源字段)。默认不返回完整 `content`(可能较大)。
## 8. 前端架构
### 8.1 目录与数据层
- 保持 Svelte 5 runes`src/lib/api.js` 扩展为按域划分的客户端模块(如 `api/status.js``api/tools.js``api/memories.js`),不引入状态管理库。
- 组件库 `src/lib/`:在现有 `Markdown.svelte``ToolCallCard.svelte``TurnView.svelte``Toast.svelte``StatusBadge.svelte` 基础上,新增 Signal Deck 组件ActivitySpine、MetricTile、Sparkline、CapacityMeter、LogStream、BadgeSet 等)。
- 页面 `src/pages/`:重构 ChatPage、新增 OverviewPage、ToolsPage、重写 LogsPage、重构 MemoryPage、重构 TasksPage、重构 SettingsPage、保留 PairingPage。
### 8.2 主题
- 设计 tokens 以 CSS 自定义属性表达:`:root`(暗色)与 `:root[data-theme="light"]`(亮色),替换现有 `styles.css` 的变量集。
- 主题切换持久化到 `localStorage`,默认跟随 `prefers-color-scheme`
### 8.3 字体内嵌(构建管线变更)
- 字体文件latin 子集 woff2取自 @fontsource)放入 `webui/public/fonts/`。Vite 默认 `publicDir` 会把 `public/` 内容**原样、固定名**复制到产物根(`OUT_DIR/webui/fonts/*.woff2`),无需改 `vite.config.js``assetFileNames`
- `http.rs`:新增 `/fonts/{name}` 路由,用 `include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/...woff2"))` 嵌入(静态 name→bytes 映射),返回 `Content-Type: font/woff2` 与长期缓存头;属公开静态资源层(与 app.js/styles.css 同级,不进设备鉴权)。
- CSP现有 `default-src 'self'` 已允许同源字体font-src 回落到 default-src无需放宽。
- 二进制体积增量约 100150KBSpace Grotesk + JetBrains Mono可考虑子集化
- `build.rs``rerun-if-changed` 需追加 `webui/public`;依赖 stamp 逻辑不变。
### 8.4 全局 WS 与活动脊
- 聊天 WS 连接提升到应用外壳层App.svelte使活动脊在所有页面可用。
- 活动脊消费 WS 的 turn 快照得到实时 Turn 状态;其余字段轮询 `/api/status`
## 9. 分阶段实现
- **P0 地基**设计系统tokens/组件库/双主题/字体内嵌)+ 应用外壳(扁平导航 + 全局活动脊 + 主题/鉴权)+ 聊天页重构。
- **P1 观测**`Metrics` + `GET /api/status` + 概览页 + `GET /api/tools`/`/api/skills` + 工具&Skills 页。
- **P2 日志与数据**tracing 广播层 + `/ws/logs` + 日志页 + 记忆写入端点 + 记忆页重构 + 任务页重构。
- **P3 配置**:配置编辑器重构 + 配置大纲 + profile + reload 状态可视化。
每阶段独立可验证;前端改动须过 `npm run check` + `npm run build` + `cargo build`(验证 OUT_DIR 嵌入Rust 改动须过定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings`
## 10. 风险与开放项
- **字体内嵌**构建管线需同时支持文本include_str!与二进制include_bytes!)资产;需在实现期验证 vite 固定名输出与 Cargo 嵌入路径。若字体子集化复杂,可退回系统字体栈(牺牲部分排版个性)。
- **`Metrics` 侵入性**:在 AgentLoop/Provider 埋点需避免持锁慢操作,遵循"不在持锁时做网络/模型/DB 慢操作"的不变量;计数用原子操作。
- **`/api/status` 聚合成本**:每 2s 轮询,聚合多个服务的只读查询;需确保各查询轻量、不加锁阻塞。必要时缓存短 TTL 快照。
- **tracing 广播层**:需保证无订阅者时零开销、有订阅者时不阻塞日志写入;广播满时丢旧而非阻塞。
- **记忆 key 路由**key 可能含特殊字符URL 编解码与 404 语义需在实现期明确。
- **Timeline 删除语义**UI 已用强警告;后端不做额外保护(用户拥有自己的 Agent但删除为幂等硬删除。
## 11. 验收标准
- 单二进制 `cargo build` 成功WebUI 从内存提供,无外部 CDN 依赖。
- 亮/暗双主题完整覆盖所有页面与组件。
- 聊天页保留现有全部能力dialog scope、历史持久化、turn 快照、斜杠补全、附件、Todo 侧栏)。
- 概览页实时反映运行状况;活动脊在所有页面可见且实时。
- 工具页正确展示 read_only/exclusive/concurrency_safe 能力标识。
- 日志页实时流式推送,支持 level/搜索过滤与暂停。
- 记忆页支持 Knowledge/Timeline 编辑与删除,删除警告分级,大量条目下虚拟滚动流畅。
- 配置页可编辑、密钥掩码、热重载状态可视。
- 所有新端点受设备鉴权保护,响应不含密钥。
- `npm run check``npm run build``cargo build``cargo test --lib``cargo clippy -- -D warnings` 全部通过。