163 lines
7.4 KiB
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}
|