feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
按 design/22 §4.4/§4.6 + §7-4/7-7(契约先行,后端由并行代理实现)。
- 人格页:展示每个角色绑定的会话数——面板侧用 GET /admin/sessions 的 personaId 聚合(personaId 为空 = 继承默认角色,已计入默认角色并在 UI 写明口径),随既有 useAutoReload 刷新;**未新增后端字段**
- 渠道级绑定 UI:每个渠道账号加「角色」下拉(选项取 GET /admin/personas),保存走 PUT /admin/channels/{platform}/config 的 personaId;已绑角色回填、未绑定显示「跟随默认角色」(不冒充某个角色);提示语写明**群聊/陌生人会话始终用默认角色**,避免暗示可覆盖隔离会话
- 验收:npm run build 绿 + 逻辑测试(personaSession/messageSync)通过
This commit is contained in:
1 parent
2aa3233f84
commit
3732c538cc
9 files changed
+532
-5
No files matched your search
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAsyncData } from "../../lib/useAsyncData";
|
import { useAsyncData } from "../../lib/useAsyncData";
|
||||||
import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api";
|
import {
|
||||||
|
adminCreatePersona,
|
||||||
|
adminListPersonas,
|
||||||
|
adminListSessions,
|
||||||
|
adminUpdatePersona,
|
||||||
|
} from "../../lib/api";
|
||||||
import type { PersonaRecord, PersonaUpsertRequest } from "../../lib/types";
|
import type { PersonaRecord, PersonaUpsertRequest } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
EMPTY_THINKING_DRAFT,
|
EMPTY_THINKING_DRAFT,
|
||||||
@@ -14,8 +19,12 @@ import { useAuditStore } from "../audit";
|
|||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { ThinkingFields } from "./personaThinkingForm";
|
import { ThinkingFields } from "./personaThinkingForm";
|
||||||
|
import { countSessionsByPersona, personaSessionCount, type SessionPersonaCounts } from "./personaSessionCount";
|
||||||
|
|
||||||
const columns: AdminColumn<PersonaRecord>[] = [
|
/** The role table's columns. Built per render because the「绑定会话数」cell reads
|
||||||
|
* the live session tallies (design/22 §4.6). */
|
||||||
|
function buildColumns(counts: SessionPersonaCounts, sessionsKnown: boolean): AdminColumn<PersonaRecord>[] {
|
||||||
|
return [
|
||||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||||
{
|
{
|
||||||
key: "default",
|
key: "default",
|
||||||
@@ -24,8 +33,71 @@ const columns: AdminColumn<PersonaRecord>[] = [
|
|||||||
},
|
},
|
||||||
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode || "—"}</Tag> },
|
{ 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: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace || "—"}</span> },
|
||||||
|
{
|
||||||
|
key: "sessions",
|
||||||
|
title: "绑定会话数",
|
||||||
|
render: (p) => (
|
||||||
|
<SessionCountCell count={personaSessionCount(counts, p.id)} known={sessionsKnown} showInherited={p.isDefault} />
|
||||||
|
),
|
||||||
|
},
|
||||||
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
|
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's session count. An unread session list is "未知" (—), never a 0 that
|
||||||
|
* would read as "this role talks to nobody". */
|
||||||
|
function SessionCountCell({
|
||||||
|
count,
|
||||||
|
known,
|
||||||
|
showInherited,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
known: boolean;
|
||||||
|
showInherited: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
if (!known) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]" title="会话列表读取失败,数量未知">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-xs">{count}</span>
|
||||||
|
{showInherited && count > 0 ? <Tag tone="neutral">含继承默认的会话</Tag> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 口径 of the「绑定会话数」column, plus the two cases it cannot attribute to
|
||||||
|
* a row. Both are real sessions, so both are stated instead of dropped. */
|
||||||
|
function SessionCountNotes({
|
||||||
|
counts,
|
||||||
|
sessionsKnown,
|
||||||
|
}: {
|
||||||
|
counts: SessionPersonaCounts;
|
||||||
|
sessionsKnown: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Notice>
|
||||||
|
「绑定会话数」= 实际以该角色运行的会话数,由面板聚合 GET /admin/sessions(每项带 personaId)得出,随面板自动刷新。
|
||||||
|
会话未绑定角色(personaId 为空)时跟随全局默认角色,因此计入默认角色的计数——默认角色那一行同时包含显式绑定与继承默认的会话。
|
||||||
|
</Notice>
|
||||||
|
{sessionsKnown && counts.unknownRole > 0 ? (
|
||||||
|
<Notice tone="warn">
|
||||||
|
有 {counts.unknownRole} 个会话绑定的角色已不在角色列表中(角色被删除或改名),这些会话不计入任何一行。
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
{sessionsKnown && counts.inheritedUnattributed > 0 ? (
|
||||||
|
<Notice tone="warn">
|
||||||
|
有 {counts.inheritedUnattributed} 个会话继承默认角色,但角色列表中没有标记为「默认」的角色,无法归属到任何一行。
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,6 +122,11 @@ const emptyDraft: Draft = {
|
|||||||
export function PersonasModule(): React.ReactNode {
|
export function PersonasModule(): React.ReactNode {
|
||||||
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
|
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
|
||||||
useAutoReload(reload, 10000);
|
useAutoReload(reload, 10000);
|
||||||
|
// 绑定会话数 is derived on the panel from GET /admin/sessions (every row already
|
||||||
|
// carries `personaId`) — no backend counter to keep in sync, and the number
|
||||||
|
// follows the same 10s auto-reload as the rest of the panel.
|
||||||
|
const sessions = useAsyncData(adminListSessions, []);
|
||||||
|
useAutoReload(sessions.reload, 10000);
|
||||||
const [selected, setSelected] = useState<PersonaRecord | null>(null);
|
const [selected, setSelected] = useState<PersonaRecord | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||||
@@ -57,6 +134,10 @@ export function PersonasModule(): React.ReactNode {
|
|||||||
const [fail, setFail] = useState<string | null>(null);
|
const [fail, setFail] = useState<string | null>(null);
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
|
|
||||||
|
const counts = countSessionsByPersona(sessions.data ?? [], data ?? []);
|
||||||
|
const sessionsKnown = sessions.data != null;
|
||||||
|
const columns = buildColumns(counts, sessionsKnown);
|
||||||
|
|
||||||
const startCreate = () => {
|
const startCreate = () => {
|
||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -163,6 +244,8 @@ export function PersonasModule(): React.ReactNode {
|
|||||||
/>
|
/>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
|
<SessionCountNotes counts={counts} sessionsKnown={sessionsKnown} />
|
||||||
|
|
||||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||||
|
|
||||||
{creating ? (
|
{creating ? (
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { adminSaveChannelConfig } from "../../lib/api";
|
import { adminListPersonas, adminSaveChannelConfig } from "../../lib/api";
|
||||||
|
import type { PersonaRecord } from "../../lib/types";
|
||||||
|
import { useAsyncData } from "../../lib/useAsyncData";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { Icon } from "../../components/Icon";
|
import { Icon } from "../../components/Icon";
|
||||||
|
import { withChannelPersona } from "./channels/channelPersona";
|
||||||
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
|
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
|
||||||
|
|
||||||
/** Prefill bundle computed by the module's openConfig from the backend record
|
/** Prefill bundle computed by the module's openConfig from the backend record
|
||||||
@@ -19,6 +22,8 @@ export interface ChannelConfigInitial {
|
|||||||
groupMsgPerMin: string;
|
groupMsgPerMin: string;
|
||||||
msgPerMin: string;
|
msgPerMin: string;
|
||||||
maxMessages: string;
|
maxMessages: string;
|
||||||
|
/** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */
|
||||||
|
personaId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelConfigFormProps {
|
interface ChannelConfigFormProps {
|
||||||
@@ -58,6 +63,12 @@ export function ChannelConfigForm({
|
|||||||
const [advOpen, setAdvOpen] = useState(false);
|
const [advOpen, setAdvOpen] = useState(false);
|
||||||
const [polOpen, setPolOpen] = useState(false);
|
const [polOpen, setPolOpen] = useState(false);
|
||||||
const [tokenHint, setTokenHint] = useState(initial.tokenHint);
|
const [tokenHint, setTokenHint] = useState(initial.tokenHint);
|
||||||
|
// Channel-level role binding (design/22 §4.4). `dirty` tells an explicit clear
|
||||||
|
// (owner picked「跟随默认角色」) from a value the form simply never had, so an
|
||||||
|
// untouched unbound account does not write an empty personaId over a stored one.
|
||||||
|
const [personaId, setPersonaId] = useState(initial.personaId);
|
||||||
|
const [personaDirty, setPersonaDirty] = useState(false);
|
||||||
|
const personas = useAsyncData(adminListPersonas, []);
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!platform) return;
|
if (!platform) return;
|
||||||
@@ -87,7 +98,10 @@ export function ChannelConfigForm({
|
|||||||
} else if (Object.keys(specificMap).length) {
|
} else if (Object.keys(specificMap).length) {
|
||||||
configMap = specificMap;
|
configMap = specificMap;
|
||||||
}
|
}
|
||||||
adminSaveChannelConfig(platform, {
|
adminSaveChannelConfig(
|
||||||
|
platform,
|
||||||
|
withChannelPersona(
|
||||||
|
{
|
||||||
id: cfgId.trim() || undefined,
|
id: cfgId.trim() || undefined,
|
||||||
token: token.trim() || undefined,
|
token: token.trim() || undefined,
|
||||||
baseURL: baseURL.trim() || undefined,
|
baseURL: baseURL.trim() || undefined,
|
||||||
@@ -99,7 +113,10 @@ export function ChannelConfigForm({
|
|||||||
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
||||||
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
||||||
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
||||||
})
|
},
|
||||||
|
{ id: personaId, dirty: personaDirty },
|
||||||
|
),
|
||||||
|
)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
log("channel.save", "ok");
|
log("channel.save", "ok");
|
||||||
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
|
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
|
||||||
@@ -115,6 +132,8 @@ export function ChannelConfigForm({
|
|||||||
setMsgPerMin("");
|
setMsgPerMin("");
|
||||||
setMaxMessages("");
|
setMaxMessages("");
|
||||||
setExtraConfig("");
|
setExtraConfig("");
|
||||||
|
setPersonaId("");
|
||||||
|
setPersonaDirty(false);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
log("channel.save", "error");
|
log("channel.save", "error");
|
||||||
@@ -157,6 +176,16 @@ export function ChannelConfigForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChannelPersonaField
|
||||||
|
value={personaId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setPersonaId(id);
|
||||||
|
setPersonaDirty(true);
|
||||||
|
}}
|
||||||
|
personas={personas.data ?? []}
|
||||||
|
loadError={personas.error}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
|
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
|
||||||
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
||||||
<button
|
<button
|
||||||
@@ -221,6 +250,67 @@ export function ChannelConfigForm({
|
|||||||
const inputCls =
|
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)]";
|
"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)]";
|
||||||
|
|
||||||
|
/** Channel-level role binding (design/22 §4.4, 批次 2). The copy is deliberately
|
||||||
|
* explicit that this cannot reach an isolated conversation: 群聊/陌生人 always
|
||||||
|
* run as the default role, and a session's own binding outranks the account's. */
|
||||||
|
function ChannelPersonaField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
personas,
|
||||||
|
loadError,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (personaId: string) => void;
|
||||||
|
personas: PersonaRecord[];
|
||||||
|
loadError: string | null;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const bound = value.trim();
|
||||||
|
const known = personas.find((p) => p.id === bound);
|
||||||
|
const unknown = bound !== "" && known === undefined;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">角色(渠道级绑定)</span>
|
||||||
|
{bound === "" ? (
|
||||||
|
<Tag tone="neutral">未绑定 · 跟随默认角色</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag tone="accent">已绑定 · {known?.name ?? bound}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||||
|
该渠道账号在非隔离会话(与主人的私聊等)中使用的角色
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-label="渠道账号绑定的角色"
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
<option value="">跟随默认角色(未绑定)</option>
|
||||||
|
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
|
||||||
|
{personas.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.isDefault ? " · 默认" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] leading-relaxed text-[var(--mu-text-lo)]">
|
||||||
|
角色解析顺序:会话绑定 > 渠道绑定 > 全局默认。
|
||||||
|
渠道绑定<strong className="font-medium">只影响该账号与主人的私聊等非隔离会话</strong>;
|
||||||
|
群聊 / 陌生人会话始终使用默认角色,此处的绑定覆盖不了它们。
|
||||||
|
</p>
|
||||||
|
{loadError ? (
|
||||||
|
<p className="text-[11px] text-[var(--mu-err)]">
|
||||||
|
角色列表读取失败({loadError}):不改动该下拉时保存会原样保留当前绑定;请确认后端在线后重试再改。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact labeled input for the config form; `secret` masks the field. */
|
/** Compact labeled input for the config form; `secret` masks the field. */
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Logic test for the channel-level role binding (design/22 §4.4, 批次 2): the
|
||||||
|
// account-record → form prefill read, and the form value → PUT body mapping.
|
||||||
|
//
|
||||||
|
// The mapping rule that matters is omission vs explicit clear. A plain string
|
||||||
|
// field cannot tell `personaId: ""` from an absent key on the wire, so the panel
|
||||||
|
// must only send the key when it means something: an untouched select on an
|
||||||
|
// account that already reads as unbound sends NOTHING (both backend readings of an
|
||||||
|
// omitted field — "keep" or "clear" — then leave the stored binding where it is),
|
||||||
|
// while picking「跟随默认角色」on a bound account sends `""` (a real clear).
|
||||||
|
// The project has no frontend test runner, so this is a dependency-free
|
||||||
|
// plain-node check driving the real pure module and throwing on the first
|
||||||
|
// failure. Run it with the esbuild that already ships with vite:
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelPersona.test.ts \
|
||||||
|
// --bundle --platform=node --format=esm \
|
||||||
|
// --outfile=/tmp/cyrene-channel-persona.test.mjs \
|
||||||
|
// && node /tmp/cyrene-channel-persona.test.mjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||||
|
// imports nothing from node:*.
|
||||||
|
|
||||||
|
import type { AdminChannelAccount } from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
channelPersonaOf,
|
||||||
|
channelPersonaRequest,
|
||||||
|
withChannelPersona,
|
||||||
|
} from "./channelPersona";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function same(got: unknown, want: unknown, what: string): void {
|
||||||
|
const g = JSON.stringify(got);
|
||||||
|
const w = JSON.stringify(want);
|
||||||
|
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const acct = (a: Partial<AdminChannelAccount>): AdminChannelAccount =>
|
||||||
|
({ id: "conf0", ...a }) as AdminChannelAccount;
|
||||||
|
|
||||||
|
// ── Read side: the account record backfills the select ────────────────────────
|
||||||
|
same(channelPersonaOf(acct({ personaId: "shiro" })), "shiro", "top-level personaId backfills");
|
||||||
|
same(channelPersonaOf(acct({ personaId: " shiro " })), "shiro", "the backfilled value is trimmed");
|
||||||
|
// `omitempty` means "unbound" arrives as an absent key, never as "".
|
||||||
|
same(channelPersonaOf(acct({})), "", "an absent personaId reads as 未绑定");
|
||||||
|
same(channelPersonaOf(acct({ personaId: "" })), "", "an explicit empty string reads as 未绑定");
|
||||||
|
same(channelPersonaOf(undefined), "", "no account record reads as 未绑定, never a guess");
|
||||||
|
// Fallback for a backend that nests the value inside `config` — without it a real
|
||||||
|
// binding would display as「未绑定」and a save could write over it.
|
||||||
|
same(
|
||||||
|
channelPersonaOf(acct({ config: { personaId: "shiro", bot_token: "x" } })),
|
||||||
|
"shiro",
|
||||||
|
"a config-nested personaId is read as a fallback",
|
||||||
|
);
|
||||||
|
// A present top-level field is authoritative, even when empty.
|
||||||
|
same(
|
||||||
|
channelPersonaOf(acct({ personaId: "", config: { personaId: "shiro" } })),
|
||||||
|
"",
|
||||||
|
"a present top-level field wins over the nested fallback",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
channelPersonaOf(acct({ config: { personaId: 42 } })),
|
||||||
|
"",
|
||||||
|
"a non-string nested value is not read as a role id",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Write side: the request body ──────────────────────────────────────────────
|
||||||
|
// Bound → send the id (a re-bind, and idempotent).
|
||||||
|
same(channelPersonaRequest({ id: "shiro", dirty: false }), "shiro", "a bound select sends its id");
|
||||||
|
same(channelPersonaRequest({ id: " shiro ", dirty: true }), "shiro", "a chosen id is trimmed");
|
||||||
|
// Never touched and reads unbound → omit the key entirely.
|
||||||
|
same(
|
||||||
|
channelPersonaRequest({ id: "", dirty: false }),
|
||||||
|
undefined,
|
||||||
|
"an untouched unbound select omits personaId",
|
||||||
|
);
|
||||||
|
// Owner explicitly picked「跟随默认角色」→ send "" so the stored binding is cleared.
|
||||||
|
same(channelPersonaRequest({ id: "", dirty: true }), "", "an explicit clear sends an empty string");
|
||||||
|
|
||||||
|
// The body-level mapping, exercised as it is on the wire: the key must be absent
|
||||||
|
// from the JSON when omitted (that is what JSON.stringify does to undefined).
|
||||||
|
same(
|
||||||
|
withChannelPersona({ id: "conf0", token: undefined }, { id: "", dirty: false }),
|
||||||
|
{ id: "conf0", token: undefined },
|
||||||
|
"an untouched unbound select leaves the body untouched",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "", dirty: false })),
|
||||||
|
'{"id":"conf0"}',
|
||||||
|
"the omitted personaId is absent from the serialized body",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "shiro", dirty: true })),
|
||||||
|
'{"id":"conf0","personaId":"shiro"}',
|
||||||
|
"a chosen role reaches the body",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "", dirty: true })),
|
||||||
|
'{"id":"conf0","personaId":""}',
|
||||||
|
"an explicit clear reaches the body as an empty string",
|
||||||
|
);
|
||||||
|
// The original body is never mutated (the form's other fields stay as they are).
|
||||||
|
{
|
||||||
|
const body = { id: "conf0" };
|
||||||
|
const out = withChannelPersona(body, { id: "shiro", dirty: true });
|
||||||
|
same(body, { id: "conf0" }, "the input body is not mutated");
|
||||||
|
same(out, { id: "conf0", personaId: "shiro" }, "the returned body carries the personaId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round-trip: what the read side produced must not be re-written as a clear ──
|
||||||
|
// Opening a bound account and saving without touching the select re-sends the same
|
||||||
|
// id (idempotent under either omission semantics).
|
||||||
|
{
|
||||||
|
const value = channelPersonaOf(acct({ personaId: "shiro" }));
|
||||||
|
same(
|
||||||
|
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: value, dirty: false })),
|
||||||
|
'{"id":"conf0","personaId":"shiro"}',
|
||||||
|
"save-without-edit re-sends the stored binding",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// … and opening an unbound account and saving without touching it sends nothing.
|
||||||
|
{
|
||||||
|
const value = channelPersonaOf(acct({}));
|
||||||
|
same(
|
||||||
|
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: value, dirty: false })),
|
||||||
|
'{"id":"conf0"}',
|
||||||
|
"save-without-edit on an unbound account sends no personaId",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`channel persona logic test: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { AdminChannelAccount } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** Reading the channel account's bound role (design/22 §4.4, 批次 2).
|
||||||
|
*
|
||||||
|
* The contract puts `personaId` at the top level of the account record. The
|
||||||
|
* `config` map is accepted as a fallback so a backend that nests the value there
|
||||||
|
* still backfills the form instead of showing a real binding as「未绑定」.
|
||||||
|
* A present top-level field is authoritative even when it is `""` (explicitly
|
||||||
|
* unbound); only an absent one falls through. */
|
||||||
|
export function channelPersonaOf(account: AdminChannelAccount | undefined): string {
|
||||||
|
if (!account) return "";
|
||||||
|
const top = account.personaId;
|
||||||
|
if (typeof top === "string") return top.trim();
|
||||||
|
const nested = account.config?.personaId;
|
||||||
|
return typeof nested === "string" ? nested.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The role select's state: the id it shows, plus whether the owner touched it.
|
||||||
|
* The dirty flag is what lets an explicit clear differ from "so far unbound". */
|
||||||
|
export interface ChannelPersonaValue {
|
||||||
|
id: string;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map the select onto the `personaId` field of the channel-config PUT body.
|
||||||
|
*
|
||||||
|
* - a non-empty id → send it (bind / re-bind);
|
||||||
|
* - an empty id the owner *changed* to empty → send `""`, an explicit clear;
|
||||||
|
* - an empty id the owner never touched → omit the key. The account already
|
||||||
|
* reads as unbound, so both readings of an omitted field by the backend
|
||||||
|
* ("keep the stored value" / "clear it") leave the stored binding where it is
|
||||||
|
* — which is exactly the read-modify-write semantics of this endpoint.
|
||||||
|
*
|
||||||
|
* `undefined` means "leave the key out"; `""` means "clear it". They are never
|
||||||
|
* conflated, because a plain string field cannot distinguish them on the wire. */
|
||||||
|
export function channelPersonaRequest(value: ChannelPersonaValue): string | undefined {
|
||||||
|
const id = value.id.trim();
|
||||||
|
if (id !== "") return id;
|
||||||
|
return value.dirty ? "" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PUT body with `personaId` attached only when the mapping produced one, so
|
||||||
|
* an untouched unbound select never writes an empty string over the stored
|
||||||
|
* binding. */
|
||||||
|
export function withChannelPersona<T extends Record<string, unknown>>(
|
||||||
|
body: T,
|
||||||
|
value: ChannelPersonaValue,
|
||||||
|
): T {
|
||||||
|
const personaId = channelPersonaRequest(value);
|
||||||
|
return personaId === undefined ? body : { ...body, personaId };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AdminChannelRecord } from "../../../lib/types";
|
import type { AdminChannelRecord } from "../../../lib/types";
|
||||||
import { PLATFORM_FIELDS } from "../channelFields";
|
import { PLATFORM_FIELDS } from "../channelFields";
|
||||||
|
import { channelPersonaOf } from "./channelPersona";
|
||||||
import type { ChannelConfigInitial } from "../channelConfigForm";
|
import type { ChannelConfigInitial } from "../channelConfigForm";
|
||||||
|
|
||||||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||||||
@@ -49,6 +50,7 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[]): Cfg
|
|||||||
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
||||||
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
||||||
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
||||||
|
personaId: channelPersonaOf(acct),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Logic test for the 人格管理 page's「绑定会话数」aggregation (design/22 §4.6, 批次 2).
|
||||||
|
// There is no backend counter: the panel tallies GET /admin/sessions itself, where
|
||||||
|
// every row already carries `personaId` (批次 1). The rule that must not drift is
|
||||||
|
// the inheritance one — a session with an empty/absent `personaId` does not belong
|
||||||
|
// to "no role", it runs as the global default role, so it must land on the default
|
||||||
|
// role's row (and only there). The project has no frontend test runner, so this is
|
||||||
|
// a dependency-free plain-node check driving the real pure module and throwing on
|
||||||
|
// the first failure. Run it with the esbuild that already ships with vite:
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/personaSessionCount.test.ts \
|
||||||
|
// --bundle --platform=node --format=esm \
|
||||||
|
// --outfile=/tmp/cyrene-persona-session-count.test.mjs \
|
||||||
|
// && node /tmp/cyrene-persona-session-count.test.mjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||||
|
// imports nothing from node:*.
|
||||||
|
|
||||||
|
import type { PersonaRecord, SessionMeta } from "../../lib/types";
|
||||||
|
import {
|
||||||
|
countSessionsByPersona,
|
||||||
|
personaSessionCount,
|
||||||
|
} from "./personaSessionCount";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function same(got: unknown, want: unknown, what: string): void {
|
||||||
|
const g = JSON.stringify(got);
|
||||||
|
const w = JSON.stringify(want);
|
||||||
|
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const persona = (id: string, isDefault = false): Pick<PersonaRecord, "id" | "isDefault"> => ({ id, isDefault });
|
||||||
|
const session = (personaId?: string): Pick<SessionMeta, "personaId"> =>
|
||||||
|
personaId === undefined ? {} : { personaId };
|
||||||
|
|
||||||
|
// ── Empty / absent personaId inherits the default role ─────────────────────────
|
||||||
|
// Both spellings the backend can emit ('') and the "field absent" case an older
|
||||||
|
// backend sends must land on the default role's row.
|
||||||
|
{
|
||||||
|
const counts = countSessionsByPersona(
|
||||||
|
[session(""), session(undefined), session("cyrene")],
|
||||||
|
[persona("cyrene", true), persona("shiro")],
|
||||||
|
);
|
||||||
|
same(personaSessionCount(counts, "cyrene"), 3, "empty/absent personaId counts on the default role");
|
||||||
|
same(personaSessionCount(counts, "shiro"), 0, "a role nobody talks to reads 0, not undefined");
|
||||||
|
same(counts.unknownRole, 0, "no unknown role here");
|
||||||
|
same(counts.inheritedUnattributed, 0, "none unattributed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Explicit bindings stay on their own row ───────────────────────────────────
|
||||||
|
{
|
||||||
|
const counts = countSessionsByPersona(
|
||||||
|
[session("shiro"), session("shiro"), session("cyrene"), session("")],
|
||||||
|
[persona("cyrene", true), persona("shiro")],
|
||||||
|
);
|
||||||
|
same(counts.byPersona, { shiro: 2, cyrene: 2 }, "per-role tallies");
|
||||||
|
same(personaSessionCount(counts, "cyrene"), 2, "default row mixes explicit + inherited");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace is not a role id: a padded value is the id it names.
|
||||||
|
{
|
||||||
|
const counts = countSessionsByPersona([session(" shiro ")], [persona("shiro")]);
|
||||||
|
same(personaSessionCount(counts, "shiro"), 1, "a padded personaId is trimmed, not treated as unbound");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A binding the catalog does not contain is kept out of every row ───────────
|
||||||
|
// (role deleted, or a stale id). It must not be silently folded into the default
|
||||||
|
// role's count, which would claim a session that role never speaks in.
|
||||||
|
{
|
||||||
|
const counts = countSessionsByPersona(
|
||||||
|
[session("ghost"), session(""), session("cyrene")],
|
||||||
|
[persona("cyrene", true)],
|
||||||
|
);
|
||||||
|
same(counts.unknownRole, 1, "an id absent from the catalog is counted as unknown");
|
||||||
|
same(personaSessionCount(counts, "cyrene"), 2, "the default row does not absorb the unknown id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inherited sessions with no default role in the catalog ────────────────────
|
||||||
|
// (catalog still loading, or a catalog without a default) — real sessions that no
|
||||||
|
// row can own, so they are counted apart rather than dropped or blamed on a role.
|
||||||
|
{
|
||||||
|
const counts = countSessionsByPersona([session(""), session("")], [persona("shiro")]);
|
||||||
|
same(counts.inheritedUnattributed, 2, "inherited sessions are counted when no default role exists");
|
||||||
|
same(counts.byPersona, {}, "no row is invented for them");
|
||||||
|
same(personaSessionCount(counts, "shiro"), 0, "the non-default row stays 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── An empty session list and an empty catalog are both legal ─────────────────
|
||||||
|
{
|
||||||
|
same(countSessionsByPersona([], [persona("cyrene", true)]).byPersona, {}, "no sessions → no tallies");
|
||||||
|
same(countSessionsByPersona([], []).inheritedUnattributed, 0, "empty catalog + empty list stays at 0");
|
||||||
|
same(countSessionsByPersona([session("")], []).inheritedUnattributed, 1, "empty catalog still counts inherit");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`persona session-count logic test: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PersonaRecord, SessionMeta } from "../../lib/types";
|
||||||
|
|
||||||
|
/** Per-role session tallies for the 人格管理 page (design/22 §4.6, 批次 2).
|
||||||
|
*
|
||||||
|
* There is no backend counter: the panel derives this from `GET /admin/sessions`,
|
||||||
|
* whose every row already carries `personaId` (批次 1). A session with an empty /
|
||||||
|
* absent `personaId` does not talk to "no role" — it inherits the global default
|
||||||
|
* role (design/22 §4.7), so it must be counted on the default role's row. That is
|
||||||
|
* the only place a reader of this page can see those sessions, and it is why the
|
||||||
|
* default row alone mixes inherited and explicitly bound sessions. */
|
||||||
|
export interface SessionPersonaCounts {
|
||||||
|
/** Effective session count per role id. */
|
||||||
|
byPersona: Record<string, number>;
|
||||||
|
/** Sessions bound to an id the catalog does not contain (role deleted, or a
|
||||||
|
* stale binding). Kept out of every row so a per-role number never claims a
|
||||||
|
* session it cannot name. */
|
||||||
|
unknownRole: number;
|
||||||
|
/** Sessions that inherit the default role while the catalog names no default
|
||||||
|
* role (still loading, or a catalog without one) — real sessions that no row
|
||||||
|
* can own, counted so they do not silently disappear. */
|
||||||
|
inheritedUnattributed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tally sessions per *effective* role: `''` → the default role's row, a known id
|
||||||
|
* → its own row, anything else → `unknownRole`. */
|
||||||
|
export function countSessionsByPersona(
|
||||||
|
sessions: readonly Pick<SessionMeta, "personaId">[],
|
||||||
|
personas: readonly Pick<PersonaRecord, "id" | "isDefault">[],
|
||||||
|
): SessionPersonaCounts {
|
||||||
|
const known = new Set(personas.map((p) => p.id));
|
||||||
|
const defaultId = personas.find((p) => p.isDefault)?.id ?? "";
|
||||||
|
const byPersona: Record<string, number> = {};
|
||||||
|
let unknownRole = 0;
|
||||||
|
let inheritedUnattributed = 0;
|
||||||
|
|
||||||
|
const bump = (id: string) => {
|
||||||
|
byPersona[id] = (byPersona[id] ?? 0) + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
const bound = (s.personaId ?? "").trim();
|
||||||
|
if (bound === "") {
|
||||||
|
if (defaultId !== "") bump(defaultId);
|
||||||
|
else inheritedUnattributed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (known.has(bound)) bump(bound);
|
||||||
|
else unknownRole += 1;
|
||||||
|
}
|
||||||
|
return { byPersona, unknownRole, inheritedUnattributed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row's count: `0` is a real answer, an unread session list is not. */
|
||||||
|
export function personaSessionCount(counts: SessionPersonaCounts, personaId: string): number {
|
||||||
|
return counts.byPersona[personaId] ?? 0;
|
||||||
|
}
|
||||||
@@ -71,6 +71,10 @@ export function adminSaveChannelConfig(
|
|||||||
groupMsgPerMin?: number;
|
groupMsgPerMin?: number;
|
||||||
msgPerMin?: number;
|
msgPerMin?: number;
|
||||||
maxMessages?: number;
|
maxMessages?: number;
|
||||||
|
/** Role bound to this channel account (design/22 §4.4). Omit to leave the
|
||||||
|
* stored binding untouched; `''` clears it (「跟随默认角色」). It only affects
|
||||||
|
* the account's non-isolated sessions — 群聊/陌生人始终用默认角色. */
|
||||||
|
personaId?: string;
|
||||||
},
|
},
|
||||||
): Promise<ChannelConfigSaveResult> {
|
): Promise<ChannelConfigSaveResult> {
|
||||||
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
|
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
|
||||||
|
|||||||
@@ -337,6 +337,10 @@ export interface AdminChannelAccount {
|
|||||||
groupMsgPerMin?: number;
|
groupMsgPerMin?: number;
|
||||||
msgPerMin?: number;
|
msgPerMin?: number;
|
||||||
maxMessages?: number;
|
maxMessages?: number;
|
||||||
|
/** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''`
|
||||||
|
* means "跟随默认角色" — it only ever applies to the account's non-isolated
|
||||||
|
* sessions (群聊/陌生人强制默认角色). Written through the config PUT. */
|
||||||
|
personaId?: string;
|
||||||
/** Connected bot identity (kook resolves it from /api/v3/user/me). */
|
/** Connected bot identity (kook resolves it from /api/v3/user/me). */
|
||||||
botId?: string;
|
botId?: string;
|
||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user