feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
按 DevDocs/design/22 §4.6/§5(7) 实现(后端由并行代理在做,按已定契约先行)。
- currentPersona 由「全局单值」改为「跟随激活会话」:新增 lib/sessionPersona.ts 解析(personaId 空 → 回落默认角色),App/TopBar/chat/lib/persona.ts 全部改为按激活会话取角色(头像/背景/名字/心情随会话切换)
- 交互:新建次级会话可选角色;会话换角色入口;管理面板 Sessions 页加「挂角色」下拉(走 PATCH /sessions/{id})
- 组件拆分:Nav.tsx 228→143(抽出 NavItem.tsx 53)、新增 SessionRail.tsx 275(会话侧栏)
- 契约:types.ts / lib/api/sessions.ts 加 personaId?(建会话/改会话支持)
- mood 改为**按角色分桶**(同一角色的多个会话共享该角色心情);升级后旧 mood 值不保留(刷新即空)——属行为变化,已在报告注明
- 测试:新增 personaSession.test.ts 40 checks(角色解析/回落/乐观更新与回滚)+ 既有 messageSync 54 checks 回归
- 验收:npm run build 绿;**未做浏览器端到端**(无 jsdom/渲染测试底座),UI 交互与失败路径提示未渲染验证
- 已知余量:useAppStore.ts 494/500 行(下次改动前应先拆 selectCurrentPersona/useCurrentPersona/useCurrentMood)
This commit is contained in:
1 parent
38a403afca
commit
66a96d2f93
16 files changed
+1044
-205
No files matched your search
@@ -1,7 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminListSessions, createSubSession } from "../../lib/api";
|
||||
import type { SessionMeta } from "../../lib/types";
|
||||
import {
|
||||
adminListPersonas,
|
||||
adminListSessions,
|
||||
createSubSession,
|
||||
patchSession,
|
||||
} from "../../lib/api";
|
||||
import type { PersonaRecord, SessionMeta } from "../../lib/types";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
@@ -17,14 +22,130 @@ function exportMeta(sess: SessionMeta): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function SessionsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
|
||||
useAutoReload(reload, 10000);
|
||||
const [query, setQuery] = useState("");
|
||||
/** 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;
|
||||
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> },
|
||||
{ key: "id", title: "会话 ID", render: (s) => <span className="font-mono text-xs">{s.id}</span> },
|
||||
{
|
||||
key: "persona",
|
||||
title: "角色",
|
||||
render: (s) => (
|
||||
<PersonaCell
|
||||
session={s}
|
||||
personas={personas}
|
||||
disabled={busy !== null}
|
||||
onChange={(personaId) => onChangePersona(s.id, personaId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "createdAt", title: "创建时间", render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span> },
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (s) => (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportMeta(s)}>
|
||||
导出
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const run = (
|
||||
busyKey: string,
|
||||
action: string,
|
||||
call: () => Promise<unknown>,
|
||||
fallback: string
|
||||
) => {
|
||||
setBusy(busyKey);
|
||||
setFail(null);
|
||||
call()
|
||||
.then(() => {
|
||||
log(action, "ok");
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log(action, "error");
|
||||
setFail(e instanceof Error ? e.message : fallback);
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
return {
|
||||
busy,
|
||||
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 filtered = (data ?? []).filter((s) => {
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -35,44 +156,11 @@ export function SessionsModule(): React.ReactNode {
|
||||
);
|
||||
});
|
||||
|
||||
const newSubSession = () => {
|
||||
setBusy("new");
|
||||
setFail(null);
|
||||
createSubSession()
|
||||
.then(() => {
|
||||
log("session.create", "ok");
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("session.create", "error");
|
||||
setFail(e instanceof Error ? e.message : "新建次级会话失败");
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const columns: AdminColumn<SessionMeta>[] = [
|
||||
{ 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> },
|
||||
{
|
||||
key: "id",
|
||||
title: "会话 ID",
|
||||
render: (s) => <span className="font-mono text-xs">{s.id}</span>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "创建时间",
|
||||
render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (s) => (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportMeta(s)}>
|
||||
导出
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
const columns: AdminColumn<SessionMeta>[] = buildColumns({
|
||||
personas: personas.data ?? [],
|
||||
busy,
|
||||
onChangePersona: changePersona,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -112,6 +200,8 @@ export function SessionsModule(): React.ReactNode {
|
||||
|
||||
<Notice>
|
||||
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
|
||||
「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走;
|
||||
改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user