PicoBot/webui/src/App.svelte

138 lines
4.5 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 { applyTheme, preferredTheme } from "./lib/theme.js";
import Toast from "./lib/Toast.svelte";
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
import ChatPage from "./pages/ChatPage.svelte";
import TasksPage from "./pages/TasksPage.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 = [
["chat", "◫", "聊天"],
["overview", "◉", "概览"],
["tools", "🧰", "工具&Skills"],
["logs", "≋", "日志"],
["memory", "◇", "记忆"],
["tasks", "⌁", "任务"],
["settings", "⚙", "配置"]
];
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(([name]) => name === current) || pages[0]);
async function health() {
try {
const result = await api("/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();
applyTheme(preferred);
theme = preferred;
checkAuth().then(() => authenticated && health());
const authRequired = () => (authenticated = false);
window.addEventListener("picobot-auth-required", authRequired);
const timer = setInterval(() => authenticated && health(), 30_000);
return () => {
clearInterval(timer);
window.removeEventListener("picobot-auth-required", authRequired);
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">
<aside class:open={menuOpen} class="sidebar">
<div class="brand"><span class="brand-mark">P</span><div><strong>PicoBot</strong><small>Local agent console</small></div></div>
<nav>
{#each pages as page}
<button class:active={current === page[0]} onclick={() => selectPage(page[0])}><span>{page[1]}</span>{page[2]}</button>
{/each}
</nav>
<div class="gateway-status">
<i class:online></i>
<div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div>
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={toggleTheme}>
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span><span>{theme === "dark" ? "浅色" : "深色"}</span>
</button>
</div>
</aside>
<main>
<ActivitySpine {version} />
<header class="topbar">
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
<h1>{meta[2]}</h1>
</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 />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>
<Toast bind:this={toast} />
</Tooltip.Provider>
{/if}