feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

design/22 §8-1(主人 2026-10-03 拍板):会话角色在创建时确定、之后不可变,
要换角色必须新开会话。

后端:
- PATCH /api/v1/sessions/{id} 的 personaId 改为 json.RawMessage 判「键出现」,
  出现即 400(null/""/未知 id 一律拒),且先于会话存在性检查;错误消息点名 personaId
- 删除 Server.setSessionPersona 与 PATCH 的 501 分支;title/pinned 行为逐字不变
- sessionPersonaWriter 接口与 history.Store.SetSessionPersona 保留(存储能力)

前端:
- 移除会话行「切换角色」入口与面板「挂角色」下拉(含 setSessionPersona action、
  lib/sessionPersona.ts);保留「新建会话选角色」;面板「角色」列改为只读展示

契约:Docs/api-client.md -> v0.46(破坏性:依赖 PATCH 换角色的客户端须改为新建会话)

验证:go build/vet ./... 绿;gateway+history 包测试绿(-count=1);前端 npm run build
(tsc --noEmit + vite build)绿;9 个前端逻辑测试全绿。
变异验证:改回「静默忽略」/「写绑定」两种反向实现,新断言均 FAIL。
This commit is contained in:
AskaEth committed 2026-10-03 21:24:51 +08:00
1 parent f9430dee81
commit c7ecc3a249
10 files changed
+193 -448

No files matched your search

+15 -73
View File
@@ -1,12 +1,7 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListPersonas,
adminListSessions,
createSubSession,
patchSession,
} from "../../lib/api";
import type { PersonaRecord, SessionMeta } from "../../lib/types";
import { adminListSessions, createSubSession } from "../../lib/api";
import type { SessionMeta } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
@@ -22,49 +17,11 @@ function exportMeta(sess: SessionMeta): void {
URL.revokeObjectURL(url);
}
/** The role cell of one row: the session's bound role, editable in place. An id
* that is not in the catalog (role deleted, catalog still loading) keeps its own
* option, so the cell never silently reads as "inherit the default". */
function PersonaCell({
session,
personas,
disabled,
onChange,
}: {
session: SessionMeta;
personas: PersonaRecord[];
disabled: boolean;
onChange: (personaId: string) => void;
}): React.ReactNode {
const bound = session.personaId ?? "";
const unknown = bound !== "" && !personas.some((p) => p.id === bound);
return (
<select
value={bound}
disabled={disabled}
aria-label={`会话 ${session.id} 的角色`}
onChange={(e) => onChange(e.target.value)}
className="max-w-[180px] rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
>
<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>
);
}
/** The session table's columns. Built per render because two of the cells are
* wired to the module's live state (the busy flag, the role catalog). */
function buildColumns(opts: {
personas: PersonaRecord[];
busy: string | null;
onChangePersona: (sessionId: string, personaId: string) => void;
}): AdminColumn<SessionMeta>[] {
const { personas, busy, onChangePersona } = opts;
/** The session table's columns. Built per render because the action cell is wired to
* the module's live busy flag. The 角色 cell is read-only: a session's role is fixed
* when it is created (design/22 §8-1), so the panel shows the binding it was created
* with instead of offering a re-bind — changing the role means creating a session. */
function buildColumns(busy: string | null): AdminColumn<SessionMeta>[] {
return [
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
@@ -73,12 +30,9 @@ function buildColumns(opts: {
key: "persona",
title: "角色",
render: (s) => (
<PersonaCell
session={s}
personas={personas}
disabled={busy !== null}
onChange={(personaId) => onChangePersona(s.id, personaId)}
/>
<span className="font-mono text-xs">
{s.personaId ? s.personaId : <span className="text-[var(--mu-text-lo)]">(继承默认角色)</span>}
</span>
),
},
{ key: "createdAt", title: "创建时间", render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span> },
@@ -94,14 +48,12 @@ function buildColumns(opts: {
];
}
/** The page's two write actions, both audited. The list is re-read on success —
* the PATCH reply is not a full session row — which also refreshes the row from
* server truth instead of a local guess. */
/** The page's write action, audited. The list is re-read on success so the new row
* comes from server truth instead of a local guess. */
function useSessionMutations(reload: () => void): {
busy: string | null;
fail: string | null;
newSubSession: () => void;
changePersona: (sessionId: string, personaId: string) => void;
} {
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
@@ -132,19 +84,14 @@ function useSessionMutations(reload: () => void): {
fail,
newSubSession: () =>
run("new", "session.create", () => createSubSession(), "新建次级会话失败"),
changePersona: (sessionId, personaId) =>
run(sessionId, "session.persona", () => patchSession(sessionId, { personaId }), "挂角色失败"),
};
}
export function SessionsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
useAutoReload(reload, 10000);
// The role catalog for the "挂角色" column (design/22 §4.6); the panel reads it
// from the admin endpoint, and the write goes through the ordinary session PATCH.
const personas = useAsyncData(adminListPersonas, []);
const [query, setQuery] = useState("");
const { busy, fail, newSubSession, changePersona } = useSessionMutations(reload);
const { busy, fail, newSubSession } = useSessionMutations(reload);
const filtered = (data ?? []).filter((s) => {
if (!query.trim()) return true;
@@ -156,11 +103,7 @@ export function SessionsModule(): React.ReactNode {
);
});
const columns: AdminColumn<SessionMeta>[] = buildColumns({
personas: personas.data ?? [],
busy,
onChangePersona: changePersona,
});
const columns: AdminColumn<SessionMeta>[] = buildColumns(busy);
return (
<div className="space-y-5">
@@ -200,8 +143,7 @@ export function SessionsModule(): React.ReactNode {
<Notice>
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走;
改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
「角色」列只读显示该会话创建时绑定的角色(design/22 §4.6/§8-1):会话角色在创建时确定、之后不可变,要换角色请新开会话(本页「新建次级会话」按默认角色新建,用户端侧栏的新建会话可选角色)。
</Notice>
</div>
);