feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。

【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。

【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。

【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。

【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。

【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。

实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
This commit is contained in:
Aska committed 2026-09-06 21:54:40 +08:00
1 parent e0a543cc37
commit 6dbb4a2e18
51 files changed
+5395 -287

No files matched your search

+98 -23
View File
@@ -1,30 +1,84 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListAutomations, isNotImplemented } from "../../lib/api";
import {
adminListAutomations,
adminCreateAutomation,
adminRunAutomation,
adminDeleteAutomation,
isNotImplemented,
} from "../../lib/api";
import type { AdminAutomationRecord } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
interface ReminderRow {
id: string;
name: string;
kind: string;
cron: string;
enabled: boolean;
}
const sample: ReminderRow[] = [
const sample: AdminAutomationRecord[] = [
{ 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 { data, error, reload } = useAsyncData(adminListAutomations, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ReminderRow[]>(sample);
const backend = !isNotImplemented(error);
const rows = backend && data && data.length > 0 ? data : sample;
const [name, setName] = useState("");
const [kind, setKind] = useState("cron");
const [expr, setExpr] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const create = () => {
setBusy(true);
setMsg(null);
adminCreateAutomation({ name, kind, expression: expr })
.then((r) => {
log("automation.create", "ok");
setMsg(`已创建 ${r.name}(${r.kind})`);
setName("");
setExpr("");
reload();
})
.catch((e: unknown) => {
log("automation.create", "error");
setMsg(e instanceof Error ? e.message : "创建失败");
})
.finally(() => setBusy(false));
};
const run = (id: string, label: string) => {
setBusy(true);
setMsg(null);
adminRunAutomation(id)
.then((r) => {
log("automation.run", "ok");
setMsg(`已触发 ${label}:${JSON.stringify(r.output)}`);
})
.catch((e: unknown) => {
log("automation.run", "error");
setMsg(e instanceof Error ? e.message : "触发失败");
})
.finally(() => setBusy(false));
};
const del = (id: string, label: string) => {
setBusy(true);
setMsg(null);
adminDeleteAutomation(id)
.then(() => {
log("automation.delete", "ok");
setMsg(`已删除 ${label}`);
reload();
})
.catch((e: unknown) => {
log("automation.delete", "error");
setMsg(e instanceof Error ? e.message : "删除失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
@@ -32,33 +86,51 @@ export function AutomationsModule(): React.ReactNode {
title="自动化与提醒"
subtitle="提醒 + cron + 消息队列事件 + 后台任务(含终端任务) + 运行日志"
actions={
<Button
variant="primary"
onClick={() => {
log("automation.create", "ok");
alert("新增自动化(占位):后端 M10+ 提供 CRUD。");
}}
>
<Button variant="primary" disabled={busy || !name || !expr} onClick={create}>
新增自动化
</Button>
}
/>
{msg ? <Notice tone={msg.includes("失败") ? "err" : "ok"}>{msg}</Notice> : null}
{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>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">类型</span>
<select value={kind} onChange={(e) => setKind(e.target.value)} className={inputCls}>
<option value="cron">cron</option>
<option value="event">event</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">表达式(cron / bus subject)</span>
<input value={expr} onChange={(e) => setExpr(e.target.value)} placeholder="0 22 * * *" className={inputCls} />
</label>
</div>
{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 className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron || "—"}</span>
<span className="ml-auto flex items-center gap-2">
<Switch checked={r.enabled} disabled onChange={() => {}} label="启用" />
<Button size="sm" variant="ghost" disabled={busy} onClick={() => run(r.id, r.name)}>
触发
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => del(r.id, r.name)}>
删除
</Button>
</span>
<Button size="sm" variant="ghost" onClick={() => { log("automation.run", "ok"); alert(`手动触发 ${r.name}(占位)`); }}>触发</Button>
</div>
))}
</Surface>
@@ -77,3 +149,6 @@ export function AutomationsModule(): React.ReactNode {
</div>
);
}
const inputCls =
"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)]";
+91 -41
View File
@@ -1,52 +1,101 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListChannels } from "../../lib/api";
import type { AdminChannelRecord } from "../../lib/types";
import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest } from "../../lib/api";
import type { AdminChannelRecord, ChannelTestResult } 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 [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<string | null>(null);
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
const toggle = (name: string, connect: boolean) => {
setBusy(name);
setErr(null);
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
call
.then(() => {
log(connect ? "channel.open" : "channel.close", "ok");
reload();
})
.catch((e: unknown) => {
log(connect ? "channel.open" : "channel.close", "error");
setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
})
.finally(() => setBusy(null));
};
const test = () => {
if (!ownerId.trim()) {
setErr("邮箱/账号 id 不能为空");
return;
}
setBusy("__test__");
setErr(null);
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
.then((v) => {
log("channel.identity-test", "ok");
setVerdict(v);
})
.catch((e: unknown) => {
log("channel.identity-test", "error");
setErr(e instanceof Error ? e.message : "身份测试失败");
})
.finally(() => setBusy(null));
};
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>
),
},
{
key: "actions",
title: "操作",
render: (c) => (
<span className="flex items-center gap-2">
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
连接
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
断开
</Button>
</span>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET /api/v1/admin/channels · 类型/账号/状态/健康 · 身份识别基于 actor 元数据"
subtitle="GET/POST /api/v1/admin/channels · 连接/断开(真实生命周期) + 身份识别基于 actor 元数据 + 白名单"
actions={
<Button
variant="soft"
onClick={() => {
log("channel.reconnect", "ok");
alert("热重载/重连(占位):后端 M10+ 提供。");
}}
>
热重载
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
}
/>
{err ? <Notice tone="err">{err}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
@@ -59,11 +108,17 @@ export function ChannelsModule(): React.ReactNode {
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(占位 · 策略引擎 M10 接入)</h3>
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={platform}
onChange={(e) => setPlatform(e.target.value)}
placeholder="platform(如 weixin-ilink,可留空)"
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)]"
/>
<input
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
@@ -74,24 +129,19 @@ export function ChannelsModule(): React.ReactNode {
<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 variant="primary" disabled={busy !== null} onClick={test}>
测试
</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}
{verdict ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
<div>判定:{verdict.verdict}</div>
</div>
) : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。</Notice>
<Notice>账号配置(扫码/token)、消息流预览、推送测试由后端 M10+ 提供;此页当前为注册渠道 + 连接健康 + 结构性身份识别。</Notice>
</div>
);
}
+95 -18
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListIoT } from "../../lib/api";
import { adminListIoT, adminIoTControl, adminIoTScenario, adminIoTRule } from "../../lib/api";
import type { AdminIoTDevice } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -31,42 +32,118 @@ const columns: AdminColumn<AdminIoTDevice>[] = [
</span>
),
},
{
key: "control",
title: "控制",
render: (d) => (
<DeviceControl device={d} disabled={!d.online} />
),
},
];
function DeviceControl({ device, disabled }: { device: AdminIoTDevice; disabled: boolean }): React.ReactNode {
const [action, setAction] = useState("on");
const [params, setParams] = useState("");
const [res, setRes] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const log = useAuditStore((s) => s.log);
const send = () => {
setBusy(true);
setErr(null);
let parsed: Record<string, unknown> | undefined;
if (params.trim()) {
try {
parsed = JSON.parse(params) as Record<string, unknown>;
} catch {
setErr("参数需为合法 JSON");
setBusy(false);
return;
}
}
adminIoTControl(device.id, action.trim() || "on", parsed)
.then((d) => {
log("iot.control", "ok");
setRes(`${d.name} → ${action}:${JSON.stringify(d.state)}`);
})
.catch((e: unknown) => {
log("iot.control", "error");
setErr(e instanceof Error ? e.message : "控制失败");
})
.finally(() => setBusy(false));
};
return (
<div className="flex flex-wrap items-center gap-1.5">
<input
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="on / off / set_temperature"
className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<input
value={params}
onChange={(e) => setParams(e.target.value)}
placeholder='{"temp":24}'
className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<Button size="sm" variant="ghost" disabled={disabled || busy} onClick={send}>
控制
</Button>
{res ? <span className="text-[11px] text-[var(--mu-ok)]">{res}</span> : null}
{err ? <span className="text-[11px] text-[var(--mu-err)]">{err}</span> : null}
</div>
);
}
export function IoTModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListIoT, []);
useAutoReload(reload, 8000);
const log = useAuditStore((s) => s.log);
const [msg, setMsg] = useState<string | null>(null);
const scenario = () =>
adminIoTScenario({ name: "场景-夜灯" })
.then(() => {
log("iot.scene", "ok");
setMsg("场景保存成功");
})
.catch((e: unknown) => {
log("iot.scene", "error");
setMsg(e instanceof Error ? e.message : "场景保存失败");
});
const rule = () =>
adminIoTRule({ name: "规则-定时", expression: "0 22 * * *" })
.then(() => {
log("iot.rule", "ok");
setMsg("规则保存成功");
})
.catch((e: unknown) => {
log("iot.rule", "error");
setMsg(e instanceof Error ? e.message : "规则保存失败");
});
return (
<div className="space-y-5">
<PageHeader
title="IoT 设备"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制台 + 场景 + 自动化规则"
subtitle="GET /api/v1/admin/iot · 设备列表/实时状态 + 控制(真实下发) + 场景 + 自动化规则"
actions={
<>
<Button
variant="soft"
onClick={() => {
log("iot.scene", "ok");
alert("场景 / 自动化规则 CRUD(占位):后端 M10+ 提供。");
}}
>
<Button variant="soft" onClick={scenario}>
场景与规则
</Button>
<Button
variant="primary"
onClick={() => {
log("iot.control", "ok");
alert("设备控制(占位):后端 M10+ 提供指令下发。");
}}
>
设备控制台
<Button variant="primary" onClick={rule}>
新增规则
</Button>
</>
}
/>
{msg ? <Notice tone="err">{msg}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
columns={columns}
@@ -78,7 +155,7 @@ export function IoTModule(): React.ReactNode {
/>
</Surface>
<Notice>实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。</Notice>
<Notice>设备控制真实下发到 IoT provider(在线设备可操作);场景/规则存储由后端 M10 提供,当前返回明确待实现。</Notice>
</div>
);
}
+93 -30
View File
@@ -1,43 +1,77 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
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 },
];
const sample: AdminLlmConfig = {
providers: [
{ 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 { data, error, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const [rows, setRows] = useState<ProviderRow[]>(sample);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
const [name, setName] = useState("");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [multimodal, setMultimodal] = useState(false);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const addProvider = () => {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ name, model, baseUrl, multimodal })
.then(({ provider }) => {
log("llm.addProvider", "ok");
setResult(`已记录 ${provider.name}(进程生命周期,未绑定模型路由)`);
setName("");
setModel("");
setBaseUrl("");
setMultimodal(false);
reload();
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
setFail(e instanceof Error ? e.message : "新增模型失败");
})
.finally(() => setBusy(false));
};
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
setResult(null);
const providerId = p.id === "anthropic" ? "anthropic" : "openai";
adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model })
.then((r) => {
log("llm.test", "ok");
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
})
.catch((e: unknown) => {
log("llm.test", "error");
setFail(e instanceof Error ? e.message : "模型测试失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试 + fallback + 按角色路由 + 用量"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button
variant="primary"
onClick={() => {
log("llm.addProvider", "ok");
alert("新增模型(占位):后端 M10+ 提供真实持久化。");
}}
>
<Button variant="primary" disabled={busy || !name || !model} onClick={addProvider}>
新增模型
</Button>
}
@@ -46,23 +80,49 @@ export function LlmModule(): React.ReactNode {
{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}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{rows.map((r) => (
{providers.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="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>
<Tag tone={r.enabled ? "ok" : "neutral"}>{r.enabled ? "启用" : "停用"}</Tag>
<Tag tone={r.multimodal ? "accent" : "neutral"}>{r.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy || !r.baseUrl} onClick={() => test(r)}>
测试
</Button>
</span>
</div>
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(进程生命周期,未绑定路由)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="flex items-center gap-2">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
<span className="text-xs text-[var(--mu-text-lo)]">API key 不入库、不进 wire、不泄露;测试端点每次用请求内的 key 直接调用。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
{["owner", "admin", "viewer"].map((role) => (
@@ -74,7 +134,10 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;新增模型记录为进程生命周期临时(不持久化/config 不变)。</Notice>
</div>
);
}
const inputCls =
"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)]";
+76 -25
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListMemories } from "../../lib/api";
import type { MemoryRecord } from "../../lib/types";
import { adminListMemories, adminMemorySearch } from "../../lib/api";
import type { MemoryRecord, MemorySearchHit } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -13,16 +13,26 @@ const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
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> },
];
function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; loading: boolean; error: string | null }): React.ReactNode {
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> },
];
return (
<AdminTable columns={columns} rows={rows} loading={loading} error={error} emptyTitle="无记忆数据" rowKey={(m) => m.id} />
);
}
export function MemoriesModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListMemories, []);
useAutoReload(reload, 15000);
const [query, setQuery] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [personaId, setPersonaId] = useState("cyrene");
const [hits, setHits] = useState<MemorySearchHit[] | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((m) => {
@@ -31,42 +41,83 @@ export function MemoriesModule(): React.ReactNode {
return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q);
});
const search = () => {
if (!searchQuery.trim()) {
setFail("测试查询不能为空");
return;
}
setBusy(true);
setFail(null);
adminMemorySearch({ query: searchQuery.trim(), personaId: personaId.trim() || "cyrene" })
.then((r) => {
log("memory.vectorTest", "ok");
setHits(r.hits);
})
.catch((e: unknown) => {
log("memory.vectorTest", "error");
setFail(e instanceof Error ? e.message : "向量测试失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="记忆管理"
subtitle="GET /api/v1/admin/memories · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
subtitle="GET /api/v1/admin/memories + POST /memories/search · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
actions={
<Button
variant="soft"
onClick={() => {
log("memory.vectorTest", "ok");
alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。");
}}
>
<Button variant="soft" disabled={busy} onClick={search}>
向量测试
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">语义/关键词检索测试(真实 retrieval)</h3>
<div className="flex flex-wrap items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(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)]"
/>
<input
value={personaId}
onChange={(e) => setPersonaId(e.target.value)}
placeholder="personaId"
className="w-32 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)]"
/>
</div>
{hits ? (
<div className="space-y-1">
{hits.length === 0 ? (
<span className="text-xs text-[var(--mu-text-lo)]">无命中</span>
) : (
hits.map((h) => (
<div key={h.id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs">
<Tag tone="neutral">{h.scope}</Tag> <span className="ml-1 font-mono text-[var(--mu-text-lo)]">{h.score.toFixed(3)}</span>
<div className="mt-1 text-[var(--mu-text-mid)]">{h.text}</div>
</div>
))
)}
<span className="text-[11px] text-[var(--mu-text-lo)]">当前为 keyword×decay 路径;语义/向量路径需主会话注入 vector index(wire.go 装配)。</span>
</div>
) : null}
</Surface>
<Surface className="space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="按内容 / 作用域 / ID 检索…"
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}
/>
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
</Surface>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
<Notice>编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。</Notice>
</div>
);
}
+156 -32
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPersonas } from "../../lib/api";
import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api";
import type { PersonaRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -14,30 +14,103 @@ const columns: AdminColumn<PersonaRecord>[] = [
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: "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> },
];
interface Draft {
id: string;
name: string;
role: string;
systemPrompt: string;
mode: string;
memoryNamespace: string;
}
const emptyDraft: Draft = { id: "", name: "", role: "", systemPrompt: "", mode: "", memoryNamespace: "" };
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
useAutoReload(reload, 10000);
const [selected, setSelected] = useState<PersonaRecord | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const startCreate = () => {
setDraft(emptyDraft);
setCreating(true);
setSelected(null);
setFail(null);
};
const editDraftFrom = (p: PersonaRecord): Draft => ({
id: p.id,
name: p.name,
role: p.character || "",
systemPrompt: "",
mode: p.mode || "",
memoryNamespace: p.memoryNamespace || "",
});
const create = () => {
setBusy(true);
setFail(null);
adminCreatePersona({
id: draft.id,
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
.then((p) => {
log("persona.create", "ok");
setCreating(false);
setDraft(emptyDraft);
setSelected(p);
reload();
})
.catch((err: unknown) => {
log("persona.create", "error");
setFail(err instanceof Error ? err.message : "创建人格失败");
})
.finally(() => setBusy(false));
};
const save = () => {
if (!selected) return;
setBusy(true);
setFail(null);
adminUpdatePersona(selected.id, {
name: draft.name,
role: draft.role,
systemPrompt: draft.systemPrompt,
mode: draft.mode,
memoryNamespace: draft.memoryNamespace,
})
.then((p) => {
log("persona.edit", "ok");
setSelected(p);
reload();
})
.catch((err: unknown) => {
log("persona.edit", "error");
setFail(err instanceof Error ? err.message : "保存人格失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="人格管理"
subtitle="GET /api/v1/admin/personas · 身份/情绪曲线/好感度/模型路由/工具标记"
subtitle="GET/POST/PUT /api/v1/admin/personas · 新建/编辑真实持久化到 personas 存储/YAML"
actions={
<Button
variant="primary"
onClick={() => {
log("persona.create", "ok");
alert("新建人格:后端 M10+ 提供,当前为占位操作。");
}}
>
<Button variant="primary" onClick={startCreate}>
新建人格
</Button>
}
@@ -49,35 +122,77 @@ export function PersonasModule(): React.ReactNode {
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="无人格数据(后端未接线)"
emptyTitle="无人格数据"
rowKey={(p) => p.id}
onRowClick={(p) => {
setSelected(p);
setDraft(editDraftFrom(p));
setCreating(false);
setFail(null);
}}
/>
</Surface>
<Notice>点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。</Notice>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{selected ? (
{creating ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新建人格</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="ID *" value={draft.id} onChange={(v) => setDraft({ ...draft, id: v })} />
<Field label="名称 *" value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} />
<Field label="人设 role *" value={draft.role} onChange={(v) => setDraft({ ...draft, role: v })} />
<Field label="行为模式 mode" value={draft.mode} onChange={(v) => setDraft({ ...draft, mode: v })} />
<Field label="记忆域 memoryNamespace" value={draft.memoryNamespace} onChange={(v) => setDraft({ ...draft, memoryNamespace: v })} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">身份说systemPrompt(可留空,自动生成)</span>
<textarea
value={draft.systemPrompt}
onChange={(e) => setDraft({ ...draft, systemPrompt: e.target.value })}
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)]"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.id || !draft.name || !draft.role} onClick={create}>
{busy ? "创建中…" : "创建"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setCreating(false)}>
取消
</Button>
</div>
</Surface>
) : null}
{selected && !creating ? (
<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>
<span className="ml-auto font-mono text-xs text-[var(--mu-text-lo)]">{selected.id}</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} />
<Field label="名称 *" value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} />
<Field label="人设 role *" value={draft.role} onChange={(v) => setDraft({ ...draft, role: v })} />
<Field label="行为模式 mode" value={draft.mode} onChange={(v) => setDraft({ ...draft, mode: v })} />
<Field label="记忆域 memoryNamespace" value={draft.memoryNamespace} onChange={(v) => setDraft({ ...draft, memoryNamespace: v })} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">身份说 systemPrompt(留空则自动生成)</span>
<textarea
value={draft.systemPrompt}
onChange={(e) => setDraft({ ...draft, systemPrompt: e.target.value })}
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)]"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !draft.name || !draft.role} onClick={save}>
{busy ? "保存中…" : "保存"}
</Button>
<Button variant="ghost" disabled={busy} onClick={() => setSelected(null)}>
关闭
</Button>
</div>
</Surface>
) : null}
@@ -85,12 +200,21 @@ export function PersonasModule(): React.ReactNode {
);
}
function LabeledField({ label, value }: { label: string; value: string }): React.ReactNode {
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
defaultValue={value}
value={value}
onChange={(e) => onChange(e.target.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>
+48 -9
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, getStoredRole } from "../../lib/api";
import { adminListPlugins, adminSetPluginPolicy, adminPluginInstall, adminPluginUninstall, getStoredRole } from "../../lib/api";
import type { AdminPluginRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
@@ -14,6 +14,41 @@ export function PluginsModule(): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const install = () => {
setBusy("__install__");
setFail(null);
setMsg(null);
adminPluginInstall({ name: "sample-plugin", kind: "tool", description: "手工安装示例" })
.then(() => {
log("plugin.install", "ok");
setMsg("插件安装成功");
reload();
})
.catch((err: unknown) => {
log("plugin.install", "error");
setFail(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setBusy(null));
};
const uninstall = (name: string) => {
setBusy(name);
setFail(null);
setMsg(null);
adminPluginUninstall({ name })
.then(() => {
log("plugin.uninstall", "ok");
setMsg(`已卸载 ${name}`);
reload();
})
.catch((err: unknown) => {
log("plugin.uninstall", "error");
setFail(err instanceof Error ? err.message : "卸载失败");
})
.finally(() => setBusy(null));
};
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
@@ -59,6 +94,15 @@ export function PluginsModule(): React.ReactNode {
</span>
),
},
{
key: "actions",
title: "操作",
render: (p) => (
<Button size="sm" variant="ghost" disabled={!isOwner || busy !== null} onClick={() => uninstall(p.name)}>
卸载
</Button>
),
},
];
return (
@@ -67,20 +111,15 @@ export function PluginsModule(): React.ReactNode {
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 variant="primary" disabled={busy !== null} onClick={install}>
安装插件
</Button>
}
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{fail ? <Notice tone="err">策略更新失败:{fail}</Notice> : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{msg ? <Notice tone="ok">{msg}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
+60 -24
View File
@@ -1,30 +1,28 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListSessions } from "../../lib/api";
import { adminListSessions, adminExportSession } 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> },
];
function exportBlob(sess: SessionRecord): void {
const blob = new Blob([JSON.stringify(sess, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${sess.sessionId}.json`;
a.click();
URL.revokeObjectURL(url);
}
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const filtered = (data ?? []).filter((s) => {
@@ -38,24 +36,62 @@ export function SessionsModule(): React.ReactNode {
);
});
const exportSession = (s: SessionRecord) => {
setBusy(s.sessionId);
setFail(null);
adminExportSession(s.sessionId)
.then(({ session }) => {
log("session.export", "ok");
exportBlob(session);
})
.catch((e: unknown) => {
log("session.export", "error");
setFail(e instanceof Error ? e.message : "导出失败");
})
.finally(() => setBusy(null));
};
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> },
{
key: "actions",
title: "操作",
render: (s) => (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportSession(s)}>
导出
</Button>
),
},
];
return (
<div className="space-y-5">
<PageHeader
title="会话管理"
subtitle="GET /api/v1/admin/sessions · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
subtitle="GET /api/v1/admin/sessions + GET /sessions/{id}/export · 渠道/模式/角色/分页 · 身份识别靠 channel 层 actor 元数据"
actions={
<Button
variant="soft"
onClick={() => {
log("session.export", "ok");
alert("导出会话(占位):后端 M10+ 提供导出。");
}}
>
导出
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<input
value={query}
@@ -73,7 +109,7 @@ export function SessionsModule(): React.ReactNode {
/>
</Surface>
<Notice>会话详情 / 上下文编辑 / 删除为后端 M10+ 提供,当前仅列表只读。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
<Notice>导出当前为会话身份元数据 JSON(完整会话消息存储由后端 M10+ 提供)。身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。</Notice>
</div>
);
}
+100 -36
View File
@@ -1,6 +1,12 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListUsers, getStoredRole } from "../../lib/api";
import {
adminListUsers,
adminCreateUser,
adminIssueApiKey,
adminRevokeApiKey,
getStoredRole,
} from "../../lib/api";
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -12,13 +18,6 @@ const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
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: "叶酱",
@@ -29,41 +28,105 @@ const initialProfile: OwnerProfile = {
};
export function UsersModule(): React.ReactNode {
const users = useAsyncData(adminListUsers, []);
const { data, error, loading, reload } = 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 [issuedKey, setIssuedKey] = useState<string | null>(null);
const [role, setRole] = useState<string>("owner");
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const saveProfile = () => {
log("owner.profile.edit", "ok");
setProfile({ ...profile });
};
const createUser = () => {
setBusy(true);
setFail(null);
adminCreateUser({ username: "new-user", role: "viewer" })
.then(() => {
log("user.create", "ok");
reload();
})
.catch((err: unknown) => {
log("user.create", "error");
setFail(err instanceof Error ? err.message : "新增用户失败");
})
.finally(() => setBusy(false));
};
const issueKey = () => {
setBusy(true);
setFail(null);
adminIssueApiKey(role)
.then((rg) => {
log("apikey.generate", "ok");
setIssuedKey(rg.key);
reload();
})
.catch((err: unknown) => {
log("apikey.generate", "error");
setFail(err instanceof Error ? err.message : "签发密钥失败");
})
.finally(() => setBusy(false));
};
const revokeKey = (id: string) => {
setBusy(true);
setFail(null);
adminRevokeApiKey(id)
.then(() => {
log("apikey.revoke", "ok");
reload();
})
.catch((err: unknown) => {
log("apikey.revoke", "error");
setFail(err instanceof Error ? err.message : "撤销密钥失败");
})
.finally(() => setBusy(false));
};
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> },
{
key: "actions",
title: "操作",
render: (u) =>
u.username === "apikey" && isOwner ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => revokeKey(u.id)}>
撤销
</Button>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
];
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 variant="soft" disabled={!isOwner || busy} onClick={createUser}>
新增用户
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
{users.error && !users.data ? (
{error && !data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error}</Notice>
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error}</Notice>
<AdminTable
columns={columns}
rows={[
@@ -77,7 +140,7 @@ export function UsersModule(): React.ReactNode {
/>
</div>
) : (
<AdminTable columns={columns} rows={users.data} loading={users.loading} error={users.error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
<AdminTable columns={columns} rows={data} loading={loading} error={error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
@@ -106,31 +169,32 @@ export function UsersModule(): React.ReactNode {
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;真实持久化 + 热重载需后端 M10+ 提供。</span>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;owner.yaml 持久化/热重载(编辑)由后端 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}
{issuedKey ? (
<div className="space-y-1">
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
</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(""));
}}
<div className="flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="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)]"
>
签发新密钥(模拟)
<option value="owner">owner</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
<Button variant="soft" disabled={!isOwner || busy} onClick={issueKey}>
签发新密钥
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">以 X-API-Key 头访问管理端点;真实签发/撤销需后端 M10+。</span>
<span className="text-xs text-[var(--mu-text-lo)]">行内「撤销」真实移除该密钥。</span>
</div>
</Surface>
</div>