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

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

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import type { AgentInfo, AgentStatus } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
running: { label: "运行中", tone: "accent" },
blocked: { label: "阻塞", tone: "warn" },
done: { label: "完成", tone: "ok" },
error: { label: "错误", tone: "err" },
};
function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) => void }): React.ReactNode {
const meta = statusTone[agent.status] ?? statusTone.idle;
return (
<Surface className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${agent.status === "running" ? "bg-[var(--mu-accent)] mu-blink" : "bg-[var(--mu-surface-5)]"}`} />
<span className="text-sm font-medium">{agent.name}</span>
{agent.role ? <span className="text-xs text-[var(--mu-text-lo)]">{agent.role}</span> : null}
<Tag tone={meta.tone} className="ml-auto">{meta.label}</Tag>
</div>
{typeof agent.progress === "number" ? (
<div className="h-1.5 w-full overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<div className="h-full rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)]" style={{ width: `${Math.min(100, Math.max(0, agent.progress))}%` }} />
</div>
) : null}
{agent.thinking ? (
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">{agent.thinking}</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="text-xs text-[var(--mu-text-lo)]">⚙ {t.name}{t.ok === false ? " ✗" : ""}</div>
))}
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => sendUp({ type: "task_output_query", data: { taskId: agent.id } })}>
上下文查询
</Button>
<Button size="sm" variant="outline" onClick={() => onKill(agent.id)}>
终止
</Button>
</div>
</Surface>
);
}
export function AgentsModule(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const flowGraph = useAppStore((s) => s.flowGraph);
const taskLogs = useAppStore((s) => s.taskLogs);
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
const log = useAuditStore((s) => s.log);
const [tab, setTab] = useState<"main" | "sub">("main");
const subAgents = agents.filter((a) => a.id !== "main");
const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents;
return (
<div className="space-y-5">
<PageHeader
title="Agent 运行监控"
subtitle="主会话专属页签 + 子代理:状态/进度/思维链/工具链/流程图/后台任务/agent 消息流"
actions={
<Button
variant="soft"
onClick={() => {
log("agent.respawn", "ok");
alert("重派(占位):后端 M10+ 提供子代理重派。");
}}
>
重派
</Button>
}
/>
<div className="flex items-center gap-2">
<TabPill active={tab === "main"} onClick={() => setTab("main")}>主会话</TabPill>
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
</div>
{flowGraph ? (
<Surface className="flex flex-wrap items-center gap-3">
{flowGraph.nodes.map((n, i) => (
<div key={n.id} className="flex items-center gap-3">
<Tag tone={n.status === "running" ? "accent" : "neutral"}>{n.label}</Tag>
{i < flowGraph.nodes.length - 1 ? <span className="text-[var(--mu-text-lo)]">→</span> : null}
</div>
))}
</Surface>
) : (
<Notice>流程图占位 —— 连接 /ws/chat 后按 graph_update 事件渲染。</Notice>
)}
{list.length === 0 ? (
<EmptyState icon="🧩" title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
) : (
<div className="space-y-3">
{list.map((a) => (
<AgentCard key={a.id} agent={a} onKill={(id) => { log(`agent.kill:${id}`, "ok"); alert(`终止 ${id}(占位)`); }} />
))}
</div>
)}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">Agent 消息流 / 后台任务日志</h3>
{Object.keys(taskLogs).length === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">暂无任务日志。</div>
) : (
Object.entries(taskLogs).map(([id, text]) => (
<div key={id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-2">
<div className="mb-1 flex items-center gap-2 text-xs">
<Tag tone="accent">{id}</Tag>
<button className="ml-auto text-[var(--mu-text-lo)]" onClick={() => clearTaskLog(id)}>清空</button>
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap font-mono text-xs text-[var(--mu-text-mid)]">{text}</pre>
</div>
))
)}
<Notice>子代理链上下文查询、终止/重派、流程图导出需后端 M10+ 提供。</Notice>
</Surface>
</div>
);
}
function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
active ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListAutomations, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
interface ReminderRow {
id: string;
name: string;
kind: string;
cron: string;
enabled: boolean;
}
const sample: ReminderRow[] = [
{ id: "r1", name: "睡前总结", kind: "reminder", cron: "0 22 * * *", enabled: true },
{ id: "r2", name: "晨间问候", kind: "cron", cron: "0 8 * * *", enabled: true },
{ id: "r3", name: "设备待机提醒", kind: "queue", cron: "—", enabled: false },
];
export function AutomationsModule(): React.ReactNode {
const { data, error } = useAsyncData(adminListAutomations, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ReminderRow[]>(sample);
const backend = !isNotImplemented(error);
return (
<div className="space-y-5">
<PageHeader
title="自动化与提醒"
subtitle="提醒 + cron + 消息队列事件 + 后台任务(含终端任务) + 运行日志"
actions={
<Button
variant="primary"
onClick={() => {
log("automation.create", "ok");
alert("新增自动化(占位):后端 M10+ 提供 CRUD。");
}}
>
新增自动化
</Button>
}
/>
{backend && !error ? <Notice tone="ok">后端已返回自动化配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">提醒 / CRON / 队列事件</h3>
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5 text-sm">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.kind}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron}</span>
<span className="ml-auto">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
</span>
<Button size="sm" variant="ghost" onClick={() => { log("automation.run", "ok"); alert(`手动触发 ${r.name}(占位)`); }}>触发</Button>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">后台任务 / 运行历史</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["消息队列事件", "后台任务(含终端任务)", "运行日志"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">📦</div>
{x} 占位 —— 后端 M10+ 提供
</div>
))}
</div>
</Surface>
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import type { BackthinkItem } from "../../lib/types";
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
const [open, setOpen] = useState(false);
return (
<Surface className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Tag tone="neutral">{new Date(item.at).toLocaleTimeString()}</Tag>
<span className="font-medium">{item.trigger}</span>
<Tag tone={item.shouldNotify ? "warn" : "neutral"}>{item.shouldNotify ? "需要打扰" : "静默"}</Tag>
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
<span className="ml-auto">
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
{item.pushed ? "已推送" : "推送结果"}
</Button>
</span>
</div>
{item.conclusion ? (
<div className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
{item.conclusion}
</div>
) : null}
<button
className="text-xs text-[var(--mu-accent-hi)]"
onClick={() => setOpen((v) => !v)}
>
{open ? "收起链路" : "查看完整链路导图"}
</button>
{open ? (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
链路导图占位 —— 后端 M10+ 提供思维链 / 流程图(graph_update 事件已接入实时通道)。
</div>
) : null}
</Surface>
);
}
export function BackthinkModule(): React.ReactNode {
const backthink = useAppStore((s) => s.backthink);
const log = useAuditStore((s) => s.log);
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
const items = backthink.filter((b) => {
if (filter === "notify") return b.shouldNotify === true;
if (filter === "silent") return b.shouldNotify !== true;
return true;
});
return (
<div className="space-y-5">
<PageHeader
title="后台思考与主动"
subtitle="触发源 / 时间 / 结论 / 是否打扰 / 推送结果 · 打扰决策可解释"
actions={
<Button
variant="soft"
onClick={() => {
log("backthink.intervene", "ok");
alert("干预(占位):后端 M10+ 提供打断/重派。");
}}
>
干预
</Button>
}
/>
<div className="flex items-center gap-2">
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
<FilterPill active={filter === "silent"} onClick={() => setFilter("silent")}>静默</FilterPill>
</div>
{items.length === 0 ? (
<EmptyState icon="🌙" title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
) : (
<div className="space-y-3">
{items.map((b) => (
<BackthinkRow key={b.id} item={b} />
))}
</div>
)}
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时);导图与干预为后端 M10+ 提供。</Notice>
</div>
);
}
function FilterPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius-pill)] px-3 py-1 text-xs transition-all duration-200 ease-[var(--mu-spring)] ${
active ? "bg-[var(--mu-accent)]/20 font-medium text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListChannels } from "../../lib/api";
import type { AdminChannelRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
key: "connected",
title: "状态",
render: (c) => (
<Tag tone={c.connected ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{c.connected ? "已连接" : "未连接"}
</Tag>
),
},
];
export function ChannelsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const log = useAuditStore((s) => s.log);
const [ownerId, setOwnerId] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<string | null>(null);
return (
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET /api/v1/admin/channels · 类型/账号/状态/健康 · 身份识别基于 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("channel.reconnect", "ok");
alert("热重载/重连(占位):后端 M10+ 提供。");
}}
>
热重载
</Button>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无渠道数据"
rowKey={(c) => c.name}
/>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(占位 · 策略引擎 M10 接入)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
placeholder="渠道账号 id(如 weixin-ilink 的 id)"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<label className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
群聊
</label>
<Button
variant="primary"
onClick={() => {
log("channel.identity-test", "ok");
setVerdict(
group
? "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
: "匹配主人白名单 → owner;若未命中则按陌生人处理(default deny)"
);
}}
>
测试
</Button>
</div>
{verdict ? <div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">判定:{verdict}</div> : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。</Notice>
</div>
);
}
@@ -0,0 +1,114 @@
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListChannels,
adminListIoT,
adminListMemories,
adminListPersonas,
adminListPlugins,
adminListSessions,
} from "../../lib/api";
import { useAppStore } from "../../store/useAppStore";
import { navigateAdmin, type AdminSectionId } from "../router";
import { CardGrid, StatCard } from "../components";
import { Surface, Tag } from "../../components/primitives";
import { useAutoReload } from "../hooks";
interface DashCard {
id: AdminSectionId;
label: string;
icon: string;
value: number | string;
sub?: string;
tone: "ok" | "warn" | "accent" | "neutral" | "err";
}
export function DashboardModule(): React.ReactNode {
const personas = useAsyncData(adminListPersonas, []);
const sessions = useAsyncData(adminListSessions, []);
const memories = useAsyncData(adminListMemories, []);
const plugins = useAsyncData(adminListPlugins, []);
const channels = useAsyncData(adminListChannels, []);
const iot = useAsyncData(adminListIoT, []);
useAutoReload(personas.reload, 10000);
useAutoReload(sessions.reload, 10000);
useAutoReload(memories.reload, 20000);
useAutoReload(plugins.reload, 15000);
useAutoReload(channels.reload, 10000);
useAutoReload(iot.reload, 8000);
// WS-backed live signals (design/10 §3): subagents + backthink.
const agents = useAppStore((s) => s.agents);
const backthink = useAppStore((s) => s.backthink);
const wsStatus = useAppStore((s) => s.wsStatus);
const nPersonas = personas.data?.length ?? 0;
const nSessions = sessions.data?.length ?? 0;
const nMemories = memories.data?.length ?? 0;
const nPlugins = plugins.data?.length ?? 0;
const nChannels = channels.data?.length ?? 0;
const nDevices = iot.data?.length ?? 0;
const offlineChannels = (channels.data ?? []).filter((c) => !c.connected).length;
const onlineDevices = (iot.data ?? []).filter((d) => d.online).length;
const runningAgents = agents.filter((a) => a.status === "running").length;
const cards: DashCard[] = [
{ id: "personas", label: "人格", icon: "🧬", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
{ id: "sessions", label: "活跃会话", icon: "💬", value: nSessions, sub: "渠道 × actor", tone: "ok" },
{ id: "memories", label: "记忆", icon: "🧠", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
{ id: "plugins", label: "插件与能力", icon: "🔌", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
{ id: "channels", label: "渠道", icon: "📡", value: `${nChannels - offlineChannels}/${nChannels}`,
sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" },
{ id: "iot", label: "IoT 设备", icon: "📲", value: `${onlineDevices}/${nDevices}`,
sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" },
{ id: "agents", label: "Agent 运行", icon: "🧩", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
{ id: "backthink", label: "后台思考活跃度", icon: "🌙", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
];
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<div>
<h2 className="text-lg font-semibold">仪表盘</h2>
<p className="text-sm text-[var(--mu-text-lo)]">多而全的概览 · 最详细信息在各独立页面</p>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={wsStatus === "connected" ? "ok" : "neutral"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
实时通道 · {wsStatus}
</Tag>
</div>
</div>
<CardGrid>
{cards.map((c) => (
<button key={c.id} onClick={() => navigateAdmin(c.id)} className="text-left" title="点击下钻">
<StatCard icon={c.icon} label={c.label} value={c.value} sub={c.sub} tone={c.tone} />
</button>
))}
</CardGrid>
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">资源 / LLM / 业务概览</h3>
<span className="text-xs text-[var(--mu-text-lo)]">占位(后端 M10+ 提供)</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<MiniMetric label="LLM 供应商/模型" value="—" hint="多模态检测 / fallback / 角色路由" />
<MiniMetric label="存储" value="SQLite" hint="默认;PG/pgvector 可选增强" />
<MiniMetric label="工具循环上限" value="8 轮" hint="后台声明 / 前台 2s 转后台" />
</div>
<div className="pt-2 text-xs text-[var(--mu-text-lo)]">卡片即入口,点击上溯卡片下钻到对应模块。</div>
</Surface>
</div>
);
}
function MiniMetric({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode {
return (
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-3">
<div className="text-xs text-[var(--mu-text-lo)]">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
<div className="text-xs text-[var(--mu-text-lo)]">{hint}</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListIoT } from "../../lib/api";
import type { AdminIoTDevice } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<AdminIoTDevice>[] = [
{ key: "name", title: "设备", render: (d) => <span className="font-medium">{d.name}</span> },
{ key: "type", title: "类型", render: (d) => <Tag tone="accent">{d.type}</Tag> },
{ key: "id", title: "ID", render: (d) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{d.id}</span> },
{
key: "online",
title: "状态",
render: (d) => (
<Tag tone={d.online ? "ok" : "err"}>
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${d.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
{d.online ? "在线" : "离线"}
</Tag>
),
},
{
key: "state",
title: "实时状态",
render: (d) => (
<span className="block max-w-md font-mono text-xs text-[var(--mu-text-mid)]">
{Object.entries(d.state)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" · ") || "—"}
</span>
),
},
];
export function IoTModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListIoT, []);
useAutoReload(reload, 8000);
const log = useAuditStore((s) => s.log);
return (
<div className="space-y-5">
<PageHeader
title="IoT 设备"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制台 + 场景 + 自动化规则"
actions={
<>
<Button
variant="soft"
onClick={() => {
log("iot.scene", "ok");
alert("场景 / 自动化规则 CRUD(占位):后端 M10+ 提供。");
}}
>
场景与规则
</Button>
<Button
variant="primary"
onClick={() => {
log("iot.control", "ok");
alert("设备控制(占位):后端 M10+ 提供指令下发。");
}}
>
设备控制台
</Button>
</>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无 IoT 设备"
rowKey={(d) => d.id}
/>
</Surface>
<Notice>实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。</Notice>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
interface ProviderRow {
id: string;
name: string;
baseUrl: string;
model: string;
multimodal: boolean;
enabled: boolean;
}
const sample: ProviderRow[] = [
{ id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
];
export function LlmModule(): React.ReactNode {
const { data, error } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ProviderRow[]>(sample);
const backend = !isNotImplemented(error);
return (
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试 + fallback + 按角色路由 + 用量"
actions={
<Button
variant="primary"
onClick={() => {
log("llm.addProvider", "ok");
alert("新增模型(占位):后端 M10+ 提供真实持久化。");
}}
>
新增模型
</Button>
}
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{rows.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl}</span>
<span className="ml-auto flex items-center gap-4">
<Switch checked={r.enabled} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" />
<Switch checked={r.multimodal} onChange={(v) => setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, multimodal: v } : x)))} label="多模态" />
<Button size="sm" variant="ghost" onClick={() => { log("llm.test", "ok"); alert(`测试 ${r.name}(占位)`); }}>测试</Button>
</span>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
{["owner", "admin", "viewer"].map((role) => (
<div key={role} className="flex items-center gap-3 text-sm">
<Tag tone="neutral">{role}</Tag>
<span className="text-[var(--mu-text-mid)]">主 → 备 → 本地</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
</div>
))}
</Surface>
<Notice>配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。</Notice>
</div>
);
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListMemories } from "../../lib/api";
import type { MemoryRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
habitual: "ok",
project: "warn",
};
const columns: AdminColumn<MemoryRecord>[] = [
{ key: "scope", title: "作用域", render: (m) => <Tag tone={scopeTone[m.scope] ?? "neutral"}>{m.scope}</Tag> },
{ key: "text", title: "内容", render: (m) => <span className="block max-w-md truncate">{m.text}</span> },
{ key: "id", title: "ID", render: (m) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{m.id}</span> },
];
export function MemoriesModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListMemories, []);
useAutoReload(reload, 15000);
const [query, setQuery] = useState("");
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((m) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q);
});
return (
<div className="space-y-5">
<PageHeader
title="记忆管理"
subtitle="GET /api/v1/admin/memories · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
actions={
<Button
variant="soft"
onClick={() => {
log("memory.vectorTest", "ok");
alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。");
}}
>
向量测试
</Button>
}
/>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按内容 / 作用域 / ID 检索…"
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<AdminTable
columns={columns}
rows={loading ? null : filtered}
loading={loading}
error={error}
emptyTitle="无记忆数据"
rowKey={(m) => m.id}
/>
</Surface>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
</div>
);
}
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
type Tab = "logs" | "trace" | "metrics" | "audit";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "logs", label: "实时日志" },
{ id: "trace", label: "Tracing" },
{ id: "metrics", label: "指标" },
{ id: "audit", label: "审计" },
];
export function ObservabilityModule(): React.ReactNode {
const [tab, setTab] = useState<Tab>("logs");
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
const audit = useAuditStore((s) => s.records);
const clearAudit = useAuditStore((s) => s.clear);
return (
<div className="space-y-5">
<PageHeader title="可观测性" subtitle="实时日志 tail · Tracing(LLM/工具/编排) · 指标 · 审计" />
<div className="flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
tab === t.id ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "logs" ? <LogsTail lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : null}
</div>
);
}
function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">实时日志流(WS 事件回显)</h3>
<Tag tone="ok">tail</Tag>
</div>
{lines.length === 0 ? (
<Notice>暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。</Notice>
) : (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
{lines.join("\n")}
</pre>
)}
<Notice>完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。</Notice>
</Surface>
);
}
function TraceView(): React.ReactNode {
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["LLM 调用链", "工具链", "编排链"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">{x}</div>
{x} 占位 —— 后端 M10+ 提供 span/trace 数据
</div>
))}
</div>
</Surface>
);
}
function MetricsView(): React.ReactNode {
return (
<div className="space-y-4">
<CardGrid>
<StatCard icon="⚙" label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
<StatCard icon="🤖" label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
<StatCard icon="🧩" label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
<StatCard icon="🧠" label="工时/Token" value="—" sub="成本占位" tone="warn" />
</CardGrid>
<Notice>指标时序图 / 导出需后端 M10+ 提供。</Notice>
</div>
);
}
interface AuditViewProps {
records: ReturnType<typeof useAuditStore.getState>["records"];
onClear: () => void;
}
function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">审计日志(who / what / when / result)</h3>
<span className="text-xs text-[var(--mu-text-lo)]">管理 + 敏感操作全程落审计</span>
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={onClear}>
清空本地
</Button>
</span>
</div>
{records.length === 0 ? (
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--mu-glass-border)] text-left text-xs uppercase tracking-wider text-[var(--mu-text-lo)]">
<th className="px-2 py-1.5">who</th>
<th className="px-2 py-1.5">what</th>
<th className="px-2 py-1.5">when</th>
<th className="px-2 py-1.5">result</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-b border-[var(--mu-glass-border)]/60 last:border-0">
<td className="px-2 py-2 text-xs">{r.who}</td>
<td className="px-2 py-2 text-xs font-mono">{r.what}</td>
<td className="px-2 py-2 text-xs text-[var(--mu-text-lo)]">{new Date(r.when).toLocaleString()}</td>
<td className="px-2 py-2"><Tag tone={r.result === "ok" ? "ok" : "err"}>{r.result}</Tag></td>
</tr>
))}
</tbody>
</table>
)}
</Surface>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPersonas } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<PersonaRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "default",
title: "默认",
render: (p) => (p.isDefault ? <Tag tone="accent">默认</Tag> : <span className="text-[var(--mu-text-lo)]">—</span>),
},
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace}</span> },
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
];
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
useAutoReload(reload, 10000);
const [selected, setSelected] = useState<PersonaRecord | null>(null);
const log = useAuditStore((s) => s.log);
return (
<div className="space-y-5">
<PageHeader
title="人格管理"
subtitle="GET /api/v1/admin/personas · 身份/情绪曲线/好感度/模型路由/工具标记"
actions={
<Button
variant="primary"
onClick={() => {
log("persona.create", "ok");
alert("新建人格:后端 M10+ 提供,当前为占位操作。");
}}
>
新建人格
</Button>
}
/>
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无人格数据(后端未接线)"
rowKey={(p) => p.id}
/>
</Surface>
<Notice>点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。</Notice>
{selected ? (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">编辑 · {selected.name}</h3>
<span className="ml-auto">
<Button
size="sm"
variant="soft"
onClick={() => {
log("persona.edit", "ok");
alert("保存(本地占位):后端 M10+ 提供真实持久化。");
}}
>
保存(模拟)
</Button>
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<LabeledField label="ID" value={selected.id} />
<LabeledField label="记忆域" value={selected.memoryNamespace} />
<LabeledField label="角色模式" value={selected.mode} />
<LabeledField label="角色描述" value={selected.character} />
</div>
</Surface>
) : null}
</div>
);
}
function LabeledField({ label, value }: { label: string; value: string }): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
defaultValue={value}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</label>
);
}
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, getStoredRole } from "../../lib/api";
import type { AdminPluginRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
setFail(null);
adminSetPluginPolicy(name, allowInGroup, allowInStranger)
.then(() => {
log(`policy.set:${name}`, "ok");
reload();
})
.catch((err: unknown) => {
log(`policy.set:${name}`, "error");
setFail(err instanceof Error ? err.message : "策略更新失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminPluginRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "policy",
title: "环境授权(owner)",
render: (p) => (
<span className="flex items-center gap-4">
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInGroup}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
</label>
<label className="flex items-center gap-1.5">
<Switch
checked={p.allowInStranger}
disabled={!isOwner || busy === p.name}
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
/>
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
</label>
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="插件与能力"
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
actions={
<Button
variant="primary"
onClick={() => {
log("plugin.install", "ok");
alert("安装/卸载插件:后端 M10+ 提供,当前为占位操作。");
}}
>
安装插件
</Button>
}
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{fail ? <Notice tone="err">策略更新失败:{fail}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无插件数据"
rowKey={(p) => p.name}
/>
</Surface>
<Notice>工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
interface DocRow {
id: string;
name: string;
chunks: number;
embedded: boolean;
}
const docs: DocRow[] = [
{ id: "1", name: "owner_identity.md", chunks: 1, embedded: false },
{ id: "2", name: "开发时间线.md", chunks: 6, embedded: false },
{ id: "3", name: "design/10-后台管理面板.md", chunks: 2, embedded: false },
];
export function RagModule(): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[]>([]);
return (
<div className="space-y-5">
<PageHeader
title="知识库 RAG"
subtitle="文档列表(分块/嵌入状态) + 索引 + 检索测试"
actions={
<Button
variant="primary"
onClick={() => {
log("rag.upload", "ok");
alert("上传文档(占位):后端 M10+ 提供分块/嵌入。");
}}
>
上传文档
</Button>
}
/>
<Surface className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">文档列表</h3>
<span className="text-xs text-[var(--mu-text-lo)]">{docs.length} 篇</span>
</div>
{docs.map((d) => (
<div key={d.id} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm">
<span className="font-medium">{d.name}</span>
<span className="text-xs text-[var(--mu-text-lo)]">{d.chunks} 分块</span>
<span className="ml-auto">
<Tag tone={d.embedded ? "ok" : "warn"}>{d.embedded ? "已嵌入" : "待嵌入"}</Tag>
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
log("rag.delete", "ok");
alert(`删除 ${d.name}(占位)`);
}}
>
删除
</Button>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">检索测试</h3>
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="输入查询…"
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<Button
variant="primary"
onClick={() => {
log("rag.search", "ok");
setHits(query ? [`片段 1(命中 "${query}")`, `片段 2(命中 "${query}")`] : []);
}}
>
检索
</Button>
</div>
{hits.length > 0 ? (
<div className="space-y-1.5">
{hits.map((h) => (
<div key={h} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">{h}</div>
))}
</div>
) : null}
</Surface>
<Notice>分块/嵌入状态、向量索引与语义检索为后端 M10+ 提供;配置了 pgvector 增强后必须真实走语义路径(非空壳)。</Notice>
</div>
);
}
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListSessions } from "../../lib/api";
import type { SessionRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const columns: AdminColumn<SessionRecord>[] = [
{ key: "platform", title: "渠道", render: (s) => <Tag tone="accent">{s.platform}</Tag> },
{ key: "actor", title: "actor 元数据", render: (s) => <Tag tone="neutral">{s.actorRole}</Tag> },
{ key: "flags", title: "标记", render: (s) => (
<span className="flex flex-wrap gap-1">
{s.owner ? <Tag tone="ok">主人</Tag> : null}
{s.isGroup ? <Tag tone="warn">群聊</Tag> : null}
<Tag tone={s.memWritable ? "ok" : "neutral"}>{s.memWritable ? "记忆可写" : "记忆只读"}</Tag>
</span>
) },
{ key: "channel", title: "channelId", render: (s) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{s.channelId}</span> },
{ key: "session", title: "sessionId", render: (s) => <span className="font-mono text-xs">{s.sessionId}</span> },
];
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
const [query, setQuery] = useState("");
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((s) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
s.platform.toLowerCase().includes(q) ||
s.sessionId.toLowerCase().includes(q) ||
s.channelId.toLowerCase().includes(q) ||
s.actorRole.toLowerCase().includes(q)
);
});
return (
<div className="space-y-5">
<PageHeader
title="会话管理"
subtitle="GET /api/v1/admin/sessions · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("session.export", "ok");
alert("导出会话(占位):后端 M10+ 提供导出。");
}}
>
导出
</Button>
}
/>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按渠道 / sessionId / channelId / actor 过滤…"
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<AdminTable
columns={columns}
rows={loading ? null : filtered}
loading={loading}
error={error}
emptyTitle="无会话数据"
rowKey={(s) => s.sessionId}
/>
</Surface>
<Notice>会话详情 / 上下文编辑 / 删除为后端 M10+ 提供,当前仅列表只读。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from "react";
import { getStoredRole } from "../../lib/api";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [logLevel, setLogLevel] = useState("info");
const sensitive = (what: string, note: string) => {
if (!isOwner) {
alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`);
return;
}
log(`system.${what}`, "ok");
alert(`${note}(占位):后端 M10+ 提供真实执行。`);
};
return (
<div className="space-y-5">
<PageHeader title="系统设置" subtitle="环境变量 + 安全(密钥/JWT) + 存储 + 备份/恢复 + 重启/更新 + 日志级别" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-2">
<SectionTitle icon="🔐" title="安全 & 密钥" ownerOnly />
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
<KeyValueRow label="审计" value="who/what/when/result" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("rotateJwt", "轮换 JWT 密钥")}>轮换 JWT 密钥</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("revokeAllKeys", "吊销全部 API key")}>吊销全部 API key</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🗃" title="存储" />
<KeyValueRow label="默认" value="SQLite" />
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("backup", "备份数据")}>备份</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restore", "恢复数据")}>恢复</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="📦" title="环境变量" />
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
<KeyValueRow label="SERVER_PORT" value="8080" mono />
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
<div className="pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("editEnv", "编辑环境变量")}>编辑环境变量</Button>
</div>
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🔄" title="运行时" />
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">日志级别</span>
<span className="flex gap-1.5">
{["debug", "info", "warn", "error"].map((lvl) => (
<button
key={lvl}
onClick={() => setLogLevel(lvl)}
className={`rounded-[var(--mu-radius)] px-2 py-1 text-xs ${
logLevel === lvl ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{lvl}
</button>
))}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restart", "重启服务")}>重启服务</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("update", "检查/安装更新")}>检查更新</Button>
</div>
</Surface>
</div>
{!isOwner ? <Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice> : null}
</div>
);
}
function SectionTitle({ icon, title, ownerOnly = false }: { icon: string; title: string; ownerOnly?: boolean }): React.ReactNode {
return (
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span>{icon}</span>
<span>{title}</span>
{ownerOnly ? <Tag tone="warn">owner</Tag> : null}
</h3>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListUsers, getStoredRole } from "../../lib/api";
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
owner: "accent",
admin: "ok",
viewer: "neutral",
};
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
];
const initialProfile: OwnerProfile = {
id: "owner",
displayName: "叶酱",
defaultPersona: "cyrene",
identityFile: "configs/users/owner_identity.md",
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
accounts: [],
};
export function UsersModule(): React.ReactNode {
const users = useAsyncData(adminListUsers, []);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
const [sampleKey, setSampleKey] = useState<string | null>(null);
const saveProfile = () => {
log("owner.profile.edit", "ok");
setProfile({ ...profile });
};
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
actions={
<Button
variant="soft"
onClick={() => {
log("user.create", "ok");
alert("新增用户 / 角色分配(占位):后端 M10+ 提供。");
}}
>
新增用户
</Button>
}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
{users.error && !users.data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error}</Notice>
<AdminTable
columns={columns}
rows={[
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
]}
loading={false}
error={null}
rowKey={(u) => u.id}
/>
</div>
) : (
<AdminTable columns={columns} rows={users.data} loading={users.loading} error={users.error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
disabled={!isOwner}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;真实持久化 + 热重载需后端 M10+ 提供。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{sampleKey ? (
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">
{sampleKey}
</div>
) : null}
<div className="flex items-center gap-2">
<Button
variant="soft"
disabled={!isOwner}
onClick={() => {
log("apikey.generate", "ok");
const raw = new Uint8Array(24);
crypto.getRandomValues(raw);
setSampleKey(Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""));
}}
>
签发新密钥(模拟)
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">以 X-API-Key 头访问管理端点;真实签发/撤销需后端 M10+。</span>
</div>
</Surface>
</div>
);
}
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}
function Field({ label, value, onChange, disabled }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</label>
);
}