From 3732c538cc42b831bc25a260e81db7f8d250b8f9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 20:50:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BA=BA=E6=A0=BC=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E7=BB=91=E5=AE=9A=E4=BC=9A=E8=AF=9D=E6=95=B0=E3=80=8D?= =?UTF-8?q?+=20=E6=B8=A0=E9=81=93=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=A7=92=E8=89=B2=EF=BC=88B=20=E6=89=B9=E6=AC=A1=202=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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)通过 --- frontend/web/src/admin/modules/Personas.tsx | 107 ++++++++++++-- .../src/admin/modules/channelConfigForm.tsx | 118 +++++++++++++-- .../modules/channels/channelPersona.test.ts | 136 ++++++++++++++++++ .../admin/modules/channels/channelPersona.ts | 51 +++++++ .../admin/modules/channels/channelUtils.ts | 2 + .../admin/modules/personaSessionCount.test.ts | 101 +++++++++++++ .../src/admin/modules/personaSessionCount.ts | 56 ++++++++ frontend/web/src/lib/api/adminChannels.ts | 4 + frontend/web/src/lib/types.ts | 4 + 9 files changed, 553 insertions(+), 26 deletions(-) create mode 100644 frontend/web/src/admin/modules/channels/channelPersona.test.ts create mode 100644 frontend/web/src/admin/modules/channels/channelPersona.ts create mode 100644 frontend/web/src/admin/modules/personaSessionCount.test.ts create mode 100644 frontend/web/src/admin/modules/personaSessionCount.ts diff --git a/frontend/web/src/admin/modules/Personas.tsx b/frontend/web/src/admin/modules/Personas.tsx index 27cff93..5f7acab 100644 --- a/frontend/web/src/admin/modules/Personas.tsx +++ b/frontend/web/src/admin/modules/Personas.tsx @@ -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[] = [ - { key: "name", title: "名称", render: (p) => {p.name} }, - { - key: "default", - title: "默认", - render: (p) => (p.isDefault ? 默认 : —), - }, - { key: "mode", title: "模式", render: (p) => {p.mode || "—"} }, - { key: "memory", title: "记忆域", render: (p) => {p.memoryNamespace || "—"} }, - { key: "id", title: "ID", render: (p) => {p.id} }, -]; +/** 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[] { + return [ + { key: "name", title: "名称", render: (p) => {p.name} }, + { + key: "default", + title: "默认", + render: (p) => (p.isDefault ? 默认 : —), + }, + { key: "mode", title: "模式", render: (p) => {p.mode || "—"} }, + { key: "memory", title: "记忆域", render: (p) => {p.memoryNamespace || "—"} }, + { + key: "sessions", + title: "绑定会话数", + render: (p) => ( + + ), + }, + { key: "id", title: "ID", render: (p) => {p.id} }, + ]; +} + +/** 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 ( + + — + + ); + } + return ( + + {count} + {showInherited && count > 0 ? 含继承默认的会话 : null} + + ); +} + +/** 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 ( + <> + + 「绑定会话数」= 实际以该角色运行的会话数,由面板聚合 GET /admin/sessions(每项带 personaId)得出,随面板自动刷新。 + 会话未绑定角色(personaId 为空)时跟随全局默认角色,因此计入默认角色的计数——默认角色那一行同时包含显式绑定与继承默认的会话。 + + {sessionsKnown && counts.unknownRole > 0 ? ( + + 有 {counts.unknownRole} 个会话绑定的角色已不在角色列表中(角色被删除或改名),这些会话不计入任何一行。 + + ) : null} + {sessionsKnown && counts.inheritedUnattributed > 0 ? ( + + 有 {counts.inheritedUnattributed} 个会话继承默认角色,但角色列表中没有标记为「默认」的角色,无法归属到任何一行。 + + ) : 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(null); const [creating, setCreating] = useState(false); const [draft, setDraft] = useState(emptyDraft); @@ -57,6 +134,10 @@ export function PersonasModule(): React.ReactNode { const [fail, setFail] = useState(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 { /> + + {fail ? {fail} : null} {creating ? ( diff --git a/frontend/web/src/admin/modules/channelConfigForm.tsx b/frontend/web/src/admin/modules/channelConfigForm.tsx index f97549e..0677ae1 100644 --- a/frontend/web/src/admin/modules/channelConfigForm.tsx +++ b/frontend/web/src/admin/modules/channelConfigForm.tsx @@ -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} + { + setPersonaId(id); + setPersonaDirty(true); + }} + personas={personas.data ?? []} + loadError={personas.error} + /> + {/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}