feat(frontend/admin): M10 后台管理面板(/admin hash 路由 + 15 大模块 + RBAC(owner/admin/viewer) + 审计 + Miuix);Docs/api-client.md v0.3 契约补全;tsc+vite build 通过

This commit is contained in:
Aska committed 2026-09-06 07:55:04 +08:00
1 parent 4c48e94659
commit f43eb17030
26 files changed
+2253 -4

No files matched your search

+4
View File
@@ -42,6 +42,9 @@
| 9 | `/api/v1/admin/llm` | GET/PUT | 🕐 | LLM 模型/路由/降级(M10) |
| 10 | `/api/v1/admin/automations` | GET/POST | 🕐 | 自动化与提醒(M10) |
> **M10 前端契约补充(管理面板,均🕐未实现,前端按契约封装、后端 404 时显示"待实现"壳,不报错)**:
> `GET /api/v1/admin/users`(用户/角色/API key)· `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/logs` · `GET /api/v1/admin/trace` · `GET /api/v1/admin/audit` · `GET /api/v1/admin/system`。写操作(策略/档案/密钥/系统设置)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
@@ -57,5 +60,6 @@
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
## 5. 变更记录
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。
- `v0.2`(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + `plugins/policy` 写 + WS `/ws/chat`(JWT + bus 广播)。
- `v0.1`(契约先行):骨架 + 端点登记表。
+16 -2
View File
@@ -1,7 +1,9 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { TopBar } from "./components/TopBar";
import { Sidebar, BottomNav } from "./components/Nav";
import { ModeRouter } from "./modes/ModeRouter";
import { AdminApp } from "./admin/AdminApp";
import { parseAdminHash } from "./admin/router";
import { connectWs, disconnectWs } from "./lib/ws";
import { initTheme } from "./lib/theme";
import { getAuthToken } from "./lib/api";
@@ -9,12 +11,24 @@ import { getAuthToken } from "./lib/api";
export default function App(): React.ReactNode {
// Apply the persisted (dark-first) theme and open the realtime channel only
// when we hold a token — otherwise the gateway rejects the handshake with 401.
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash));
useEffect(() => {
initTheme();
if (getAuthToken()) connectWs();
return () => disconnectWs();
const onHash = () => setRoute(parseAdminHash(window.location.hash));
window.addEventListener("hashchange", onHash);
return () => {
disconnectWs();
window.removeEventListener("hashchange", onHash);
};
}, []);
// The M9 modal UI and the admin subsystem (design/10) are two separate shells.
// `/admin` is reached via `#/admin`; every admin module is a hash segment.
if (route.admin) {
return <AdminApp section={route.section} />;
}
return (
<div className="flex h-full flex-col">
<TopBar />
+166
View File
@@ -0,0 +1,166 @@
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken, getStoredRole } from "../lib/api";
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
import { navigateAdmin, type AdminSectionId } from "./router";
import { Button, Tag } from "../components/primitives";
import { DashboardModule } from "./modules/Dashboard";
import { PersonasModule } from "./modules/Personas";
import { SessionsModule } from "./modules/Sessions";
import { MemoriesModule } from "./modules/Memories";
import { PluginsModule } from "./modules/Plugins";
import { ChannelsModule } from "./modules/Channels";
import { IoTModule } from "./modules/IoT";
import { RagModule } from "./modules/Rag";
import { LlmModule } from "./modules/Llm";
import { AutomationsModule } from "./modules/Automations";
import { AgentsModule } from "./modules/Agents";
import { BackthinkModule } from "./modules/Backthink";
import { ObservabilityModule } from "./modules/Observability";
import { UsersModule } from "./modules/Users";
import { SystemModule } from "./modules/System";
function renderSection(section: AdminSectionId): React.ReactNode {
switch (section) {
case "dashboard": return <DashboardModule />;
case "personas": return <PersonasModule />;
case "sessions": return <SessionsModule />;
case "memories": return <MemoriesModule />;
case "plugins": return <PluginsModule />;
case "channels": return <ChannelsModule />;
case "iot": return <IoTModule />;
case "rag": return <RagModule />;
case "llm": return <LlmModule />;
case "automations": return <AutomationsModule />;
case "agents": return <AgentsModule />;
case "backthink": return <BackthinkModule />;
case "observability": return <ObservabilityModule />;
case "users": return <UsersModule />;
case "system": return <SystemModule />;
default: return <DashboardModule />;
}
}
function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
return (
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
{ADMIN_GROUPS.map((group) => (
<div key={group} className="mb-1">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">{group}</div>
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
<button
key={s.id}
onClick={() => navigateAdmin(s.id)}
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
s.id === active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<span className="text-base">{s.icon}</span>
<span>{s.label}</span>
</button>
))}
</div>
))}
</nav>
);
}
function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode {
const role = getStoredRole();
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
<div className="flex items-center gap-2">
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene · 管理面板</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
</div>
</div>
<div className="flex-1" />
{role ? <Tag tone={role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"}>{role}</Tag> : null}
<button
onClick={toggleTheme}
aria-label="切换主题"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
{theme === "dark" ? "☀" : "☾"}
</button>
<Button size="sm" variant="ghost" onClick={() => { window.location.hash = ""; }}>
返回用户端
</Button>
</header>
);
}
function LoginGate(): React.ReactNode {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-4xl">🔒</div>
<div className="text-lg font-semibold">需要登录</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
管理面板需持有 JWT。请先到用户端 <span className="text-[var(--mu-text-hi)]">设置 → 连接</span> 登录获取令牌后,再返回本页。
</p>
<Button variant="primary" onClick={() => { window.location.hash = ""; }}>前往用户端登录</Button>
</div>
);
}
function Forbidden403(): React.ReactNode {
const role = getStoredRole();
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-4xl">🚫</div>
<div className="text-lg font-semibold">403 · 权限不足</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
当前角色 <Tag tone="neutral">{role ?? "?"}</Tag>(viewer 只读)。管理面板需 <Tag tone="accent">owner</Tag> 或 <Tag tone="ok">admin</Tag> 角色;管理端写操作仅 owner。
</p>
<div className="flex gap-2">
<Button variant="primary" onClick={() => { window.location.hash = ""; }}>返回用户端</Button>
</div>
</div>
);
}
export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode {
// RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an
// unauthenticated user never sees the admin module; the gateway would also
// reject them with 401/403.
if (!getAuthToken() || !getStoredRole()) {
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} />
<LoginGate />
</div>
);
}
if (getStoredRole() === "viewer") {
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} />
<Forbidden403 />
</div>
);
}
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} />
<div className="flex min-h-0 flex-1">
<AdminSidebar active={section} />
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
</main>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { create } from "zustand";
import type { AuditRecord } from "../lib/types";
import { getStoredRole } from "../lib/api";
import { now, uid } from "../lib/util";
/**
* Client-side audit trail. Every admin write the panel performs is pushed here
* with who/what/when/result so the 可观测性 → 审计 tab has real content even
* before the backend exposes an audit endpoint. It is also the visual front of
* the "主代理代审 + 审计日志" red line.
*/
interface AuditStore {
records: AuditRecord[];
log: (what: string, result: "ok" | "error") => void;
clear: () => void;
}
export const useAuditStore = create<AuditStore>((set) => ({
records: [],
log: (what, result) =>
set((s) => ({
records: [
{
id: uid(),
who: getStoredRole() ?? "anonymous",
what,
when: new Date(now()).toISOString(),
result,
},
...s.records,
].slice(0, 200),
})),
clear: () => set({ records: [] }),
}));
+144
View File
@@ -0,0 +1,144 @@
import type { ReactNode } from "react";
import { EmptyState, Tag, type TagTone } from "../components/primitives";
export interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export function PageHeader({ title, subtitle, actions }: PageHeaderProps): ReactNode {
return (
<div className="flex flex-wrap items-end gap-3">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{subtitle ? <p className="text-sm text-[var(--mu-text-lo)]">{subtitle}</p> : null}
</div>
{actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
</div>
);
}
export interface NoticeProps {
tone?: TagTone;
children: ReactNode;
}
/** A soft inline notice — used for "只读/后端 M10+ 提供" hints. */
export function Notice({ tone = "neutral", children }: NoticeProps): ReactNode {
return (
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<Tag tone={tone} className="mr-1.5">{tone === "warn" ? "待实现" : tone === "err" ? "错误" : "提示"}</Tag>
{children}
</div>
);
}
export interface StatCardProps {
icon?: ReactNode;
label: string;
value: ReactNode;
sub?: ReactNode;
tone?: TagTone;
}
export function StatCard({ icon, label, value, sub, tone = "accent" }: StatCardProps): ReactNode {
return (
<div className="liquid-glass flex flex-col gap-1.5 rounded-[var(--mu-radius-lg)] p-4">
<div className="flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
{icon ? <span className="text-base">{icon}</span> : null}
<span>{label}</span>
</div>
<div className="text-2xl font-semibold leading-tight">{value}</div>
{sub ? <div className="text-xs text-[var(--mu-text-lo)]">{sub}</div> : null}
<Tag tone={tone} className="w-fit">{tone}</Tag>
</div>
);
}
export function CardGrid({ children }: { children: ReactNode }): ReactNode {
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{children}</div>;
}
export interface AdminColumn<T> {
key: string;
title: string;
render: (row: T) => ReactNode;
className?: string;
}
export interface AdminTableProps<T> {
columns: AdminColumn<T>[];
rows: readonly T[] | null;
loading: boolean;
error: string | null;
emptyTitle?: string;
rowKey: (row: T) => string;
}
/** A strict, generic data table. Loading/error/empty states are handled here so
* every module has a complete surface without duplicating state rendering. */
export function AdminTable<T>({
columns,
rows,
loading,
error,
emptyTitle = "暂无数据",
rowKey,
}: AdminTableProps<T>): ReactNode {
if (loading) {
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
}
if (error) {
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-err)]">{error}</div>;
}
if (!rows || rows.length === 0) {
return <EmptyState icon="🗂" title={emptyTitle} />;
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-[var(--mu-glass-border)] text-left">
{columns.map((c) => (
<th
key={c.key}
className={`px-3 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)] ${c.className ?? ""}`}
>
{c.title}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)} className="border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60">
{columns.map((c) => (
<td key={c.key} className={`px-3 py-2.5 align-top ${c.className ?? ""}`}>
{c.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export interface KeyValueRowProps {
label: string;
value: ReactNode;
mono?: boolean;
}
export function KeyValueRow({ label, value, mono = false }: KeyValueRowProps): ReactNode {
return (
<div className="flex items-start justify-between gap-4 py-1.5">
<span className="shrink-0 text-xs text-[var(--mu-text-lo)]">{label}</span>
<span className={`min-w-0 text-right text-sm text-[var(--mu-text-hi)] ${mono ? "font-mono" : ""}`}>
{value}
</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect } from "react";
/** Re-run `reload` on a fixed interval (ms). Pass null to disable. Backend data
* tables refresh this way; WS-backed surfaces use the app store instead. */
export function useAutoReload(reload: () => void, intervalMs: number | null): void {
useEffect(() => {
if (intervalMs === null) return;
const id = window.setInterval(reload, intervalMs);
return () => window.clearInterval(id);
}, [reload, intervalMs]);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import type { AgentInfo, AgentStatus } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
running: { label: "运行中", tone: "accent" },
blocked: { label: "阻塞", tone: "warn" },
done: { label: "完成", tone: "ok" },
error: { label: "错误", tone: "err" },
};
function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) => void }): React.ReactNode {
const meta = statusTone[agent.status] ?? statusTone.idle;
return (
<Surface className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${agent.status === "running" ? "bg-[var(--mu-accent)] mu-blink" : "bg-[var(--mu-surface-5)]"}`} />
<span className="text-sm font-medium">{agent.name}</span>
{agent.role ? <span className="text-xs text-[var(--mu-text-lo)]">{agent.role}</span> : null}
<Tag tone={meta.tone} className="ml-auto">{meta.label}</Tag>
</div>
{typeof agent.progress === "number" ? (
<div className="h-1.5 w-full overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<div className="h-full rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)]" style={{ width: `${Math.min(100, Math.max(0, agent.progress))}%` }} />
</div>
) : null}
{agent.thinking ? (
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">{agent.thinking}</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="text-xs text-[var(--mu-text-lo)]">⚙ {t.name}{t.ok === false ? " ✗" : ""}</div>
))}
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => sendUp({ type: "task_output_query", data: { taskId: agent.id } })}>
上下文查询
</Button>
<Button size="sm" variant="outline" onClick={() => onKill(agent.id)}>
终止
</Button>
</div>
</Surface>
);
}
export function AgentsModule(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const flowGraph = useAppStore((s) => s.flowGraph);
const taskLogs = useAppStore((s) => s.taskLogs);
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
const log = useAuditStore((s) => s.log);
const [tab, setTab] = useState<"main" | "sub">("main");
const subAgents = agents.filter((a) => a.id !== "main");
const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents;
return (
<div className="space-y-5">
<PageHeader
title="Agent 运行监控"
subtitle="主会话专属页签 + 子代理:状态/进度/思维链/工具链/流程图/后台任务/agent 消息流"
actions={
<Button
variant="soft"
onClick={() => {
log("agent.respawn", "ok");
alert("重派(占位):后端 M10+ 提供子代理重派。");
}}
>
重派
</Button>
}
/>
<div className="flex items-center gap-2">
<TabPill active={tab === "main"} onClick={() => setTab("main")}>主会话</TabPill>
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
</div>
{flowGraph ? (
<Surface className="flex flex-wrap items-center gap-3">
{flowGraph.nodes.map((n, i) => (
<div key={n.id} className="flex items-center gap-3">
<Tag tone={n.status === "running" ? "accent" : "neutral"}>{n.label}</Tag>
{i < flowGraph.nodes.length - 1 ? <span className="text-[var(--mu-text-lo)]">→</span> : null}
</div>
))}
</Surface>
) : (
<Notice>流程图占位 —— 连接 /ws/chat 后按 graph_update 事件渲染。</Notice>
)}
{list.length === 0 ? (
<EmptyState icon="🧩" title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
) : (
<div className="space-y-3">
{list.map((a) => (
<AgentCard key={a.id} agent={a} onKill={(id) => { log(`agent.kill:${id}`, "ok"); alert(`终止 ${id}(占位)`); }} />
))}
</div>
)}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">Agent 消息流 / 后台任务日志</h3>
{Object.keys(taskLogs).length === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">暂无任务日志。</div>
) : (
Object.entries(taskLogs).map(([id, text]) => (
<div key={id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-2">
<div className="mb-1 flex items-center gap-2 text-xs">
<Tag tone="accent">{id}</Tag>
<button className="ml-auto text-[var(--mu-text-lo)]" onClick={() => clearTaskLog(id)}>清空</button>
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap font-mono text-xs text-[var(--mu-text-mid)]">{text}</pre>
</div>
))
)}
<Notice>子代理链上下文查询、终止/重派、流程图导出需后端 M10+ 提供。</Notice>
</Surface>
</div>
);
}
function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
active ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListAutomations, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
interface ReminderRow {
id: string;
name: string;
kind: string;
cron: string;
enabled: boolean;
}
const sample: ReminderRow[] = [
{ id: "r1", name: "睡前总结", kind: "reminder", cron: "0 22 * * *", enabled: true },
{ id: "r2", name: "晨间问候", kind: "cron", cron: "0 8 * * *", enabled: true },
{ id: "r3", name: "设备待机提醒", kind: "queue", cron: "—", enabled: false },
];
export function AutomationsModule(): React.ReactNode {
const { data, error } = useAsyncData(adminListAutomations, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ReminderRow[]>(sample);
const backend = !isNotImplemented(error);
return (
<div className="space-y-5">
<PageHeader
title="自动化与提醒"
subtitle="提醒 + cron + 消息队列事件 + 后台任务(含终端任务) + 运行日志"
actions={
<Button
variant="primary"
onClick={() => {
log("automation.create", "ok");
alert("新增自动化(占位):后端 M10+ 提供 CRUD。");
}}
>
新增自动化
</Button>
}
/>
{backend && !error ? <Notice tone="ok">后端已返回自动化配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">提醒 / CRON / 队列事件</h3>
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5 text-sm">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.kind}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron}</span>
<span className="ml-auto">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
</span>
<Button size="sm" variant="ghost" onClick={() => { log("automation.run", "ok"); alert(`手动触发 ${r.name}(占位)`); }}>触发</Button>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">后台任务 / 运行历史</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["消息队列事件", "后台任务(含终端任务)", "运行日志"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">📦</div>
{x} 占位 —— 后端 M10+ 提供
</div>
))}
</div>
</Surface>
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import type { BackthinkItem } from "../../lib/types";
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
const [open, setOpen] = useState(false);
return (
<Surface className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Tag tone="neutral">{new Date(item.at).toLocaleTimeString()}</Tag>
<span className="font-medium">{item.trigger}</span>
<Tag tone={item.shouldNotify ? "warn" : "neutral"}>{item.shouldNotify ? "需要打扰" : "静默"}</Tag>
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
<span className="ml-auto">
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
{item.pushed ? "已推送" : "推送结果"}
</Button>
</span>
</div>
{item.conclusion ? (
<div className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
{item.conclusion}
</div>
) : null}
<button
className="text-xs text-[var(--mu-accent-hi)]"
onClick={() => setOpen((v) => !v)}
>
{open ? "收起链路" : "查看完整链路导图"}
</button>
{open ? (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
链路导图占位 —— 后端 M10+ 提供思维链 / 流程图(graph_update 事件已接入实时通道)。
</div>
) : null}
</Surface>
);
}
export function BackthinkModule(): React.ReactNode {
const backthink = useAppStore((s) => s.backthink);
const log = useAuditStore((s) => s.log);
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
const items = backthink.filter((b) => {
if (filter === "notify") return b.shouldNotify === true;
if (filter === "silent") return b.shouldNotify !== true;
return true;
});
return (
<div className="space-y-5">
<PageHeader
title="后台思考与主动"
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 · 打扰决策可解释"
actions={
<Button
variant="soft"
onClick={() => {
log("backthink.intervene", "ok");
alert("干预(占位):后端 M10+ 提供打断/重派。");
}}
>
干预
</Button>
}
/>
<div className="flex items-center gap-2">
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
<FilterPill active={filter === "silent"} onClick={() => setFilter("silent")}>静默</FilterPill>
</div>
{items.length === 0 ? (
<EmptyState icon="🌙" title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
) : (
<div className="space-y-3">
{items.map((b) => (
<BackthinkRow key={b.id} item={b} />
))}
</div>
)}
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时);导图与干预为后端 M10+ 提供。</Notice>
</div>
);
}
function FilterPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius-pill)] px-3 py-1 text-xs transition-all duration-200 ease-[var(--mu-spring)] ${
active ? "bg-[var(--mu-accent)]/20 font-medium text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListChannels } from "../../lib/api";
import type { AdminChannelRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
key: "connected",
title: "状态",
render: (c) => (
<Tag tone={c.connected ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{c.connected ? "已连接" : "未连接"}
</Tag>
),
},
];
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const log = useAuditStore((s) => s.log);
const [ownerId, setOwnerId] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<string | null>(null);
return (
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET /api/v1/admin/channels · 类型/账号/状态/健康 · 身份识别基于 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("channel.reconnect", "ok");
alert("热重载/重连(占位):后端 M10+ 提供。");
}}
>
热重载
</Button>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无渠道数据"
rowKey={(c) => c.name}
/>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(占位 · 策略引擎 M10 接入)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
placeholder="渠道账号 id(如 weixin-ilink 的 id)"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<label className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
群聊
</label>
<Button
variant="primary"
onClick={() => {
log("channel.identity-test", "ok");
setVerdict(
group
? "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
: "匹配主人白名单 → owner;若未命中则按陌生人处理(default deny)"
);
}}
>
测试
</Button>
</div>
{verdict ? <div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">判定:{verdict}</div> : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。</Notice>
</div>
);
}
@@ -0,0 +1,114 @@
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListChannels,
adminListIoT,
adminListMemories,
adminListPersonas,
adminListPlugins,
adminListSessions,
} from "../../lib/api";
import { useAppStore } from "../../store/useAppStore";
import { navigateAdmin, type AdminSectionId } from "../router";
import { CardGrid, StatCard } from "../components";
import { Surface, Tag } from "../../components/primitives";
import { useAutoReload } from "../hooks";
interface DashCard {
id: AdminSectionId;
label: string;
icon: string;
value: number | string;
sub?: string;
tone: "ok" | "warn" | "accent" | "neutral" | "err";
}
export function DashboardModule(): React.ReactNode {
const personas = useAsyncData(adminListPersonas, []);
const sessions = useAsyncData(adminListSessions, []);
const memories = useAsyncData(adminListMemories, []);
const plugins = useAsyncData(adminListPlugins, []);
const channels = useAsyncData(adminListChannels, []);
const iot = useAsyncData(adminListIoT, []);
useAutoReload(personas.reload, 10000);
useAutoReload(sessions.reload, 10000);
useAutoReload(memories.reload, 20000);
useAutoReload(plugins.reload, 15000);
useAutoReload(channels.reload, 10000);
useAutoReload(iot.reload, 8000);
// WS-backed live signals (design/10 §3): subagents + backthink.
const agents = useAppStore((s) => s.agents);
const backthink = useAppStore((s) => s.backthink);
const wsStatus = useAppStore((s) => s.wsStatus);
const nPersonas = personas.data?.length ?? 0;
const nSessions = sessions.data?.length ?? 0;
const nMemories = memories.data?.length ?? 0;
const nPlugins = plugins.data?.length ?? 0;
const nChannels = channels.data?.length ?? 0;
const nDevices = iot.data?.length ?? 0;
const offlineChannels = (channels.data ?? []).filter((c) => !c.connected).length;
const onlineDevices = (iot.data ?? []).filter((d) => d.online).length;
const runningAgents = agents.filter((a) => a.status === "running").length;
const cards: DashCard[] = [
{ id: "personas", label: "人格", icon: "🧬", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
{ id: "sessions", label: "活跃会话", icon: "💬", value: nSessions, sub: "渠道 × actor", tone: "ok" },
{ id: "memories", label: "记忆", icon: "🧠", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
{ id: "plugins", label: "插件与能力", icon: "🔌", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
{ id: "channels", label: "渠道", icon: "📡", value: `${nChannels - offlineChannels}/${nChannels}`,
sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" },
{ id: "iot", label: "IoT 设备", icon: "📲", value: `${onlineDevices}/${nDevices}`,
sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" },
{ id: "agents", label: "Agent 运行", icon: "🧩", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
{ id: "backthink", label: "后台思考活跃度", icon: "🌙", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
];
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<div>
<h2 className="text-lg font-semibold">仪表盘</h2>
<p className="text-sm text-[var(--mu-text-lo)]">多而全的概览 · 最详细信息在各独立页面</p>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={wsStatus === "connected" ? "ok" : "neutral"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
实时通道 · {wsStatus}
</Tag>
</div>
</div>
<CardGrid>
{cards.map((c) => (
<button key={c.id} onClick={() => navigateAdmin(c.id)} className="text-left" title="点击下钻">
<StatCard icon={c.icon} label={c.label} value={c.value} sub={c.sub} tone={c.tone} />
</button>
))}
</CardGrid>
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">资源 / LLM / 业务概览</h3>
<span className="text-xs text-[var(--mu-text-lo)]">占位(后端 M10+ 提供)</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<MiniMetric label="LLM 供应商/模型" value="—" hint="多模态检测 / fallback / 角色路由" />
<MiniMetric label="存储" value="SQLite" hint="默认;PG/pgvector 可选增强" />
<MiniMetric label="工具循环上限" value="8 轮" hint="后台声明 / 前台 2s 转后台" />
</div>
<div className="pt-2 text-xs text-[var(--mu-text-lo)]">卡片即入口,点击上溯卡片下钻到对应模块。</div>
</Surface>
</div>
);
}
function MiniMetric({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode {
return (
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-3">
<div className="text-xs text-[var(--mu-text-lo)]">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
<div className="text-xs text-[var(--mu-text-lo)]">{hint}</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListIoT } from "../../lib/api";
import type { AdminIoTDevice } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<AdminIoTDevice>[] = [
{ key: "name", title: "设备", render: (d) => <span className="font-medium">{d.name}</span> },
{ key: "type", title: "类型", render: (d) => <Tag tone="accent">{d.type}</Tag> },
{ key: "id", title: "ID", render: (d) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{d.id}</span> },
{
key: "online",
title: "状态",
render: (d) => (
<Tag tone={d.online ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${d.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{d.online ? "在线" : "离线"}
</Tag>
),
},
{
key: "state",
title: "实时状态",
render: (d) => (
<span className="block max-w-md font-mono text-xs text-[var(--mu-text-mid)]">
{Object.entries(d.state)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" · ") || "—"}
</span>
),
},
];
export function IoTModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListIoT, []);
useAutoReload(reload, 8000);
const log = useAuditStore((s) => s.log);
return (
<div className="space-y-5">
<PageHeader
title="IoT 设备"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制台 + 场景 + 自动化规则"
actions={
<>
<Button
variant="soft"
onClick={() => {
log("iot.scene", "ok");
alert("场景 / 自动化规则 CRUD(占位):后端 M10+ 提供。");
}}
>
场景与规则
</Button>
<Button
variant="primary"
onClick={() => {
log("iot.control", "ok");
alert("设备控制(占位):后端 M10+ 提供指令下发。");
}}
>
设备控制台
</Button>
</>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无 IoT 设备"
rowKey={(d) => d.id}
/>
</Surface>
<Notice>实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。</Notice>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
interface ProviderRow {
id: string;
name: string;
baseUrl: string;
model: string;
multimodal: boolean;
enabled: boolean;
}
const sample: ProviderRow[] = [
{ id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
];
export function LlmModule(): React.ReactNode {
const { data, error } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ProviderRow[]>(sample);
const backend = !isNotImplemented(error);
return (
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试 + fallback + 按角色路由 + 用量"
actions={
<Button
variant="primary"
onClick={() => {
log("llm.addProvider", "ok");
alert("新增模型(占位):后端 M10+ 提供真实持久化。");
}}
>
新增模型
</Button>
}
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{rows.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl}</span>
<span className="ml-auto flex items-center gap-4">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
<Switch checked={r.multimodal} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, multimodal: v } : x)))} label="多模态" />
<Button size="sm" variant="ghost" onClick={() => { log("llm.test", "ok"); alert(`测试 ${r.name}(占位)`); }}>测试</Button>
</span>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
{["owner", "admin", "viewer"].map((role) => (
<div key={role} className="flex items-center gap-3 text-sm">
<Tag tone="neutral">{role}</Tag>
<span className="text-[var(--mu-text-mid)]">主 → 备 → 本地</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
</div>
))}
</Surface>
<Notice>配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。</Notice>
</div>
);
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListMemories } from "../../lib/api";
import type { MemoryRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
habitual: "ok",
project: "warn",
};
const columns: AdminColumn<MemoryRecord>[] = [
{ key: "scope", title: "作用域", render: (m) => <Tag tone={scopeTone[m.scope] ?? "neutral"}>{m.scope}</Tag> },
{ key: "text", title: "内容", render: (m) => <span className="block max-w-md truncate">{m.text}</span> },
{ key: "id", title: "ID", render: (m) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{m.id}</span> },
];
export function MemoriesModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListMemories, []);
useAutoReload(reload, 15000);
const [query, setQuery] = useState("");
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((m) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q);
});
return (
<div className="space-y-5">
<PageHeader
title="记忆管理"
subtitle="GET /api/v1/admin/memories · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
actions={
<Button
variant="soft"
onClick={() => {
log("memory.vectorTest", "ok");
alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。");
}}
>
向量测试
</Button>
}
/>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按内容 / 作用域 / ID 检索…"
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<AdminTable
columns={columns}
rows={loading ? null : filtered}
loading={loading}
error={error}
emptyTitle="无记忆数据"
rowKey={(m) => m.id}
/>
</Surface>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
</div>
);
}
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
type Tab = "logs" | "trace" | "metrics" | "audit";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "logs", label: "实时日志" },
{ id: "trace", label: "Tracing" },
{ id: "metrics", label: "指标" },
{ id: "audit", label: "审计" },
];
export function ObservabilityModule(): React.ReactNode {
const [tab, setTab] = useState<Tab>("logs");
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
const audit = useAuditStore((s) => s.records);
const clearAudit = useAuditStore((s) => s.clear);
return (
<div className="space-y-5">
<PageHeader title="可观测性" subtitle="实时日志 tail · Tracing(LLM/工具/编排) · 指标 · 审计" />
<div className="flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
tab === t.id ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "logs" ? <LogsTail lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : null}
</div>
);
}
function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">实时日志流(WS 事件回显)</h3>
<Tag tone="ok">tail</Tag>
</div>
{lines.length === 0 ? (
<Notice>暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。</Notice>
) : (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
{lines.join("\n")}
</pre>
)}
<Notice>完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。</Notice>
</Surface>
);
}
function TraceView(): React.ReactNode {
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["LLM 调用链", "工具链", "编排链"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">{x}</div>
{x} 占位 —— 后端 M10+ 提供 span/trace 数据
</div>
))}
</div>
</Surface>
);
}
function MetricsView(): React.ReactNode {
return (
<div className="space-y-4">
<CardGrid>
<StatCard icon="⚙" label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
<StatCard icon="🤖" label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
<StatCard icon="🧩" label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
<StatCard icon="🧠" label="工时/Token" value="—" sub="成本占位" tone="warn" />
</CardGrid>
<Notice>指标时序图 / 导出需后端 M10+ 提供。</Notice>
</div>
);
}
interface AuditViewProps {
records: ReturnType<typeof useAuditStore.getState>["records"];
onClear: () => void;
}
function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">审计日志(who / what / when / result)</h3>
<span className="text-xs text-[var(--mu-text-lo)]">管理 + 敏感操作全程落审计</span>
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={onClear}>
清空本地
</Button>
</span>
</div>
{records.length === 0 ? (
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--mu-glass-border)] text-left text-xs uppercase tracking-wider text-[var(--mu-text-lo)]">
<th className="px-2 py-1.5">who</th>
<th className="px-2 py-1.5">what</th>
<th className="px-2 py-1.5">when</th>
<th className="px-2 py-1.5">result</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-b border-[var(--mu-glass-border)]/60 last:border-0">
<td className="px-2 py-2 text-xs">{r.who}</td>
<td className="px-2 py-2 text-xs font-mono">{r.what}</td>
<td className="px-2 py-2 text-xs text-[var(--mu-text-lo)]">{new Date(r.when).toLocaleString()}</td>
<td className="px-2 py-2"><Tag tone={r.result === "ok" ? "ok" : "err"}>{r.result}</Tag></td>
</tr>
))}
</tbody>
</table>
)}
</Surface>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPersonas } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<PersonaRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "default",
title: "默认",
render: (p) => (p.isDefault ? <Tag tone="accent">默认</Tag> : <span className="text-[var(--mu-text-lo)]">—</span>),
},
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace}</span> },
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
];
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
useAutoReload(reload, 10000);
const [selected, setSelected] = useState<PersonaRecord | null>(null);
const log = useAuditStore((s) => s.log);
return (
<div className="space-y-5">
<PageHeader
title="人格管理"
subtitle="GET /api/v1/admin/personas · 身份/情绪曲线/好感度/模型路由/工具标记"
actions={
<Button
variant="primary"
onClick={() => {
log("persona.create", "ok");
alert("新建人格:后端 M10+ 提供,当前为占位操作。");
}}
>
新建人格
</Button>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无人格数据(后端未接线)"
rowKey={(p) => p.id}
/>
</Surface>
<Notice>点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。</Notice>
{selected ? (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">编辑 · {selected.name}</h3>
<span className="ml-auto">
<Button
size="sm"
variant="soft"
onClick={() => {
log("persona.edit", "ok");
alert("保存(本地占位):后端 M10+ 提供真实持久化。");
}}
>
保存(模拟)
</Button>
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<LabeledField label="ID" value={selected.id} />
<LabeledField label="记忆域" value={selected.memoryNamespace} />
<LabeledField label="角色模式" value={selected.mode} />
<LabeledField label="角色描述" value={selected.character} />
</div>
</Surface>
) : null}
</div>
);
}
function LabeledField({ label, value }: { label: string; value: string }): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
defaultValue={value}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</label>
);
}
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, getStoredRole } from "../../lib/api";
import type { AdminPluginRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
setFail(null);
adminSetPluginPolicy(name, allowInGroup, allowInStranger)
.then(() => {
log(`policy.set:${name}`, "ok");
reload();
})
.catch((err: unknown) => {
log(`policy.set:${name}`, "error");
setFail(err instanceof Error ? err.message : "策略更新失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminPluginRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "policy",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="插件与能力"
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
actions={
<Button
variant="primary"
onClick={() => {
log("plugin.install", "ok");
alert("安装/卸载插件:后端 M10+ 提供,当前为占位操作。");
}}
>
安装插件
</Button>
}
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{fail ? <Notice tone="err">策略更新失败:{fail}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无插件数据"
rowKey={(p) => p.name}
/>
</Surface>
<Notice>工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
interface DocRow {
id: string;
name: string;
chunks: number;
embedded: boolean;
}
const docs: DocRow[] = [
{ id: "1", name: "owner_identity.md", chunks: 1, embedded: false },
{ id: "2", name: "开发时间线.md", chunks: 6, embedded: false },
{ id: "3", name: "design/10-后台管理面板.md", chunks: 2, embedded: false },
];
export function RagModule(): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[]>([]);
return (
<div className="space-y-5">
<PageHeader
title="知识库 RAG"
subtitle="文档列表(分块/嵌入状态) + 索引 + 检索测试"
actions={
<Button
variant="primary"
onClick={() => {
log("rag.upload", "ok");
alert("上传文档(占位):后端 M10+ 提供分块/嵌入。");
}}
>
上传文档
</Button>
}
/>
<Surface className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">文档列表</h3>
<span className="text-xs text-[var(--mu-text-lo)]">{docs.length} 篇</span>
</div>
{docs.map((d) => (
<div key={d.id} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm">
<span className="font-medium">{d.name}</span>
<span className="text-xs text-[var(--mu-text-lo)]">{d.chunks} 分块</span>
<span className="ml-auto">
<Tag tone={d.embedded ? "ok" : "warn"}>{d.embedded ? "已嵌入" : "待嵌入"}</Tag>
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
log("rag.delete", "ok");
alert(`删除 ${d.name}(占位)`);
}}
>
删除
</Button>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">检索测试</h3>
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="输入查询…"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<Button
variant="primary"
onClick={() => {
log("rag.search", "ok");
setHits(query ? [`片段 1(命中 "${query}")`, `片段 2(命中 "${query}")`] : []);
}}
>
检索
</Button>
</div>
{hits.length > 0 ? (
<div className="space-y-1.5">
{hits.map((h) => (
<div key={h} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">{h}</div>
))}
</div>
) : null}
</Surface>
<Notice>分块/嵌入状态、向量索引与语义检索为后端 M10+ 提供;配置了 pgvector 增强后必须真实走语义路径(非空壳)。</Notice>
</div>
);
}
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListSessions } from "../../lib/api";
import type { SessionRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<SessionRecord>[] = [
{ key: "platform", title: "渠道", render: (s) => <Tag tone="accent">{s.platform}</Tag> },
{ key: "actor", title: "actor 元数据", render: (s) => <Tag tone="neutral">{s.actorRole}</Tag> },
{ key: "flags", title: "标记", render: (s) => (
<span className="flex flex-wrap gap-1">
{s.owner ? <Tag tone="ok">主人</Tag> : null}
{s.isGroup ? <Tag tone="warn">群聊</Tag> : null}
<Tag tone={s.memWritable ? "ok" : "neutral"}>{s.memWritable ? "记忆可写" : "记忆只读"}</Tag>
</span>
) },
{ key: "channel", title: "channelId", render: (s) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{s.channelId}</span> },
{ key: "session", title: "sessionId", render: (s) => <span className="font-mono text-xs">{s.sessionId}</span> },
];
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
const [query, setQuery] = useState("");
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((s) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
s.platform.toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q) ||
s.channelId.toLowerCase().includes(q) ||
s.actorRole.toLowerCase().includes(q)
);
});
return (
<div className="space-y-5">
<PageHeader
title="会话管理"
subtitle="GET /api/v1/admin/sessions · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("session.export", "ok");
alert("导出会话(占位):后端 M10+ 提供导出。");
}}
>
导出
</Button>
}
/>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按渠道 / sessionId / channelId / actor 过滤…"
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<AdminTable
columns={columns}
rows={loading ? null : filtered}
loading={loading}
error={error}
emptyTitle="无会话数据"
rowKey={(s) => s.sessionId}
/>
</Surface>
<Notice>会话详情 / 上下文编辑 / 删除为后端 M10+ 提供,当前仅列表只读。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from "react";
import { getStoredRole } from "../../lib/api";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [logLevel, setLogLevel] = useState("info");
const sensitive = (what: string, note: string) => {
if (!isOwner) {
alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`);
return;
}
log(`system.${what}`, "ok");
alert(`${note}(占位):后端 M10+ 提供真实执行。`);
};
return (
<div className="space-y-5">
<PageHeader title="系统设置" subtitle="环境变量 + 安全(密钥/JWT) + 存储 + 备份/恢复 + 重启/更新 + 日志级别" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-2">
<SectionTitle icon="🔐" title="安全 & 密钥" ownerOnly />
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
<KeyValueRow label="审计" value="who/what/when/result" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("rotateJwt", "轮换 JWT 密钥")}>轮换 JWT 密钥</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("revokeAllKeys", "吊销全部 API key")}>吊销全部 API key</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🗃" title="存储" />
<KeyValueRow label="默认" value="SQLite" />
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("backup", "备份数据")}>备份</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restore", "恢复数据")}>恢复</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="📦" title="环境变量" />
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
<KeyValueRow label="SERVER_PORT" value="8080" mono />
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
<div className="pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("editEnv", "编辑环境变量")}>编辑环境变量</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🔄" title="运行时" />
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">日志级别</span>
<span className="flex gap-1.5">
{["debug", "info", "warn", "error"].map((lvl) => (
<button
key={lvl}
onClick={() => setLogLevel(lvl)}
className={`rounded-[var(--mu-radius)] px-2 py-1 text-xs ${
logLevel === lvl ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{lvl}
</button>
))}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restart", "重启服务")}>重启服务</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("update", "检查/安装更新")}>检查更新</Button>
</div>
</Surface>
</div>
{!isOwner ? <Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice> : null}
</div>
);
}
function SectionTitle({ icon, title, ownerOnly = false }: { icon: string; title: string; ownerOnly?: boolean }): React.ReactNode {
return (
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span>{icon}</span>
<span>{title}</span>
{ownerOnly ? <Tag tone="warn">owner</Tag> : null}
</h3>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListUsers, getStoredRole } from "../../lib/api";
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
owner: "accent",
admin: "ok",
viewer: "neutral",
};
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
];
const initialProfile: OwnerProfile = {
id: "owner",
displayName: "叶酱",
defaultPersona: "cyrene",
identityFile: "configs/users/owner_identity.md",
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
accounts: [],
};
export function UsersModule(): React.ReactNode {
const users = useAsyncData(adminListUsers, []);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
const [sampleKey, setSampleKey] = useState<string | null>(null);
const saveProfile = () => {
log("owner.profile.edit", "ok");
setProfile({ ...profile });
};
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
actions={
<Button
variant="soft"
onClick={() => {
log("user.create", "ok");
alert("新增用户 / 角色分配(占位):后端 M10+ 提供。");
}}
>
新增用户
</Button>
}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
{users.error && !users.data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error}</Notice>
<AdminTable
columns={columns}
rows={[
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
]}
loading={false}
error={null}
rowKey={(u) => u.id}
/>
</div>
) : (
<AdminTable columns={columns} rows={users.data} loading={users.loading} error={users.error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
disabled={!isOwner}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;真实持久化 + 热重载需后端 M10+ 提供。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{sampleKey ? (
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">
{sampleKey}
</div>
) : null}
<div className="flex items-center gap-2">
<Button
variant="soft"
disabled={!isOwner}
onClick={() => {
log("apikey.generate", "ok");
const raw = new Uint8Array(24);
crypto.getRandomValues(raw);
setSampleKey(Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""));
}}
>
签发新密钥(模拟)
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">以 X-API-Key 头访问管理端点;真实签发/撤销需后端 M10+。</span>
</div>
</Surface>
</div>
);
}
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}
function Field({ label, value, onChange, disabled }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</label>
);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Hash-based admin routing. The M9 SPA uses a state store (useModeStore) instead
* of react-router, so the admin subsystem stays consistent: it is entered via
* `#/admin` and each module is a hash segment (`#/admin/personas`, ...). Keeps
* the build dependency-free and works behind the single-binary gateway serving
* `base: "./"`.
*/
export type AdminSectionId =
| "dashboard"
| "personas"
| "sessions"
| "memories"
| "plugins"
| "channels"
| "iot"
| "rag"
| "llm"
| "automations"
| "agents"
| "backthink"
| "observability"
| "system"
| "users";
export interface AdminRoute {
admin: boolean;
section: AdminSectionId;
}
const VALID_SECTIONS: ReadonlySet<string> = new Set([
"dashboard",
"personas",
"sessions",
"memories",
"plugins",
"channels",
"iot",
"rag",
"llm",
"automations",
"agents",
"backthink",
"observability",
"system",
"users",
]);
/** Parse a `window.location.hash` into an admin route descriptor. */
export function parseAdminHash(hash: string): AdminRoute {
const raw = hash.replace(/^#/, "").replace(/^\/+/, "").split("/");
if (raw[0] !== "admin") return { admin: false, section: "dashboard" };
const section = raw[1];
return {
admin: true,
section: VALID_SECTIONS.has(section) ? (section as AdminSectionId) : "dashboard",
};
}
/** The hash that selects the admin root (dashboard). */
export const ADMIN_ROOT_HASH = "#/admin";
/** Build a hash for a given admin section. */
export function adminSectionHash(section: AdminSectionId): string {
return section === "dashboard" ? ADMIN_ROOT_HASH : `#/admin/${section}`;
}
/** Navigate to an admin section by setting the location hash. */
export function navigateAdmin(section: AdminSectionId): void {
window.location.hash = adminSectionHash(section);
}
+36
View File
@@ -0,0 +1,36 @@
import type { AdminSectionId } from "./router";
/** Sidebar grouping for the 15 admin modules. Groups are visual only. */
export interface AdminSectionMeta {
id: AdminSectionId;
label: string;
icon: string;
group: string;
}
/**
* The 15-module registry (design/10 §3). Order drives the sidebar; group drives
* the section headers. All are clickable — several are shells until M10+ backend.
*/
export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
{ id: "dashboard", label: "仪表盘", icon: "📊", group: "总览" },
{ id: "personas", label: "人格管理", icon: "🧬", group: "内容" },
{ id: "sessions", label: "会话管理", icon: "💬", group: "内容" },
{ id: "memories", label: "记忆管理", icon: "🧠", group: "内容" },
{ id: "rag", label: "知识库 RAG", icon: "📚", group: "内容" },
{ id: "plugins", label: "插件与能力", icon: "🔌", group: "能力与连接" },
{ id: "channels", label: "渠道管理", icon: "📡", group: "能力与连接" },
{ id: "iot", label: "IoT 设备", icon: "📲", group: "能力与连接" },
{ id: "llm", label: "LLM 模型", icon: "🤖", group: "能力与连接" },
{ id: "automations", label: "自动化与提醒", icon: "⏰", group: "能力与连接" },
{ id: "agents", label: "Agent 监控", icon: "🧩", group: "运行时" },
{ id: "backthink", label: "后台思考", icon: "🌙", group: "运行时" },
{ id: "observability", label: "可观测性", icon: "📈", group: "运行时" },
{ id: "users", label: "用户与权限", icon: "👥", group: "治理" },
{ id: "system", label: "系统设置", icon: "⚙", group: "治理" },
];
/** Group headers in sidebar order (derived from ADMIN_SECTIONS). */
export const ADMIN_GROUPS: ReadonlyArray<string> = Array.from(
new Set(ADMIN_SECTIONS.map((s) => s.group))
);
+8
View File
@@ -46,6 +46,14 @@ export function TopBar(): React.ReactNode {
{meta.label}
</Tag>
<button
onClick={() => { window.location.hash = "#/admin"; }}
aria-label="管理面板"
title="管理面板 /admin"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
🛡
</button>
<button
onClick={toggleTheme}
aria-label="切换主题"
+123 -2
View File
@@ -1,8 +1,16 @@
import type {
AdminAutomationRecord,
AdminChannelRecord,
AdminIoTDevice,
AdminLlmConfig,
AdminPluginRecord,
AdminUserRecord,
ApiErrorShape,
ChatSyncResult,
LoginResult,
MemoryRecord,
PersonaRecord,
Role,
SessionRecord,
} from "./types";
@@ -25,13 +33,58 @@ export class ApiError extends Error {
const API_BASE = "/api/v1";
const TOKEN_KEY = "cyrene.jwt";
const ROLE_KEY = "cyrene.role";
const API_KEY_KEY = "cyrene.apikey";
export function getAuthToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setAuthToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
if (token) {
localStorage.setItem(TOKEN_KEY, token);
setStoredRole(decodeJwtRole(token));
} else {
localStorage.removeItem(TOKEN_KEY);
setStoredRole(null);
}
}
/** Decode the RBAC role from a JWT payload (client-side only; never verified).
* base64url is converted to base64 before decoding to survive `-`/`_`. */
export function decodeJwtRole(token: string): Role | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(padded)) as Record<string, unknown>;
const r = payload.role;
return r === "owner" || r === "admin" || r === "viewer" ? r : null;
} catch {
return null;
}
}
/** Persisted RBAC role, refreshed when the token changes. Falls back to
* decoding the token so a session created before role persistence still works. */
export function getStoredRole(): Role | null {
const r = localStorage.getItem(ROLE_KEY);
if (r === "owner" || r === "admin" || r === "viewer") return r;
const token = getAuthToken();
return token ? decodeJwtRole(token) : null;
}
export function setStoredRole(role: Role | null): void {
if (role) localStorage.setItem(ROLE_KEY, role);
else localStorage.removeItem(ROLE_KEY);
}
/** Optional API key for admin endpoints (X-API-Key). */
export function getApiKey(): string | null {
return localStorage.getItem(API_KEY_KEY);
}
export function setApiKey(key: string | null): void {
if (key) localStorage.setItem(API_KEY_KEY, key);
else localStorage.removeItem(API_KEY_KEY);
}
interface Envelope {
@@ -48,6 +101,8 @@ async function request<T>(
headers.set("X-API-Version", "1");
const token = getAuthToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const apiKey = getApiKey();
if (apiKey) headers.set("X-API-Key", apiKey);
let res: Response;
try {
@@ -115,3 +170,69 @@ export function fetchSessions(): Promise<SessionRecord[]> {
export function fetchPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/personas");
}
/** Is this error a "backend has not implemented it yet" case (404/501/403 on a
* not-yet-wired route) rather than a real connectivity problem? */
export function isNotImplemented(err: unknown): boolean {
if (err instanceof ApiError) {
return err.status === 404 || err.status === 501 || err.code === "not_found";
}
return false;
}
/* ----------------------------------------------------------------------------
* Admin API (/api/v1/admin/*). Reference: Docs/api-client.md §2 + design/10.
* Implemented endpoints return real data; the rest are contract-shaped and are
* expected to 404 until the backend wires them — callers show a graceful shell.
* -------------------------------------------------------------------------- */
/** GET /api/v1/admin/personas */
export function adminListPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/admin/personas");
}
/** GET /api/v1/admin/sessions */
export function adminListSessions(): Promise<SessionRecord[]> {
return request<SessionRecord[]>("/admin/sessions");
}
/** GET /api/v1/admin/memories */
export function adminListMemories(): Promise<MemoryRecord[]> {
return request<MemoryRecord[]>("/admin/memories");
}
/** GET /api/v1/admin/plugins */
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
return request<AdminPluginRecord[]>("/admin/plugins");
}
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
export function adminSetPluginPolicy(
name: string,
allowInGroup: boolean,
allowInStranger: boolean
): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/policy", {
method: "POST",
body: JSON.stringify({ name, allowInGroup, allowInStranger }),
});
}
/** GET /api/v1/admin/channels */
export function adminListChannels(): Promise<AdminChannelRecord[]> {
return request<AdminChannelRecord[]>("/admin/channels");
}
/** GET /api/v1/admin/iot */
export function adminListIoT(): Promise<AdminIoTDevice[]> {
return request<AdminIoTDevice[]>("/admin/iot");
}
// --- Planned M10 endpoints (contract-shaped, backend may 404). ---
/** GET /api/v1/admin/users */
export function adminListUsers(): Promise<AdminUserRecord[]> {
return request<AdminUserRecord[]>("/admin/users");
}
/** GET /api/v1/admin/llm */
export function adminGetLlm(): Promise<AdminLlmConfig> {
return request<AdminLlmConfig>("/admin/llm");
}
/** GET /api/v1/admin/automations */
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
return request<AdminAutomationRecord[]>("/admin/automations");
}
+98
View File
@@ -160,3 +160,101 @@ export interface ApiErrorShape {
code?: string;
message?: string;
}
/** RBAC roles for the admin panel (owner > admin > viewer). */
export type Role = "owner" | "admin" | "viewer";
/** A memory entry as returned by GET /api/v1/admin/memories. */
export interface MemoryRecord {
id: string;
scope: string;
text: string;
}
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
export interface AdminPluginRecord {
name: string;
kind: string;
description: string;
allowInGroup: boolean;
allowInStranger: boolean;
}
/** A channel adapter as returned by GET /api/v1/admin/channels. */
export interface AdminChannelRecord {
name: string;
connected: boolean;
}
/** An IoT device as returned by GET /api/v1/admin/iot. */
export interface AdminIoTDevice {
id: string;
name: string;
type: string;
state: Record<string, unknown>;
online: boolean;
}
/** A backend user / API key record (GET /api/v1/admin/users, planned M10). */
export interface AdminUserRecord {
id: string;
username: string;
role: string;
createdAt?: string;
}
/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, planned M10). */
export interface AdminLlmProvider {
id: string;
name: string;
baseUrl: string;
model: string;
multimodal: boolean;
enabled: boolean;
fallback?: string;
}
export interface AdminLlmConfig {
providers: AdminLlmProvider[];
}
/** An automation / reminder entry (GET/POST /api/v1/admin/automations, planned). */
export interface AdminAutomationRecord {
id: string;
name: string;
kind: string;
cron?: string;
enabled: boolean;
lastRun?: string;
nextRun?: string;
}
/** A structured log line (GET /api/v1/admin/logs, planned). */
export interface AdminLogRecord {
ts: string;
level: string;
source: string;
msg: string;
}
/** One audit entry: who / what / when / result. */
export interface AuditRecord {
id: string;
who: string;
what: string;
when: string;
result: string;
}
/** The owner profile (mirrors configs/users/owner.yaml). */
export interface OwnerProfile {
id: string;
displayName: string;
defaultPersona: string;
identityFile: string;
notes: string;
accounts: OwnerAccount[];
}
export interface OwnerAccount {
platform: string;
accountId: string;
}