feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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:
AskaEth committed 2026-10-03 20:50:07 +08:00
1 parent 2aa3233f84
commit 3732c538cc
9 files changed
+553 -26

No files matched your search

+95 -12
View File
@@ -1,6 +1,11 @@
import { useState } from "react";
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 {
EMPTY_THINKING_DRAFT,
@@ -14,18 +19,85 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { ThinkingFields } from "./personaThinkingForm";
import { countSessionsByPersona, personaSessionCount, type SessionPersonaCounts } from "./personaSessionCount";
const columns: AdminColumn<PersonaRecord>[] = [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "default",
title: "默认",
render: (p) => (p.isDefault ? <Tag tone="accent">默认</Tag> : <span className="text-[var(--mu-text-lo)]">—</span>),
},
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode || "—"}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace || "—"}</span> },
{ key: "id", title: "ID", render: (p) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.id}</span> },
];
/** 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: "default",
title: "默认",
render: (p) => (p.isDefault ? <Tag tone="accent">默认</Tag> : <span className="text-[var(--mu-text-lo)]">—</span>),
},
{ key: "mode", title: "模式", render: (p) => <Tag tone="neutral">{p.mode || "—"}</Tag> },
{ key: "memory", title: "记忆域", render: (p) => <span className="font-mono text-xs">{p.memoryNamespace || "—"}</span> },
{
key: "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> },
];
}
/** 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 {
id: string;
@@ -50,6 +122,11 @@ const emptyDraft: Draft = {
export function PersonasModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
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 [creating, setCreating] = useState(false);
const [draft, setDraft] = useState<Draft>(emptyDraft);
@@ -57,6 +134,10 @@ export function PersonasModule(): React.ReactNode {
const [fail, setFail] = useState<string | null>(null);
const log = useAuditStore((s) => s.log);
const counts = countSessionsByPersona(sessions.data ?? [], data ?? []);
const sessionsKnown = sessions.data != null;
const columns = buildColumns(counts, sessionsKnown);
const startCreate = () => {
setDraft(emptyDraft);
setCreating(true);
@@ -163,6 +244,8 @@ export function PersonasModule(): React.ReactNode {
/>
</Surface>
<SessionCountNotes counts={counts} sessionsKnown={sessionsKnown} />
{fail ? <Notice tone="err">{fail}</Notice> : null}
{creating ? (
@@ -1,8 +1,11 @@
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 { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { withChannelPersona } from "./channels/channelPersona";
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_LABEL } from "./channelFields";
/** Prefill bundle computed by the module's openConfig from the backend record
@@ -19,6 +22,8 @@ export interface ChannelConfigInitial {
groupMsgPerMin: string;
msgPerMin: string;
maxMessages: string;
/** The account's bound role, `''` = 未绑定(跟随默认角色)(design/22 §4.4). */
personaId: string;
}
interface ChannelConfigFormProps {
@@ -58,6 +63,12 @@ export function ChannelConfigForm({
const [advOpen, setAdvOpen] = useState(false);
const [polOpen, setPolOpen] = useState(false);
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 = () => {
if (!platform) return;
@@ -87,19 +98,25 @@ export function ChannelConfigForm({
} else if (Object.keys(specificMap).length) {
configMap = specificMap;
}
adminSaveChannelConfig(platform, {
id: cfgId.trim() || undefined,
token: token.trim() || undefined,
baseURL: baseURL.trim() || undefined,
config: configMap,
allowGroups: toList(allowGroups),
denyGroups: toList(denyGroups),
allowUsers: toList(allowUsers),
denyUsers: toList(denyUsers),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
})
adminSaveChannelConfig(
platform,
withChannelPersona(
{
id: cfgId.trim() || undefined,
token: token.trim() || undefined,
baseURL: baseURL.trim() || undefined,
config: configMap,
allowGroups: toList(allowGroups),
denyGroups: toList(denyGroups),
allowUsers: toList(allowUsers),
denyUsers: toList(denyUsers),
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
maxMessages: maxMessages ? Number(maxMessages) : undefined,
},
{ id: personaId, dirty: personaDirty },
),
)
.then((r) => {
log("channel.save", "ok");
onSaved(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
@@ -115,6 +132,8 @@ export function ChannelConfigForm({
setMsgPerMin("");
setMaxMessages("");
setExtraConfig("");
setPersonaId("");
setPersonaDirty(false);
})
.catch((e: unknown) => {
log("channel.save", "error");
@@ -157,6 +176,16 @@ export function ChannelConfigForm({
) : null}
</div>
<ChannelPersonaField
value={personaId}
onChange={(id) => {
setPersonaId(id);
setPersonaDirty(true);
}}
personas={personas.data ?? []}
loadError={personas.error}
/>
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
<button
@@ -221,6 +250,67 @@ export function ChannelConfigForm({
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)]";
/** 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)]">
角色解析顺序:会话绑定 &gt; 渠道绑定 &gt; 全局默认。
渠道绑定<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. */
interface FieldProps {
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 { PLATFORM_FIELDS } from "../channelFields";
import { channelPersonaOf } from "./channelPersona";
import type { ChannelConfigInitial } from "../channelConfigForm";
/** 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),
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
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;
msgPerMin?: 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> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
+4
View File
@@ -337,6 +337,10 @@ export interface AdminChannelAccount {
groupMsgPerMin?: number;
msgPerMin?: 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). */
botId?: string;
botUsername?: string;