PicoBot/webui/src/App.svelte

163 lines
7.4 KiB
Svelte

<script>
import { onMount } from "svelte";
import { Tooltip } from "bits-ui";
import { api } from "./lib/api.js";
import { chat } from "./lib/chat.svelte.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";
import AgentsPage from "./pages/AgentsPage.svelte";
import MemoryPage from "./pages/MemoryPage.svelte";
import LogsPage from "./pages/LogsPage.svelte";
import SettingsPage from "./pages/SettingsPage.svelte";
import OverviewPage from "./pages/OverviewPage.svelte";
import ToolsPage from "./pages/ToolsPage.svelte";
import PairingPage from "./pages/PairingPage.svelte";
const pages = [
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
{ name: "agents", label: "子代理", description: "查看活动中的子代理与历史运行" },
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
{ name: "tasks", label: "任务", description: "管理定时任务" },
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
];
const icons = {
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
overview: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
agents: '<rect x="4" y="7" width="16" height="12" rx="2"/><path d="M12 7V4"/><path d="M8 4h8"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/>',
logs: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
memory: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
settings: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>'
};
let current = $state("chat");
let menuOpen = $state(false);
let online = $state(false);
let version = $state("Gateway");
let theme = $state("dark");
let authReady = $state(false);
let authenticated = $state(false);
let toast = $state();
const meta = $derived(pages.find((page) => page.name === current) || pages[0]);
async function health() {
try {
const result = await api("/health");
online = true;
version = `Gateway v${result.version}`;
} catch {
online = false;
}
}
function selectPage(name) {
current = name;
menuOpen = false;
}
function toggleTheme() {
theme = theme === "dark" ? "light" : "dark";
applyTheme(theme);
}
async function checkAuth() {
try {
const status = await api("/api/auth/status");
authenticated = status.authenticated || !status.require_pairing;
} catch {
authenticated = false;
} finally {
authReady = true;
}
}
function paired() {
authenticated = true;
health();
}
$effect(() => {
if (authenticated) {
chat.connect();
} else {
chat.disconnect();
}
});
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();
};
});
</script>
{#if !authReady}
<main class="pairing-screen"><div class="auth-loading"><span class="pulse"></span>正在检查设备授权…</div></main>
{:else if !authenticated}
<PairingPage onpaired={paired} />
{:else}
<Tooltip.Provider delayDuration={350}>
<div class="shell">
{#if menuOpen}<button class="nav-scrim" aria-label="关闭导航" onclick={() => (menuOpen = false)}></button>{/if}
<aside class:open={menuOpen} class="sidebar">
<div class="brand"><span class="brand-mark"><Icon name="bot" size={22} /></span><div><strong>PicoBot</strong><small>Agent workspace</small></div></div>
<span class="nav-label">工作区</span>
<nav aria-label="主导航">
{#each pages as page}
<button class:active={current === page.name} aria-current={current === page.name ? "page" : undefined} onclick={() => selectPage(page.name)}>
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{@html icons[page.name]}</svg></span>
{page.label}
</button>
{/each}
</nav>
<div class="gateway-status">
<i class:online></i><div><b>{online ? "Gateway 已连接" : "Gateway 不可用"}</b><small>{version}</small></div>
</div>
</aside>
<main class="app-main">
<header class="topbar">
<button class="menu icon-button" aria-label="打开导航" onclick={() => (menuOpen = !menuOpen)}><Icon name="panel" /></button>
<div class="page-heading"><h1>{meta.label}</h1><p>{meta.description}</p></div>
<ActivitySpine {version} />
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={toggleTheme}>
<Icon name={theme === "dark" ? "sun" : "moon"} /><span>{theme === "dark" ? "浅色" : "深色"}</span>
</button>
</header>
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "logs"}<LogsPage />
{:else if current === "memory"}<MemoryPage />
{:else if current === "tasks"}<TasksPage />
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "agents"}<AgentsPage />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>
<Toast bind:this={toast} />
</Tooltip.Provider>
{/if}