feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【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:
1 parent
e0a543cc37
commit
6dbb4a2e18
51 files changed
+5395
-287
No files matched your search
@@ -75,6 +75,7 @@ export interface AdminTableProps<T> {
|
||||
error: string | null;
|
||||
emptyTitle?: string;
|
||||
rowKey: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
/** A strict, generic data table. Loading/error/empty states are handled here so
|
||||
@@ -86,6 +87,7 @@ export function AdminTable<T>({
|
||||
error,
|
||||
emptyTitle = "暂无数据",
|
||||
rowKey,
|
||||
onRowClick,
|
||||
}: AdminTableProps<T>): ReactNode {
|
||||
if (loading) {
|
||||
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
|
||||
@@ -113,7 +115,11 @@ export function AdminTable<T>({
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={rowKey(row)} className="border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60">
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={`px-3 py-2.5 align-top ${c.className ?? ""}`}>
|
||||
{c.render(row)}
|
||||
|
||||
@@ -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)]";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)]";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import type { Mode, WsStatus } from "../lib/types";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { AwakenBadge } from "./status";
|
||||
|
||||
const MODE_LABEL: Record<Mode, string> = {
|
||||
chat: "对话", code: "代码", home: "家居", work: "工作", docs: "知识库",
|
||||
@@ -56,6 +57,8 @@ export function TopBar(): React.ReactNode {
|
||||
|
||||
{mood ? <Tag tone="accent">心情 · {mood}</Tag> : null}
|
||||
|
||||
<AwakenBadge />
|
||||
|
||||
<Tag tone={meta.tone}>
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
|
||||
{meta.label}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { WakePath } from "./status";
|
||||
import { modeBody } from "./messages";
|
||||
|
||||
/** Collapsible inline chain-of-thought block. */
|
||||
@@ -76,12 +77,18 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
||||
* message's own `mode`: the same role renders differently under different modes,
|
||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||
* (legacy persisted messages). */
|
||||
export function MessageBubble({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
active = false,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
active?: boolean;
|
||||
}): React.ReactNode {
|
||||
const mode = message.mode ?? "chat";
|
||||
if (message.role === "user") {
|
||||
return <UserBubble message={message} mode={mode} />;
|
||||
}
|
||||
return <AssistantBubble message={message} mode={mode} />;
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
||||
@@ -111,7 +118,15 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
|
||||
/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
|
||||
* mode-specific body (chat / code / docs / home / work / vision …). */
|
||||
function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||
function AssistantBubble({
|
||||
message,
|
||||
mode,
|
||||
active = false,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
mode: Mode;
|
||||
active?: boolean;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
|
||||
@@ -120,6 +135,7 @@ function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }
|
||||
{message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||||
) : null}
|
||||
<WakePath active={active} />
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
{modeBody(mode, message)}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { Tag, type TagTone } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import type { AwakenState } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Miuix 风格的状态呈现组件(用户端):唤醒式能力三态 + 会话编排 busy/pending。
|
||||
* 全部从 useAppStore 的实时状态派生,纯展示、无副作用;未收到对应 WS 事件时
|
||||
* 保持默认值(静息 / 不忙),UI 不报错。design/14 §5 + 15 §5 + 07 §3.
|
||||
*/
|
||||
|
||||
/** 三态排序权重:静息 → 唤醒 → 全能(用于判断「唤醒上升」)。 */
|
||||
const AWAKEN_RANK: Record<AwakenState, number> = { quiet: 0, awake: 1, full: 2 };
|
||||
const AWAKEN_ORDER: AwakenState[] = ["quiet", "awake", "full"];
|
||||
|
||||
const AWAKEN_META: Record<
|
||||
AwakenState,
|
||||
{ label: string; icon: IconName; tone: TagTone; desc: string }
|
||||
> = {
|
||||
quiet: { label: "静息", icon: "moon", tone: "neutral", desc: "温柔陪伴" },
|
||||
awake: { label: "唤醒", icon: "light", tone: "warn", desc: "办事中" },
|
||||
full: { label: "全能", icon: "gear", tone: "accent", desc: "复杂任务" },
|
||||
};
|
||||
|
||||
/** 顶部/状态栏的轻量状态徽标:显示当前唤醒态 + 最近点亮工具;唤醒上升时短暂脉动。 */
|
||||
export function AwakenBadge(): React.ReactNode {
|
||||
const state = useAppStore((s) => s.awakenState);
|
||||
const from = useAppStore((s) => s.awakenFrom);
|
||||
const tool = useAppStore((s) => s.awakenTool);
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevRef = useRef<AwakenState>(state);
|
||||
|
||||
// 唤醒上升(从较低态升到较高态)时短暂发光一下,提示「她认真起来了」。
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current;
|
||||
if (from !== null && AWAKEN_RANK[state] > AWAKEN_RANK[prev]) {
|
||||
setPulse(true);
|
||||
const t = window.setTimeout(() => setPulse(false), 1200);
|
||||
prevRef.current = state;
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
prevRef.current = state;
|
||||
}, [state, from]);
|
||||
|
||||
const meta = AWAKEN_META[state];
|
||||
return (
|
||||
<Tag tone={meta.tone} className={pulse ? "mu-awaken" : ""}>
|
||||
<Icon name={meta.icon} size={12} />
|
||||
{meta.label}
|
||||
{tool ? <span className="text-[var(--mu-text-lo)]">· {tool}</span> : null}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
/** 聊天流里的「唤醒路径」节点序列(思维导图轻量版):静息 → 唤醒 → 全能,点亮到当前态;
|
||||
* 本轮点亮的工具以 ok 标签展示。仅在活跃(正在流式)消息气泡上渲染。 */
|
||||
export function WakePath({
|
||||
active = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
}): React.ReactNode | null {
|
||||
const state = useAppStore((s) => s.awakenState);
|
||||
const from = useAppStore((s) => s.awakenFrom);
|
||||
const tool = useAppStore((s) => s.awakenTool);
|
||||
if (!active) return null;
|
||||
|
||||
const currentIdx = AWAKEN_RANK[state];
|
||||
const ascended = from !== null && AWAKEN_RANK[state] > AWAKEN_RANK[from];
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60 px-3 py-1.5">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">唤醒路径</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{AWAKEN_ORDER.map((key, i) => {
|
||||
const on = AWAKEN_RANK[key] <= currentIdx;
|
||||
const isCurrent = key === state;
|
||||
return (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 ? (
|
||||
<Icon
|
||||
name="arrow"
|
||||
size={10}
|
||||
className={on ? "text-[var(--mu-text-mid)]" : "text-[var(--mu-text-disabled)]"}
|
||||
/>
|
||||
) : null}
|
||||
<Tag
|
||||
tone={on && isCurrent ? AWAKEN_META[key].tone : "neutral"}
|
||||
className={`${on ? "" : "opacity-60"} ${on && isCurrent && ascended ? "mu-awaken" : ""}`}
|
||||
>
|
||||
<Icon name={AWAKEN_META[key].icon} size={11} />
|
||||
{AWAKEN_META[key].label}
|
||||
</Tag>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ascended ? (
|
||||
<span className="text-xs text-[var(--mu-accent-hi)]">她认真起来了</span>
|
||||
) : null}
|
||||
{tool ? (
|
||||
<Tag tone="ok">
|
||||
<Icon name="check" size={11} />
|
||||
点亮 · {tool}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话编排 busy/pending 轻状态提醒(design/15 §5)。她忙时显示在聊天区顶部;
|
||||
* **不阻塞输入框**(输入框可继续打字),挂起消息数可见。 */
|
||||
export function SessionStatusHint(): React.ReactNode | null {
|
||||
const busy = useAppStore((s) => s.sessionBusy);
|
||||
const pendingCount = useAppStore((s) => s.pendingCount);
|
||||
if (!busy) return null;
|
||||
|
||||
return (
|
||||
<div className="mu-rise-in flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/70 px-3 py-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Icon name="clock" size={14} className="shrink-0 text-[var(--mu-warn)]" />
|
||||
<span className="min-w-0 truncate">她正在忙,有新消息稍后回</span>
|
||||
{pendingCount > 0 ? <Tag tone="warn">{pendingCount} 条待回</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AdminChannelRecord,
|
||||
AdminIoTDevice,
|
||||
AdminLlmConfig,
|
||||
AdminLlmProvider,
|
||||
AdminObservabilityMetrics,
|
||||
AdminPluginRecord,
|
||||
AdminRagHit,
|
||||
@@ -10,12 +11,21 @@ import type {
|
||||
AdminTraceSpan,
|
||||
AdminUserRecord,
|
||||
ApiErrorShape,
|
||||
ApiKeyIssueResult,
|
||||
AuditRecord,
|
||||
AutomationCreateResult,
|
||||
AutomationRunResult,
|
||||
ChannelTestResult,
|
||||
ChatSyncResult,
|
||||
LlmTestRequest,
|
||||
LlmTestResult,
|
||||
LoginResult,
|
||||
MemoryRecord,
|
||||
MemorySearchRequest,
|
||||
MemorySearchResult,
|
||||
Mode,
|
||||
PersonaRecord,
|
||||
PersonaUpsertRequest,
|
||||
Role,
|
||||
SessionRecord,
|
||||
} from "./types";
|
||||
@@ -273,6 +283,137 @@ export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */
|
||||
|
||||
/** POST /api/v1/admin/personas — registers a persona (persisted to YAML + registry). */
|
||||
export function adminCreatePersona(req: PersonaUpsertRequest): Promise<PersonaRecord> {
|
||||
return request<PersonaRecord>("/admin/personas", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** PUT /api/v1/admin/personas/{id} — edits an existing persona. */
|
||||
export function adminUpdatePersona(id: string, req: PersonaUpsertRequest): Promise<PersonaRecord> {
|
||||
return request<PersonaRecord>(`/admin/personas/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/users — add user/role. No persistent user store yet, so the
|
||||
* backend answers honestly with a 501 ("M10"). */
|
||||
export function adminCreateUser(req: { username: string; role: string }): Promise<AdminUserRecord> {
|
||||
return request<AdminUserRecord>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once. */
|
||||
export function adminIssueApiKey(role: string): Promise<ApiKeyIssueResult> {
|
||||
return request<ApiKeyIssueResult>("/admin/users/apikey", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
}
|
||||
/** DELETE /api/v1/admin/users/apikey/{id} — revokes a key by masked id. */
|
||||
export function adminRevokeApiKey(id: string): Promise<{ id: string; revoked: boolean }> {
|
||||
return request<{ id: string; revoked: boolean }>(`/admin/users/apikey/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/iot/{id}/control — sends a control instruction to a device. */
|
||||
export function adminIoTControl(id: string, action: string, params?: Record<string, unknown>): Promise<AdminIoTDevice> {
|
||||
return request<AdminIoTDevice>(`/admin/iot/${encodeURIComponent(id)}/control`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action, params }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/iot/scenarios — honest 501 (no scenario store yet). */
|
||||
export function adminIoTScenario(req: { name: string }): Promise<{ name: string }> {
|
||||
return request<{ name: string }>("/admin/iot/scenarios", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/iot/rules — honest 501 (no IoT rule store yet). */
|
||||
export function adminIoTRule(req: { name: string; expression: string }): Promise<{ name: string }> {
|
||||
return request<{ name: string }>("/admin/iot/rules", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/automations — registers a real cron/event automation. */
|
||||
export function adminCreateAutomation(req: {
|
||||
name: string;
|
||||
kind: string;
|
||||
expression: string;
|
||||
}): Promise<AutomationCreateResult> {
|
||||
return request<AutomationCreateResult>("/admin/automations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/automations/{id}/run — manually triggers a rule. */
|
||||
export function adminRunAutomation(id: string): Promise<AutomationRunResult> {
|
||||
return request<AutomationRunResult>(`/admin/automations/${encodeURIComponent(id)}/run`, { method: "POST" });
|
||||
}
|
||||
/** DELETE /api/v1/admin/automations/{id} — removes a rule (cron or event). */
|
||||
export function adminDeleteAutomation(id: string): Promise<{ id: string; deleted: boolean }> {
|
||||
return request<{ id: string; deleted: boolean }>(`/admin/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
}
|
||||
/** POST /api/v1/admin/llm — records a provider for the process lifetime (temporary). */
|
||||
export function adminAddLlmProvider(req: {
|
||||
id?: string;
|
||||
name: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
multimodal?: boolean;
|
||||
fallback?: string;
|
||||
}): Promise<{ provider: AdminLlmProvider; temporary: boolean }> {
|
||||
return request<{ provider: AdminLlmProvider; temporary: boolean }>("/admin/llm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/llm/test — performs a real model call and reports the result. */
|
||||
export function adminTestLlm(req: LlmTestRequest): Promise<LlmTestResult> {
|
||||
return request<LlmTestResult>("/admin/llm/test", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/channels/open — connects a registered channel adapter. */
|
||||
export function adminChannelOpen(name: string): Promise<{ name: string; connected: boolean }> {
|
||||
return request<{ name: string; connected: boolean }>("/admin/channels/open", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */
|
||||
export function adminChannelClose(name: string): Promise<{ name: string; connected: boolean }> {
|
||||
return request<{ name: string; connected: boolean }>("/admin/channels/close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */
|
||||
export function adminChannelTest(req: {
|
||||
platform: string;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
}): Promise<ChannelTestResult> {
|
||||
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
||||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
||||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
|
||||
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
|
||||
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
|
||||
export function adminMemorySearch(req: MemorySearchRequest): Promise<MemorySearchResult> {
|
||||
return request<MemorySearchResult>("/admin/memories/search", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** GET /api/v1/admin/sessions/{id}/export — exports a session as JSON. */
|
||||
export function adminExportSession(id: string): Promise<{ session: SessionRecord }> {
|
||||
return request<{ session: SessionRecord }>(`/admin/sessions/${encodeURIComponent(id)}/export`);
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
|
||||
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
|
||||
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
|
||||
|
||||
@@ -40,6 +40,23 @@ export interface ToolUsed {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */
|
||||
export type AwakenState = "quiet" | "awake" | "full";
|
||||
|
||||
/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。
|
||||
* `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */
|
||||
export interface AwakenPayload {
|
||||
fromState?: AwakenState;
|
||||
toState?: AwakenState;
|
||||
tool?: string | { name: string };
|
||||
}
|
||||
|
||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
|
||||
export interface SessionStatePayload {
|
||||
busy?: boolean;
|
||||
pendingCount?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
@@ -153,7 +170,8 @@ export interface ChatSyncResult {
|
||||
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
|
||||
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
|
||||
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
|
||||
* error / task_output.
|
||||
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
|
||||
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
|
||||
*/
|
||||
export interface WsDownEvent {
|
||||
type: string;
|
||||
@@ -333,3 +351,112 @@ export interface OwnerAccount {
|
||||
platform: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*).
|
||||
* The backend persists to a real store or answers honestly with a 501 / clear
|
||||
* error — never a fabricated success.
|
||||
* -------------------------------------------------------------------------*/
|
||||
|
||||
/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */
|
||||
export interface PersonaUpsertRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
characterRef?: string;
|
||||
role: string;
|
||||
systemPrompt?: string;
|
||||
personality?: string;
|
||||
speechStyle?: string;
|
||||
catchphrase?: string;
|
||||
background?: string;
|
||||
values?: string[];
|
||||
modes?: string[];
|
||||
mode?: string;
|
||||
memoryNamespace?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
|
||||
export interface ApiKeyIssueResult {
|
||||
id: string;
|
||||
key: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/automations result. */
|
||||
export interface AutomationCreateResult {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/automations/{id}/run result. */
|
||||
export interface AutomationRunResult {
|
||||
id: string;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm body. */
|
||||
export interface LlmProviderRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
multimodal?: boolean;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/test body. */
|
||||
export interface LlmTestRequest {
|
||||
provider?: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/test result. */
|
||||
export interface LlmTestResult {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
reply: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
|
||||
export interface ChannelTestResult {
|
||||
role: string;
|
||||
owner: boolean;
|
||||
isGroup: boolean;
|
||||
env: string;
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */
|
||||
export interface IoTControlRequest {
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories/search body. */
|
||||
export interface MemorySearchRequest {
|
||||
query: string;
|
||||
personaId?: string;
|
||||
topK?: number;
|
||||
}
|
||||
|
||||
/** One memory retrieval hit. */
|
||||
export interface MemorySearchHit {
|
||||
id: string;
|
||||
scope: string;
|
||||
text: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/memories/search result. */
|
||||
export interface MemorySearchResult {
|
||||
query: string;
|
||||
personaId: string;
|
||||
semantic: boolean;
|
||||
hits: MemorySearchHit[];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getAuthToken } from "./api";
|
||||
import { MODES, type AgentInfo, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
|
||||
import { MODES, type AgentInfo, type AwakenState, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/**
|
||||
@@ -78,6 +78,15 @@ function parseTool(v: unknown): ToolUsed | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
|
||||
* may send a bare name or a `{name}` object; both reduce to the name. */
|
||||
function parseAwakenTool(v: unknown): string | undefined {
|
||||
if (typeof v === "string") return v || undefined;
|
||||
const r = toRecord(v);
|
||||
if (r && typeof r.name === "string" && r.name) return r.name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
|
||||
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
|
||||
* so we label the card by id when no name is present. */
|
||||
@@ -158,6 +167,10 @@ function dispatch(ev: { type: string; data: unknown }): void {
|
||||
}
|
||||
case "stream_end": {
|
||||
s.endStream();
|
||||
// The assistant reply stopped streaming, so the immediate turn is over:
|
||||
// close the busy/pending window so the badge does not stick. If the backend
|
||||
// keeps a task/subagent running it re-emits session_busy (design/15 §5).
|
||||
s.setSessionBusy(false, 0);
|
||||
if (r && typeof r.stopReason === "string") {
|
||||
s.setContext({ lastAgentMode: s.context.lastAgentMode });
|
||||
}
|
||||
@@ -293,6 +306,27 @@ function dispatch(ev: { type: string; data: unknown }): void {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "capacity_awaken": {
|
||||
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
|
||||
const fromState =
|
||||
typeof r?.fromState === "string"
|
||||
? (r.fromState as AwakenState)
|
||||
: undefined;
|
||||
const toState =
|
||||
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
|
||||
"quiet";
|
||||
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
|
||||
break;
|
||||
}
|
||||
case "session_busy":
|
||||
case "session_pending": {
|
||||
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
|
||||
const busy = typeof r?.busy === "boolean" ? r.busy : true;
|
||||
const pendingCount =
|
||||
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
|
||||
s.setSessionBusy(busy, pendingCount);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unknown future event: keep it in the context log so no frame is lost.
|
||||
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { useSendChat } from "../lib/useChat";
|
||||
@@ -7,6 +7,7 @@ import { MessageBubble } from "../components/chat";
|
||||
import { ChatInput } from "../components/ChatInput";
|
||||
import { EmptyState } from "../components/primitives";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { SessionStatusHint } from "../components/status";
|
||||
|
||||
/**
|
||||
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
||||
@@ -19,6 +20,16 @@ export function ChatShell(): React.ReactNode {
|
||||
const { send, sending } = useSendChat();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The latest assistant message is the one being streamed right now; only it
|
||||
// carries the live 唤醒路径 node sequence (WakePath), so the capacity state
|
||||
// reflects the current turn rather than a collapsed historical message.
|
||||
const lastAssistantId = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") return messages[i].id;
|
||||
}
|
||||
return undefined;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages]);
|
||||
@@ -27,6 +38,7 @@ export function ChatShell(): React.ReactNode {
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
||||
<SessionStatusHint />
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Icon name="chat" size={24} />}
|
||||
@@ -34,7 +46,17 @@ export function ChatShell(): React.ReactNode {
|
||||
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
||||
/>
|
||||
) : (
|
||||
messages.map((m) => <MessageBubble key={m.id} message={m} />)
|
||||
messages.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
active={
|
||||
m.id === lastAssistantId &&
|
||||
m.role === "assistant" &&
|
||||
(m.streaming ?? false)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type {
|
||||
AgentInfo,
|
||||
AwakenState,
|
||||
BackthinkItem,
|
||||
ChatMessage,
|
||||
FlowGraph,
|
||||
@@ -27,6 +28,13 @@ export interface AppStore {
|
||||
backthink: BackthinkItem[];
|
||||
flowGraph: FlowGraph | null;
|
||||
deviceUpdates: string[];
|
||||
/** 唤醒式能力三态(design/14 §5)——当前唤醒状态、上次切换的来源态、最近点亮的工具。 */
|
||||
awakenState: AwakenState;
|
||||
awakenFrom: AwakenState | null;
|
||||
awakenTool: string | null;
|
||||
/** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */
|
||||
sessionBusy: boolean;
|
||||
pendingCount: number;
|
||||
|
||||
setWsStatus: (status: WsStatus) => void;
|
||||
setSessionId: (id: string) => void;
|
||||
@@ -47,6 +55,12 @@ export interface AppStore {
|
||||
markBackthinkPushed: (id: string) => void;
|
||||
setFlowGraph: (graph: FlowGraph) => void;
|
||||
addDeviceUpdate: (text: string) => void;
|
||||
setAwaken: (
|
||||
from: AwakenState | undefined,
|
||||
to: AwakenState,
|
||||
tool?: string | null
|
||||
) => void;
|
||||
setSessionBusy: (busy: boolean, pendingCount?: number) => void;
|
||||
|
||||
taskLogs: Record<string, string>;
|
||||
appendTaskLog: (taskId: string, text: string) => void;
|
||||
@@ -69,6 +83,11 @@ export const useAppStore = create<AppStore>()(
|
||||
backthink: [],
|
||||
flowGraph: null,
|
||||
deviceUpdates: [],
|
||||
awakenState: "quiet",
|
||||
awakenFrom: null,
|
||||
awakenTool: null,
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
taskLogs: {},
|
||||
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
@@ -181,6 +200,17 @@ export const useAppStore = create<AppStore>()(
|
||||
setFlowGraph: (graph) => set({ flowGraph: graph }),
|
||||
addDeviceUpdate: (text) =>
|
||||
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
|
||||
setAwaken: (from, to, tool) =>
|
||||
set((s) => ({
|
||||
awakenState: to,
|
||||
awakenFrom: from ?? s.awakenFrom,
|
||||
awakenTool: tool ?? s.awakenTool,
|
||||
})),
|
||||
setSessionBusy: (busy, pendingCount) =>
|
||||
set((s) => ({
|
||||
sessionBusy: busy,
|
||||
pendingCount: pendingCount ?? s.pendingCount,
|
||||
})),
|
||||
|
||||
appendTaskLog: (taskId, text) =>
|
||||
set((s) => ({
|
||||
@@ -203,6 +233,11 @@ export const useAppStore = create<AppStore>()(
|
||||
mood: null,
|
||||
flowGraph: null,
|
||||
backthink: s.backthink,
|
||||
awakenState: "quiet",
|
||||
awakenFrom: null,
|
||||
awakenTool: null,
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -135,3 +135,21 @@ textarea {
|
||||
.mu-blink {
|
||||
animation: mu-blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 唤醒上升(静息 → 唤醒 → 全能)的短暂光芒:弹簧过冲 + 脉动光晕。
|
||||
仅用于 capacity_awaken 状态徽标 / 唤醒路径当前节点,避免常态动画。 */
|
||||
@keyframes mu-awaken {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--mu-accent) 0%, transparent);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--mu-accent) 32%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--mu-accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
.mu-awaken {
|
||||
animation: mu-awaken 1.2s var(--mu-spring) ease-in-out;
|
||||
}
|
||||
Reference in new issue
Block a user