diff --git a/AGENTS.md b/AGENTS.md index 2af8154..1cf75a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results - **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact +- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts - **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables - **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source - **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible @@ -145,4 +146,4 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - `resources/skills/about-picobot/references/` — runtime knowledge shipped to PicoBot; update it only when the assistant's built-in product knowledge must change ## Version Management -- 在每次功能变化、架构变化后,适当地更新整个产品的版本号 +- 在每次功能变化、架构变化后,适当地更新整个产品的版本号。功能变化增加中段数字,ui变化、bug修改增加末端数字。 注意版本号变更是提交时和仓库中的版本比较,不要在长时间的工程中,不断变化版本号。 diff --git a/Cargo.toml b/Cargo.toml index baf107e..7e5f9a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.3.0" +version = "1.3.1" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 73d8829..9ae3861 100644 --- a/README.md +++ b/README.md @@ -164,11 +164,11 @@ docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876 不要从宿主机经发布端口直接调用签发接口;容器会把该连接识别为非回环来源并拒绝。`picobot` 已加入正式镜像的 `PATH`,可在容器 shell 中直接调用。 -WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: +WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件。界面采用本地实现的 Microsoft Fluent 2 视觉系统,提供语义化中性色表面、品牌蓝交互状态、统一组件层级和完整的浅色/深色主题,并提供: - 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。 - 文件选择、拖放和剪贴板图片上传;消息中的附件可预览或下载。附件按服务端路径引用,原文件移动或删除后历史附件可能不可用。 -- 可持久化的浅色/深色主题,首次访问时跟随系统偏好。 +- “配置 → 外观”提供浅色/深色模式和六套 Fluent 品牌色;选择即时生效并保存在当前浏览器中,首次访问时明暗模式跟随系统偏好。 - Cron 定时任务、最近运行记录和后台子任务状态。 - 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。 - Knowledge/Timeline 记忆的分类与全文检索。 @@ -181,7 +181,7 @@ WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对 #### WebUI 开发 -WebUI 源码位于 `webui/`,使用 Svelte 5、Vite 和无样式的 Bits UI 可访问组件原语。运行发布版 PicoBot 不需要 Node.js;从源码编译或修改前端时需要 Node.js 20+: +WebUI 源码位于 `webui/`,使用 Svelte 5、Vite 和无样式的 Bits UI 可访问组件原语。Fluent 2 外观由 `webui/src/styles.css` 中的本地语义令牌与 Svelte 组件实现,不加载 CDN 或外部 UI 运行库;明暗模式和品牌色分别保存在浏览器 `picobot-theme`、`picobot-accent` 项中,并由 `theme-init.js` 在应用挂载前恢复。运行发布版 PicoBot 不需要 Node.js;从源码编译或修改前端时需要 Node.js 20+: ```bash cd webui diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 17cae9c..48b255a 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - image: picobot:1.3.0 + image: picobot:1.3.1 container_name: picobot-test restart: unless-stopped ports: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 071bff4..eb84a80 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -245,7 +245,7 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快 ### WebUI 与管理 API -Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。 +Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage`,`theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme` 与 `data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。 WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。 diff --git a/webui/index.html b/webui/index.html index 8cd1b98..41151bc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,9 +4,9 @@ - + - PicoBot Console + PicoBot Workspace
diff --git a/webui/package-lock.json b/webui/package-lock.json index 1e26fce..ff9bb5a 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index 5836e90..9ad5b9f 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.3.0", + "version": "1.3.1", "type": "module", "engines": { "node": ">=20" diff --git a/webui/public/theme-init.js b/webui/public/theme-init.js index fbf6b46..67af093 100644 --- a/webui/public/theme-init.js +++ b/webui/public/theme-init.js @@ -1,5 +1,8 @@ try { var t = localStorage.getItem("picobot-theme"); if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + var a = localStorage.getItem("picobot-accent"); + if (!["blue", "purple", "teal", "green", "orange", "magenta"].includes(a)) a = "blue"; document.documentElement.dataset.theme = t; + document.documentElement.dataset.accent = a; } catch (e) {} diff --git a/webui/src/App.svelte b/webui/src/App.svelte index 8fe6769..110c20f 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -3,8 +3,9 @@ import { Tooltip } from "bits-ui"; import { api } from "./lib/api.js"; import { chat } from "./lib/chat.svelte.js"; - import { applyTheme, preferredTheme } from "./lib/theme.js"; + import { applyAccent, applyTheme, preferredAccent, preferredTheme } from "./lib/theme.js"; import Toast from "./lib/Toast.svelte"; + import Icon from "./lib/Icon.svelte"; import ActivitySpine from "./lib/components/ActivitySpine.svelte"; import ChatPage from "./pages/ChatPage.svelte"; import TasksPage from "./pages/TasksPage.svelte"; @@ -16,13 +17,13 @@ import PairingPage from "./pages/PairingPage.svelte"; const pages = [ - ["chat", "聊天"], - ["overview", "概览"], - ["tools", "工具"], - ["logs", "日志"], - ["memory", "记忆"], - ["tasks", "任务"], - ["settings", "配置"] + { name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" }, + { name: "overview", label: "概览", description: "查看运行状态与系统容量" }, + { name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" }, + { name: "logs", label: "日志", description: "检查实时事件与运行记录" }, + { name: "memory", label: "记忆", description: "查找和维护长期记忆" }, + { name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" }, + { name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" } ]; const icons = { chat: '', @@ -41,7 +42,7 @@ let authReady = $state(false); let authenticated = $state(false); let toast = $state(); - const meta = $derived(pages.find(([name]) => name === current) || pages[0]); + const meta = $derived(pages.find((page) => page.name === current) || pages[0]); async function health() { try { @@ -89,15 +90,22 @@ onMount(() => { const preferred = preferredTheme(); + const accent = preferredAccent(); applyTheme(preferred); + applyAccent(accent); theme = preferred; checkAuth().then(() => authenticated && health()); const authRequired = () => (authenticated = false); + const appearanceChanged = (event) => { + if (event.detail?.theme === "light" || event.detail?.theme === "dark") theme = event.detail.theme; + }; window.addEventListener("picobot-auth-required", authRequired); + window.addEventListener("picobot-appearance-change", appearanceChanged); const timer = setInterval(() => authenticated && health(), 30_000); return () => { clearInterval(timer); window.removeEventListener("picobot-auth-required", authRequired); + window.removeEventListener("picobot-appearance-change", appearanceChanged); chat.disconnect(); }; }); @@ -110,29 +118,30 @@ {:else}
+ {#if menuOpen}{/if} -
- +
- -

{meta[1]}

+ +

{meta.label}

{meta.description}

+ +
{#if current === "chat"} toast.show(text, error)} /> {:else if current === "logs"} diff --git a/webui/src/lib/Icon.svelte b/webui/src/lib/Icon.svelte new file mode 100644 index 0000000..862fe7f --- /dev/null +++ b/webui/src/lib/Icon.svelte @@ -0,0 +1,53 @@ + + + + + diff --git a/webui/src/lib/ToolCallCard.svelte b/webui/src/lib/ToolCallCard.svelte index 543cb1c..539e8ba 100644 --- a/webui/src/lib/ToolCallCard.svelte +++ b/webui/src/lib/ToolCallCard.svelte @@ -1,5 +1,6 @@
-
P
+
{#each turn.blocks as block (block.id)} {#if block.type === "reasoning"} diff --git a/webui/src/lib/components/ActivitySpine.svelte b/webui/src/lib/components/ActivitySpine.svelte index e60244e..a883751 100644 --- a/webui/src/lib/components/ActivitySpine.svelte +++ b/webui/src/lib/components/ActivitySpine.svelte @@ -19,36 +19,34 @@ }); const running = $derived(chat.turn?.status === "running"); - const turnLabel = $derived(chat.turn ? `TURN ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : ""); + 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); -
+
{#if running} - {turnLabel} · STREAMING + {turnLabel} · 生成中 {#if rate != null}▲ {rate} tok/s{/if} {#if ctx}ctx {ctx}{/if} {:else if chat.turn} - IDLE - 最近 {turnLabel} + 空闲 + 最近 {turnLabel} {:else} - READY + 就绪 {/if} {chat.connected ? "已连接" : "重连中"} - {#if version}{version}{/if}
diff --git a/webui/src/lib/components/AppearanceSettings.svelte b/webui/src/lib/components/AppearanceSettings.svelte new file mode 100644 index 0000000..dbcb9b7 --- /dev/null +++ b/webui/src/lib/components/AppearanceSettings.svelte @@ -0,0 +1,149 @@ + + +
+
+
+
+

显示模式

选择更适合当前环境的页面明暗度。

+
+
+ + +
+
+ +
+
+

主题色

品牌色会应用到主操作、选中状态、进度和焦点提示。

+
+
+ {#each ACCENT_THEMES as option} + + {/each} +
+
+ +

外观设置仅保存在当前浏览器中,不会写入 Gateway 配置或同步到其他设备。

+
+ + +
+ + diff --git a/webui/src/lib/components/CapacityMeter.svelte b/webui/src/lib/components/CapacityMeter.svelte index 0555b2f..13f20b6 100644 --- a/webui/src/lib/components/CapacityMeter.svelte +++ b/webui/src/lib/components/CapacityMeter.svelte @@ -1,30 +1,16 @@
- {#each Array(segments) as _, i} - - {/each} +
diff --git a/webui/src/lib/components/MetricTile.svelte b/webui/src/lib/components/MetricTile.svelte index 1d9d843..d6b1942 100644 --- a/webui/src/lib/components/MetricTile.svelte +++ b/webui/src/lib/components/MetricTile.svelte @@ -16,9 +16,9 @@
diff --git a/webui/src/lib/theme.js b/webui/src/lib/theme.js index 3606e6b..8903976 100644 --- a/webui/src/lib/theme.js +++ b/webui/src/lib/theme.js @@ -1,4 +1,16 @@ const STORAGE_KEY = "picobot-theme"; +const ACCENT_STORAGE_KEY = "picobot-accent"; + +export const ACCENT_THEMES = Object.freeze([ + { id: "blue", label: "海蓝", swatch: "#0f6cbd", description: "Fluent 默认蓝" }, + { id: "purple", label: "紫罗兰", swatch: "#5c2e91", description: "沉静而专注" }, + { id: "teal", label: "青绿", swatch: "#006c4c", description: "清晰且克制" }, + { id: "green", label: "森林", swatch: "#107c10", description: "稳定与活力" }, + { id: "orange", label: "暖橙", swatch: "#ca5010", description: "醒目而友好" }, + { id: "magenta", label: "莓红", swatch: "#9b0062", description: "鲜明有个性" } +]); + +const ACCENT_IDS = new Set(ACCENT_THEMES.map((theme) => theme.id)); export function preferredTheme() { const saved = localStorage.getItem(STORAGE_KEY); @@ -6,11 +18,34 @@ export function preferredTheme() { return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +export function preferredAccent() { + const saved = localStorage.getItem(ACCENT_STORAGE_KEY); + return ACCENT_IDS.has(saved) ? saved : "blue"; +} + +function notifyAppearance() { + window.dispatchEvent(new CustomEvent("picobot-appearance-change", { + detail: { + theme: document.documentElement.dataset.theme, + accent: document.documentElement.dataset.accent + } + })); +} + export function applyTheme(theme) { - document.documentElement.dataset.theme = theme; - document.documentElement.style.colorScheme = theme; + const next = theme === "dark" ? "dark" : "light"; + document.documentElement.dataset.theme = next; + document.documentElement.style.colorScheme = next; document .querySelector('meta[name="theme-color"]') - ?.setAttribute("content", theme === "dark" ? "#0b1017" : "#eef1f5"); - localStorage.setItem(STORAGE_KEY, theme); + ?.setAttribute("content", next === "dark" ? "#1f1f1f" : "#f5f5f5"); + localStorage.setItem(STORAGE_KEY, next); + notifyAppearance(); +} + +export function applyAccent(accent) { + const next = ACCENT_IDS.has(accent) ? accent : "blue"; + document.documentElement.dataset.accent = next; + localStorage.setItem(ACCENT_STORAGE_KEY, next); + notifyAppearance(); } diff --git a/webui/src/pages/ChatPage.svelte b/webui/src/pages/ChatPage.svelte index 562173d..e3a402d 100644 --- a/webui/src/pages/ChatPage.svelte +++ b/webui/src/pages/ChatPage.svelte @@ -6,6 +6,7 @@ import Markdown from "../lib/Markdown.svelte"; import ToolCallCard from "../lib/ToolCallCard.svelte"; import TurnView from "../lib/TurnView.svelte"; + import Icon from "../lib/Icon.svelte"; let { notify } = $props(); let sessions = $state([]); @@ -377,9 +378,9 @@
{#if messages.length === 0 && !activeTurn} -

今天想做些什么?

消息与 CLI 客户端使用同一套会话、记忆和工具能力。

+

今天想做些什么?

消息与 CLI 客户端使用同一套会话、记忆和工具能力。

{/if} {#each messages as message (message.id)} {#if message.role !== "tool" && (message.content || message.tool_calls?.length || message.attachments?.length)}
-
{message.role === "user" ? "你" : "P"}
+
{#if message.role === "user"}你{:else}{/if}
{#if message.reasoning_content}
@@ -430,13 +431,13 @@ {attachment.name} -
{attachment.name}{attachment.mime_type || attachment.media_type}
+
{attachment.name}{attachment.mime_type || attachment.media_type}
{:else}
- +
{attachment.name}{attachment.mime_type || attachment.media_type}
- +
{/if} {/each} @@ -454,7 +455,7 @@ {/if} {/each} {#if activeTurn} - {:else if thinking}
P
正在思考…
{/if} + {:else if thinking}
正在思考…
{/if}
{ event.preventDefault(); submit(); }} ondragover={(event) => event.preventDefault()} ondrop={dropFiles}> {#if commandSuggestions.length} @@ -479,14 +480,14 @@
{#each pendingUploads as upload (upload.localId)}
-
{upload.name}{upload.status === "uploading" ? `上传中 ${upload.progress}%` : upload.status === "error" ? upload.error : formatBytes(upload.size)}
- +
{upload.name}{upload.status === "uploading" ? `上传中 ${upload.progress}%` : upload.status === "error" ? upload.error : formatBytes(upload.size)}
+
{/each}
{/if} { addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} /> - + - + {chat.connected ? "已连接" : "已断开,正在重连"}/ 打开命令 · Shift+Enter 换行
@@ -509,7 +510,7 @@