feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
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:
1 parent
f9430dee81
commit
c7ecc3a249
10 files changed
+193
-448
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user