Files
Cyrene_v2/frontend/web/src/admin/modules/Users.tsx
T
Aska 6dbb4a2e18
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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。
2026-09-06 21:54:40 +08:00

224 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
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";
import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
owner: "accent",
admin: "ok",
viewer: "neutral",
};
const initialProfile: OwnerProfile = {
id: "owner",
displayName: "叶酱",
defaultPersona: "cyrene",
identityFile: "configs/users/owner_identity.md",
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
accounts: [],
};
export function UsersModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminListUsers, []);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
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" 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>
{error && !data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error}</Notice>
<AdminTable
columns={columns}
rows={[
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
]}
loading={false}
error={null}
rowKey={(u) => u.id}
/>
</div>
) : (
<AdminTable columns={columns} rows={data} loading={loading} error={error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
disabled={!isOwner}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;owner.yaml 持久化/热重载(编辑)由后端 M10 提供,当前为本地编辑。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{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 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)]">行内「撤销」真实移除该密钥。</span>
</div>
</Surface>
</div>
);
}
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}
function Field({ label, value, onChange, disabled }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</label>
);
}