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:
1 parent
4c48e94659
commit
f43eb17030
26 files changed
+2253
-4
No files matched your search
@@ -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`(契约先行):骨架 + 端点登记表。
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user