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

+21 -98
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { createSubSession, getAuthToken, patchSession } from "../lib/api";
import { setSessionPersona } from "../lib/sessionPersona";
import { createSubSession, getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { PersonaRecord, SessionMeta } from "../lib/types";
import { Icon } from "./Icon";
@@ -8,14 +7,12 @@ import { NavItem } from "./NavItem";
import { Button } from "./primitives";
/**
* The conversation list in the rail (design/16 §2 + design/22 §4.6):
* The conversation list in the rail (design/16 §2 + design/22 §4.6/§8-1):
* - main session first, secondary sessions by creation time, unread badges;
* - "新建会话" reveals a role picker, so a new conversation can start bound to a
* role (empty = inherit the global default role);
* - every row has a role menu that re-binds *that* session through
* `PATCH /sessions/{id} {personaId}` (design/22 §4.6). Changing a session's
* role changes which memory / emotion / system prompt the session talks to, so
* it stays an explicit, per-session action instead of a global switch.
* role (empty = inherit the global default role). This is the ONLY place a role
* is picked: a session's role is fixed at creation (design/22 §8-1), so a row has
* no role menu to re-bind it — switching roles means starting a new conversation.
*
* Kept out of `Nav.tsx` so no single component grows past the 80-line rule.
*/
@@ -25,12 +22,6 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
const personas = useAppStore((s) => s.personas);
const setSessionPersonaLocal = useAppStore((s) => s.setSessionPersona);
/** The session whose role menu is expanded, or null when none is open. */
const [menuFor, setMenuFor] = useState<string | null>(null);
/** Set when a role change was rejected, so the row can say so instead of
* silently showing the role the server still has. */
const [failed, setFailed] = useState(false);
if (!getAuthToken()) return null;
@@ -58,104 +49,37 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
onNavigate();
};
/** Re-bind one session to a role: written optimistically into the store and
* rolled back when the PATCH is rejected (lib/sessionPersona.ts), so the rail
* never keeps showing a binding the server refused. */
const changePersona = async (sessionId: string, personaId: string) => {
setFailed(false);
const applied = await setSessionPersona(
{
read: (id) => useAppStore.getState().sessions[id]?.personaId ?? "",
write: setSessionPersonaLocal,
persist: (id, pid) => patchSession(id, { personaId: pid }),
},
sessionId,
personaId
);
if (applied) setMenuFor(null);
else setFailed(true);
};
return (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">会话</div>
{sessionList.map((s) => (
<SessionEntry
key={s.id}
session={s}
active={s.id === activeSessionId}
menuOpen={menuFor === s.id}
failed={failed && menuFor === s.id}
personas={personas}
onOpen={() => openSession(s.id)}
onToggleMenu={() => {
setFailed(false);
setMenuFor(menuFor === s.id ? null : s.id);
}}
onChangePersona={(personaId) => void changePersona(s.id, personaId)}
/>
<SessionEntry key={s.id} session={s} active={s.id === activeSessionId} onOpen={() => openSession(s.id)} />
))}
<CreateSessionSection personas={personas} onCreated={adoptSession} />
</div>
);
}
/** One conversation row plus its role menu. */
/** One conversation row. It carries no role affordance: the role is fixed when the
* session was created (design/22 §8-1), so the only thing a row does is open. */
function SessionEntry({
session,
active,
menuOpen,
failed,
personas,
onOpen,
onToggleMenu,
onChangePersona,
}: {
session: SessionMeta;
active: boolean;
menuOpen: boolean;
failed: boolean;
personas: PersonaRecord[];
onOpen: () => void;
onToggleMenu: () => void;
onChangePersona: (personaId: string) => void;
}): React.ReactNode {
const label = session.title ?? (session.type === "main" ? "主会话" : "次级会话");
return (
<div className="flex flex-col">
<NavItem
active={active}
icon={session.type === "main" ? "chat" : "folder"}
label={label}
badge={session.unreadCount ?? 0}
onClick={onOpen}
trailing={
<button
onClick={onToggleMenu}
aria-label={`${label} · 切换角色`}
aria-expanded={menuOpen}
title="切换角色"
className="mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--mu-radius-sm)] text-[var(--mu-text-lo)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] hover:text-[var(--mu-text-hi)]"
>
<Icon name="dna" size={14} />
</button>
}
/>
{menuOpen ? (
<div className="liquid-glass-soft mx-1 mb-1 flex flex-col gap-1.5 rounded-[var(--mu-radius)] p-2">
<span className="text-[11px] text-[var(--mu-text-lo)]">会话角色</span>
<RoleSelect
value={session.personaId ?? ""}
personas={personas}
label={`${label}的角色`}
onChange={onChangePersona}
/>
{failed ? (
<span className="text-[10px] text-[var(--mu-err)]">切换失败,已保持原角色</span>
) : null}
</div>
) : null}
</div>
<NavItem
active={active}
icon={session.type === "main" ? "chat" : "folder"}
label={label}
badge={session.unreadCount ?? 0}
onClick={onOpen}
/>
);
}
@@ -236,11 +160,12 @@ function CreateSessionSection({
);
}
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6). The empty
* option means "inherit the global default role" — the value a session with no
* binding has — so a session can always be returned to the default. A value that
* is not in the catalog (role deleted, catalog still loading) is shown as its own
* option rather than silently reading as "inherit". */
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6), used only by the
* "new conversation" panel: the empty option means "inherit the global default
* role", which is a perfectly normal way to create a session (and the only role
* choice left — a created session cannot be re-bound, §8-1). Every value this
* control shows comes from that catalog or is "", so it needs no "unknown role"
* escape hatch. */
function RoleSelect({
value,
personas,
@@ -254,7 +179,6 @@ function RoleSelect({
label: string;
onChange: (personaId: string) => void;
}): React.ReactNode {
const unknown = value !== "" && !personas.some((p) => p.id === value);
return (
<select
value={value}
@@ -264,7 +188,6 @@ function RoleSelect({
className="min-w-0 flex-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-[11px] text-[var(--mu-text-hi)] outline-none transition-colors duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
>
<option value="">继承默认角色</option>
{unknown ? <option value={value}>{value}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}