feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。 - 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false) - 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」 - 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力** - 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线 - Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义) - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
This commit is contained in:
1 parent
6dc1f218d5
commit
2aa3233f84
20 files changed
+1042
-61
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
} from "./lib/api";
|
||||
import type { SessionMeta } from "./lib/types";
|
||||
import { useAppStore } from "./store/useAppStore";
|
||||
import { personaMoodSeed } from "./store/personaSelectors";
|
||||
|
||||
export default function App(): React.ReactNode {
|
||||
// Apply the persisted (dark-first) theme and open the realtime channel only
|
||||
@@ -23,6 +24,7 @@ export default function App(): React.ReactNode {
|
||||
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const setPersonas = useAppStore((s) => s.setPersonas);
|
||||
const seedMoods = useAppStore((s) => s.seedMoods);
|
||||
|
||||
useEffect(() => {
|
||||
initTheme();
|
||||
@@ -37,12 +39,18 @@ export default function App(): React.ReactNode {
|
||||
|
||||
// Load the role catalog (design/22 §4.6). The rendered role is no longer one
|
||||
// global value: it is derived per active session from this catalog plus that
|
||||
// session's own `personaId` (see useAppStore's selectCurrentPersona), so this
|
||||
// fetch only has to fill the catalog. It must not hinge on a single mount-time
|
||||
// session's own `personaId` (see store/personaSelectors), so this fetch only has
|
||||
// to fill the catalog. It must not hinge on a single mount-time
|
||||
// request: a token may appear (login) after mount, or the fetch may transiently
|
||||
// fail — in which case the avatar/name would stay on the neutral placeholder. We
|
||||
// re-run when the token appears, and retry on failure. The catalog is persisted
|
||||
// by useAppStore, so a refresh renders the right role before the fetch returns.
|
||||
//
|
||||
// The catalog also carries each role's *role-level* mood (design/01 §2: emotion
|
||||
// follows the role), which seeds the mood bucket of every role it names — that is
|
||||
// what stops the 心情 badge from flashing empty after a refresh. Seeding merges
|
||||
// (see `seedMoods`): a role the backend reports no mood for is skipped, so a WS
|
||||
// `mood` frame that landed first is never cleared.
|
||||
const token = getAuthToken();
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -55,6 +63,7 @@ export default function App(): React.ReactNode {
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setPersonas(list);
|
||||
seedMoods(personaMoodSeed(list));
|
||||
})
|
||||
.catch(() => {
|
||||
// Transient failure — retry rather than leaving the placeholder forever.
|
||||
@@ -67,7 +76,7 @@ export default function App(): React.ReactNode {
|
||||
cancelled = true;
|
||||
if (retry !== null) window.clearTimeout(retry);
|
||||
};
|
||||
}, [token, setPersonas]);
|
||||
}, [token, setPersonas, seedMoods]);
|
||||
|
||||
const sessions = useAppStore((s) => s.sessions);
|
||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api";
|
||||
import type { PersonaRecord } from "../../lib/types";
|
||||
import type { PersonaRecord, PersonaUpsertRequest } from "../../lib/types";
|
||||
import {
|
||||
EMPTY_THINKING_DRAFT,
|
||||
thinkingDraftFrom,
|
||||
thinkingDraftToRequest,
|
||||
thinkingValidationError,
|
||||
type ThinkingDraft,
|
||||
} from "../../lib/personaThinking";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { ThinkingFields } from "./personaThinkingForm";
|
||||
|
||||
const columns: AdminColumn<PersonaRecord>[] = [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
@@ -26,9 +34,18 @@ interface Draft {
|
||||
systemPrompt: string;
|
||||
mode: string;
|
||||
memoryNamespace: string;
|
||||
thinking: ThinkingDraft;
|
||||
}
|
||||
|
||||
const emptyDraft: Draft = { id: "", name: "", role: "", systemPrompt: "", mode: "", memoryNamespace: "" };
|
||||
const emptyDraft: Draft = {
|
||||
id: "",
|
||||
name: "",
|
||||
role: "",
|
||||
systemPrompt: "",
|
||||
mode: "",
|
||||
memoryNamespace: "",
|
||||
thinking: EMPTY_THINKING_DRAFT,
|
||||
};
|
||||
|
||||
export function PersonasModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListPersonas, []);
|
||||
@@ -54,19 +71,38 @@ export function PersonasModule(): React.ReactNode {
|
||||
systemPrompt: "",
|
||||
mode: p.mode || "",
|
||||
memoryNamespace: p.memoryNamespace || "",
|
||||
// Backfill from the read side. An absent block yields the empty draft, which
|
||||
// maps back to "omit `thinking`" — so opening a persona that configures no
|
||||
// thinking and saving it leaves the stored block untouched.
|
||||
thinking: thinkingDraftFrom(p.thinking),
|
||||
});
|
||||
|
||||
const create = () => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminCreatePersona({
|
||||
id: draft.id,
|
||||
// Only what the client can know: non-negative integer budget, enumerations.
|
||||
// Whether the active provider honors `effort` is the backend's call (it
|
||||
// validates against the provider whitelist and WARNs), so the panel does not
|
||||
// pretend to gate on it.
|
||||
const thinkingError = thinkingValidationError(draft.thinking);
|
||||
|
||||
/** Body shared by create/update. `thinking` is included only when the form
|
||||
* configures something — an omitted object keeps whatever is stored, so a
|
||||
* panel that ships zeros would never wipe a hand-edited block. */
|
||||
const payload = (): PersonaUpsertRequest => {
|
||||
const req: PersonaUpsertRequest = {
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
systemPrompt: draft.systemPrompt,
|
||||
mode: draft.mode,
|
||||
memoryNamespace: draft.memoryNamespace,
|
||||
})
|
||||
};
|
||||
const thinking = thinkingDraftToRequest(draft.thinking);
|
||||
if (thinking) req.thinking = thinking;
|
||||
return req;
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminCreatePersona({ id: draft.id, ...payload() })
|
||||
.then((p) => {
|
||||
log("persona.create", "ok");
|
||||
setCreating(false);
|
||||
@@ -85,13 +121,7 @@ export function PersonasModule(): React.ReactNode {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminUpdatePersona(selected.id, {
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
systemPrompt: draft.systemPrompt,
|
||||
mode: draft.mode,
|
||||
memoryNamespace: draft.memoryNamespace,
|
||||
})
|
||||
adminUpdatePersona(selected.id, payload())
|
||||
.then((p) => {
|
||||
log("persona.edit", "ok");
|
||||
setSelected(p);
|
||||
@@ -154,8 +184,16 @@ export function PersonasModule(): React.ReactNode {
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<ThinkingFields
|
||||
value={draft.thinking}
|
||||
onChange={(thinking) => setDraft({ ...draft, thinking })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || !draft.id || !draft.name || !draft.role} onClick={create}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !draft.id || !draft.name || !draft.role || Boolean(thinkingError)}
|
||||
onClick={create}
|
||||
>
|
||||
{busy ? "创建中…" : "创建"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => setCreating(false)}>
|
||||
@@ -186,8 +224,16 @@ export function PersonasModule(): React.ReactNode {
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<ThinkingFields
|
||||
value={draft.thinking}
|
||||
onChange={(thinking) => setDraft({ ...draft, thinking })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || !draft.name || !draft.role} onClick={save}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !draft.name || !draft.role || Boolean(thinkingError)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => setSelected(null)}>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ThinkingDraft } from "../../lib/personaThinking";
|
||||
import {
|
||||
THINKING_EFFORTS,
|
||||
THINKING_MODES,
|
||||
thinkingValidationError,
|
||||
} from "../../lib/personaThinking";
|
||||
import { Tag } from "../../components/primitives";
|
||||
|
||||
/** The panel's four `thinking` controls (design/01 §2.1). Split out of Personas.tsx
|
||||
* (which is near its size ceiling) and kept dumb: it renders + validates the form
|
||||
* draft, the request mapping lives in lib/personaThinking.ts.
|
||||
*
|
||||
* It deliberately does NOT claim a capability: `effort` is offered from the union
|
||||
* of the values the providers behind the API recognize, but whether the *active*
|
||||
* provider honors the value is decided by the backend (it validates against the
|
||||
* provider whitelist and WARNs when it drops one). The hint says so instead of
|
||||
* pretending the knob is on. */
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
|
||||
export function ThinkingFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ThinkingDraft;
|
||||
onChange: (next: ThinkingDraft) => void;
|
||||
}): React.ReactNode {
|
||||
const set = <K extends keyof ThinkingDraft>(key: K, v: ThinkingDraft[K]) =>
|
||||
onChange({ ...value, [key]: v });
|
||||
const err = thinkingValidationError(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-3)]/40 px-3 py-3">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-semibold">思考配置 thinking</span>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
只描述呈现/资源,不构成能力边界(design/01 §2.1);留空 = 不配置
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">预算 budgetTokens(非负整数,留空 = 不配置)</span>
|
||||
<input
|
||||
value={value.budgetTokens}
|
||||
inputMode="numeric"
|
||||
placeholder="4096"
|
||||
onChange={(e) => set("budgetTokens", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">强度 effort(可留空)</span>
|
||||
<select
|
||||
value={value.effort}
|
||||
onChange={(e) => set("effort", e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">(不配置)</option>
|
||||
{THINKING_EFFORTS.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
{/* A stored value outside the list is shown as-is rather than being
|
||||
* silently displayed as "unconfigured" (Docs/api-client.md §1.1: an
|
||||
* unknown value is never dropped or replaced by a placeholder). The
|
||||
* validation error then asks the user to pick a recognized one. */}
|
||||
{value.effort && !(THINKING_EFFORTS as readonly string[]).includes(value.effort) ? (
|
||||
<option value={value.effort}>{value.effort}(未知取值,后端会 WARN 并忽略)</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
回显 showToUser(未配置 = 保持现行为:思考帧照常推给客户端)
|
||||
</span>
|
||||
<select
|
||||
value={value.showToUser}
|
||||
onChange={(e) => set("showToUser", e.target.value as ThinkingDraft["showToUser"])}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="unset">未配置(保持现行为)</option>
|
||||
<option value="on">开(推帧)</option>
|
||||
<option value="off">关(只落库不回显)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">开关 mode(硬开关,推理)</span>
|
||||
<select
|
||||
value={value.mode}
|
||||
onChange={(e) => set("mode", e.target.value as ThinkingDraft["mode"])}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">(未配置)</option>
|
||||
{THINKING_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
effort 的有效取值随当前生效 provider 而定(OpenAI 兼容系
|
||||
none/minimal/low/medium/high/xhigh/max;DeepSeek none/low/high/max,别名
|
||||
minimal→low、medium→high、xhigh→high、ultra→max;未知 provider 不支持)。面板只做前端枚举校验;
|
||||
该值在上游是否真生效,由后端按 provider 校验并记 WARN 决定,此处不代为承诺。
|
||||
</p>
|
||||
{err ? (
|
||||
<Tag tone="err">{err}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import { getAuthToken } from "../lib/api";
|
||||
import { labelFor } from "../lib/enums";
|
||||
import { personaAvatar } from "../lib/persona";
|
||||
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
|
||||
import { Icon } from "./Icon";
|
||||
import { NavItem } from "./NavItem";
|
||||
import { SessionRail } from "./SessionRail";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import { personaAvatar } from "../lib/persona";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
|
||||
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentPersona } from "../store/personaSelectors";
|
||||
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
|
||||
import { labelFor } from "../lib/enums";
|
||||
import { Tag } from "./primitives";
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Logic test for the panel's `thinking` form ↔ request mapping (design/01 §2.1,
|
||||
// v0.43 read side). The two rules it must not lose are the tri-state of
|
||||
// `showToUser` (omitted = 未配置 = keep the current behavior; never flattened into
|
||||
// `false`) and the omission rule: a form that configures nothing must leave the
|
||||
// `thinking` key OUT of the body, because the write side reads an omitted object
|
||||
// as "keep the stored block" (an all-default object would CLEAR it). The project
|
||||
// has no frontend test runner and no jsdom, so this is a dependency-free
|
||||
// plain-node check driving the real module (lib/personaThinking.ts) and throwing
|
||||
// on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/personaThinking.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-persona-thinking.test.mjs \
|
||||
// && node /tmp/cyrene-persona-thinking.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import {
|
||||
EMPTY_THINKING_DRAFT,
|
||||
THINKING_EFFORTS,
|
||||
thinkingDraftFrom,
|
||||
thinkingDraftToRequest,
|
||||
thinkingValidationError,
|
||||
type PersonaThinking,
|
||||
type ThinkingDraft,
|
||||
} from "./personaThinking";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
/* --- 1. Backfill from the read side ----------------------------------------- */
|
||||
|
||||
// An absent block (persona configures no thinking) reads as the empty draft.
|
||||
same(thinkingDraftFrom(undefined), EMPTY_THINKING_DRAFT, "absent read block → empty draft");
|
||||
same(thinkingDraftFrom(null), EMPTY_THINKING_DRAFT, "null read block → empty draft");
|
||||
|
||||
// A configured block backfills field by field, including the tri-state arm.
|
||||
const read: PersonaThinking = { budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" };
|
||||
same(
|
||||
thinkingDraftFrom(read),
|
||||
{ budgetTokens: "4096", effort: "high", showToUser: "off", mode: "off" },
|
||||
"configured block backfills (explicit false stays 关)"
|
||||
);
|
||||
same(
|
||||
thinkingDraftFrom({ budgetTokens: 0, effort: "", showToUser: null, mode: "" }),
|
||||
EMPTY_THINKING_DRAFT,
|
||||
"an all-unset read block backfills to the empty draft"
|
||||
);
|
||||
same(
|
||||
thinkingDraftFrom({ budgetTokens: 1024, effort: "", showToUser: null, mode: "" }),
|
||||
{ budgetTokens: "1024", effort: "", showToUser: "unset", mode: "" },
|
||||
"null showToUser backfills as 未配置, not 关"
|
||||
);
|
||||
// An out-of-list stored value is preserved (never silently blanked).
|
||||
same(
|
||||
thinkingDraftFrom({ budgetTokens: 0, effort: "tiny", showToUser: null, mode: "always" }).effort,
|
||||
"tiny",
|
||||
"an unknown stored effort is preserved for display"
|
||||
);
|
||||
|
||||
/* --- 2. Form → request body, incl. tri-state and omission -------------------- */
|
||||
|
||||
// The omission rule: nothing configured → no `thinking` key at all on the wire.
|
||||
const untouched = thinkingDraftToRequest(EMPTY_THINKING_DRAFT);
|
||||
ok(untouched === undefined, "an untouched form maps to undefined (omit the block)");
|
||||
same(
|
||||
JSON.stringify({ name: "昔涟", thinking: untouched }),
|
||||
JSON.stringify({ name: "昔涟" }),
|
||||
"an untouched form leaves no `thinking` key in the request body (省略保留)"
|
||||
);
|
||||
// …and a whitespace-only budget is still "untouched", not budget 0.
|
||||
ok(
|
||||
thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: " " }) === undefined,
|
||||
"a whitespace-only budget is treated as unconfigured"
|
||||
);
|
||||
|
||||
// The tri-state of showToUser, verbatim on the wire.
|
||||
const unset = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: "2048" });
|
||||
ok(unset !== undefined, "a budget-only form still maps to a request");
|
||||
ok(!("showToUser" in (unset as object)), "showToUser:未配置 omits the key (stays nil, not false)");
|
||||
same(unset?.showToUser, undefined, "showToUser:未配置 is undefined, never false");
|
||||
|
||||
const off = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "off" });
|
||||
same(off, { showToUser: false }, "showToUser:关 maps to the boolean false");
|
||||
same(JSON.stringify(off), JSON.stringify({ showToUser: false }), "an explicit false survives JSON.stringify");
|
||||
|
||||
const on = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "on" });
|
||||
same(on, { showToUser: true }, "showToUser:开 maps to the boolean true");
|
||||
|
||||
// Every other knob maps only when set.
|
||||
same(
|
||||
thinkingDraftToRequest({ budgetTokens: "4096", effort: "medium", showToUser: "unset", mode: "on" }),
|
||||
{ budgetTokens: 4096, effort: "medium", mode: "on" },
|
||||
"set knobs map, unset ones are omitted"
|
||||
);
|
||||
same(
|
||||
thinkingDraftToRequest({ budgetTokens: "0", effort: "", showToUser: "unset", mode: "" }),
|
||||
{ budgetTokens: 0 },
|
||||
"an explicit 0 budget is a configured value (0 = 未设 for the backend)"
|
||||
);
|
||||
|
||||
/* --- 3. Round-trip: read → form → request ---------------------------------- */
|
||||
|
||||
// What the panel sends back for a persona it just loaded must describe the same
|
||||
// stored block, so opening a persona and saving it changes nothing.
|
||||
const cases: PersonaThinking[] = [
|
||||
{ budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" },
|
||||
{ budgetTokens: 2048, effort: "", showToUser: null, mode: "" },
|
||||
{ budgetTokens: 0, effort: "", showToUser: true, mode: "on" },
|
||||
{ budgetTokens: 0, effort: "low", showToUser: null, mode: "default" },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const back = thinkingDraftToRequest(thinkingDraftFrom(c));
|
||||
// Built in the mapper's own key order (JSON.stringify is order-sensitive here).
|
||||
const want: Record<string, unknown> = {};
|
||||
if (c.budgetTokens) want.budgetTokens = c.budgetTokens;
|
||||
if (c.effort) want.effort = c.effort;
|
||||
if (c.mode) want.mode = c.mode;
|
||||
if (c.showToUser !== null) want.showToUser = c.showToUser;
|
||||
same(back, want, `round-trip ${JSON.stringify(c)}`);
|
||||
}
|
||||
|
||||
/* --- 4. Input validation (only what the client can know) -------------------- */
|
||||
|
||||
same(thinkingValidationError(EMPTY_THINKING_DRAFT), null, "empty draft is valid");
|
||||
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096" }), null, "4096 is valid");
|
||||
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "0" }), null, "0 is valid (non-negative)");
|
||||
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "-1" }) !== null, "a negative budget is rejected");
|
||||
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "1.5" }) !== null, "a fractional budget is rejected");
|
||||
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096t" }) !== null, "a non-numeric budget is rejected");
|
||||
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "on" }), null, "mode on is valid");
|
||||
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "default" }), null, "mode default is valid");
|
||||
ok(
|
||||
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "always" as ThinkingDraft["mode"] }) !== null,
|
||||
"an unknown mode is rejected"
|
||||
);
|
||||
for (const e of THINKING_EFFORTS) {
|
||||
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: e }), null, `effort ${e} is valid`);
|
||||
}
|
||||
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "tiny" }) !== null, "an unknown effort is rejected");
|
||||
|
||||
// The panel must not claim a capability: no error is raised merely because the
|
||||
// value may be unsupported by the *active* provider (the backend WARNs instead).
|
||||
same(
|
||||
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "none", mode: "off" }),
|
||||
null,
|
||||
"a provider-dependent combination is not blocked client-side"
|
||||
);
|
||||
|
||||
console.log(`personaThinking: ${checks} checks passed`);
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Persona `thinking` config — the wire shapes and the panel's form mapping
|
||||
* (design/01 §2.1; provider mapping in design/04 §3.2).
|
||||
*
|
||||
* Kept out of lib/types.ts (which is at its size ceiling) and, more importantly,
|
||||
* out of the panel component: the mapping is pure, so the tri-state rules are
|
||||
* testable without a browser.
|
||||
*
|
||||
* Two rules the read/write sides share:
|
||||
* - **`showToUser` is tri-state all the way.** `null`/absent = 未配置 = keep the
|
||||
* current behavior (thinking frames keep flowing); `true`/`false` = an explicit
|
||||
* choice. Flattening "unset" onto `false` would silently turn display off.
|
||||
* - **An all-unset block is omitted, not sent as zeros.** The write side reads an
|
||||
* omitted `thinking` object as "keep the stored one", so a panel that ships
|
||||
* defaults would wipe a hand-edited config.
|
||||
*/
|
||||
|
||||
/** Read shape of the `thinking` block (`GET /api/v1/admin/personas`, v0.43). The
|
||||
* key is absent entirely when the persona configures none. */
|
||||
export interface PersonaThinking {
|
||||
budgetTokens: number;
|
||||
effort: string;
|
||||
/** `null` = 未配置(保持现行为), `true`/`false` = 显式选择. */
|
||||
showToUser: boolean | null;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/** Write shape (`POST`/`PUT /api/v1/admin/personas`). An omitted key = unset; an
|
||||
* omitted whole object = keep the stored block. */
|
||||
export interface PersonaThinkingRequest {
|
||||
budgetTokens?: number;
|
||||
effort?: string;
|
||||
showToUser?: boolean;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
/** Explicit reasoning switch, matching the backend's `ThinkingMode` constants
|
||||
* (`internal/persona/loader`). */
|
||||
export const THINKING_MODES = ["default", "on", "off"] as const;
|
||||
|
||||
/** `reasoning_effort` values the model layer recognizes across the providers it
|
||||
* maps (design/04 §3.2). The **effective whitelist still depends on the active
|
||||
* provider** — OpenAI-compatible takes `none…max`; DeepSeek takes
|
||||
* `none/low/high/max` and normalizes the aliases (`minimal→low`, `medium→high`,
|
||||
* `xhigh→high`, `ultra→max`) — and only the backend can see which provider is in
|
||||
* play, so this list drives a select, not a promise. `ultra` is listed because
|
||||
* DeepSeek accepts it as an alias; on an OpenAI-compatible provider the backend
|
||||
* drops it and WARNs. */
|
||||
export const THINKING_EFFORTS = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
] as const;
|
||||
|
||||
/** Form state: everything is a string so a half-typed number never coerces to 0
|
||||
* and the tri-state select has an explicit "unset" arm. */
|
||||
export interface ThinkingDraft {
|
||||
budgetTokens: string;
|
||||
effort: string;
|
||||
showToUser: "unset" | "on" | "off";
|
||||
mode: "" | (typeof THINKING_MODES)[number];
|
||||
}
|
||||
|
||||
export const EMPTY_THINKING_DRAFT: ThinkingDraft = {
|
||||
budgetTokens: "",
|
||||
effort: "",
|
||||
showToUser: "unset",
|
||||
mode: "",
|
||||
};
|
||||
|
||||
/** Backfill the form from the read side. An absent block (and an unknown mode)
|
||||
* yields the empty draft, which maps back to "omitted" — so opening a persona
|
||||
* that configures no thinking and saving it changes nothing. */
|
||||
export function thinkingDraftFrom(read: PersonaThinking | null | undefined): ThinkingDraft {
|
||||
if (!read) return EMPTY_THINKING_DRAFT;
|
||||
return {
|
||||
budgetTokens: read.budgetTokens ? String(read.budgetTokens) : "",
|
||||
effort: read.effort ?? "",
|
||||
showToUser: read.showToUser === true ? "on" : read.showToUser === false ? "off" : "unset",
|
||||
mode: (THINKING_MODES as readonly string[]).includes(read.mode)
|
||||
? (read.mode as ThinkingDraft["mode"])
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Input validation only — what the client can actually know. Non-negative
|
||||
* integer budget, enumerations for mode/effort. Whether the provider really
|
||||
* honors the value is the backend's call (it validates against the active
|
||||
* provider's whitelist and WARNs when it drops one); the panel does not pretend
|
||||
* to know that, so it does not block on it. Returns an error message, or null. */
|
||||
export function thinkingValidationError(draft: ThinkingDraft): string | null {
|
||||
const budget = draft.budgetTokens.trim();
|
||||
if (budget !== "") {
|
||||
if (!/^\d+$/.test(budget)) return "思考预算必须是非负整数";
|
||||
if (Number(budget) > Number.MAX_SAFE_INTEGER) return "思考预算过大";
|
||||
}
|
||||
if (draft.mode !== "" && !(THINKING_MODES as readonly string[]).includes(draft.mode)) {
|
||||
return `思考开关取值非法:${draft.mode}(仅 default/on/off)`;
|
||||
}
|
||||
if (draft.effort !== "" && !(THINKING_EFFORTS as readonly string[]).includes(draft.effort)) {
|
||||
return `思考强度取值非法:${draft.effort}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether the form holds anything at all. An untouched form must not be sent:
|
||||
* the write side treats an omitted `thinking` object as "keep the stored block". */
|
||||
export function thinkingDraftIsEmpty(draft: ThinkingDraft): boolean {
|
||||
return (
|
||||
draft.budgetTokens.trim() === "" &&
|
||||
draft.effort === "" &&
|
||||
draft.mode === "" &&
|
||||
draft.showToUser === "unset"
|
||||
);
|
||||
}
|
||||
|
||||
/** Map the form onto the request body. Returns `undefined` when nothing is
|
||||
* configured — the caller then omits `thinking` entirely, preserving whatever is
|
||||
* stored (write-side semantics, unchanged). `showToUser: "unset"` omits the key
|
||||
* so it stays `nil` in the backend, never a flattened `false`. Callers validate
|
||||
* with `thinkingValidationError` first. */
|
||||
export function thinkingDraftToRequest(draft: ThinkingDraft): PersonaThinkingRequest | undefined {
|
||||
if (thinkingDraftIsEmpty(draft)) return undefined;
|
||||
const req: PersonaThinkingRequest = {};
|
||||
const budget = draft.budgetTokens.trim();
|
||||
if (budget !== "") req.budgetTokens = Number(budget);
|
||||
if (draft.effort !== "") req.effort = draft.effort;
|
||||
if (draft.mode !== "") req.mode = draft.mode;
|
||||
if (draft.showToUser !== "unset") req.showToUser = draft.showToUser === "on";
|
||||
return req;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
|
||||
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
|
||||
|
||||
import type { PersonaThinking, PersonaThinkingRequest } from "./personaThinking";
|
||||
|
||||
/** The 9 behavior modes. Switching only changes the presentation layer. */
|
||||
export type Mode =
|
||||
| "chat"
|
||||
@@ -218,6 +220,14 @@ export interface PersonaRecord {
|
||||
avatar?: string;
|
||||
/** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */
|
||||
chatBackground?: { landscape?: string; portrait?: string };
|
||||
/** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows
|
||||
* the role, never a session, so every session of a role shows the same value).
|
||||
* `""`/absent = the role has no tracked emotion; the UI keeps its neutral
|
||||
* placeholder rather than inventing one. Used to seed the mood badge on load. */
|
||||
mood?: string;
|
||||
/** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`)
|
||||
* only; absent when the persona configures none. */
|
||||
thinking?: PersonaThinking;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -557,6 +567,11 @@ export interface PersonaUpsertRequest {
|
||||
modes?: string[];
|
||||
mode?: string;
|
||||
memoryNamespace?: string;
|
||||
/** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when
|
||||
* the form configures nothing**: an omitted object keeps the stored block,
|
||||
* while an all-default object clears it. `showToUser` stays tri-state — omit it
|
||||
* (= not configured) rather than sending `false`. */
|
||||
thinking?: PersonaThinkingRequest;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentPersona } from "../store/personaSelectors";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { useSendChat } from "../lib/useChat";
|
||||
import { sendUp } from "../lib/ws";
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Logic test for seeding the mood badge from the role catalog (v0.43 read side,
|
||||
// design/01 §2): `GET /personas` reports each role's *role-level* mood, and the
|
||||
// frontend seeds `moods[roleId]` from it on load so a refresh no longer flashes an
|
||||
// empty 心情 badge. The project has no frontend test runner and no jsdom, so this
|
||||
// is a dependency-free plain-node check driving the real seed helper
|
||||
// (store/personaSelectors.ts), the real store and the real WS frame router
|
||||
// (lib/ws.ts), and throwing on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change); the banner supplies the `window.localStorage` the store's
|
||||
// persist middleware probes, which is simply absent under node:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/store/personaMoodSeed.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-persona-mood-seed.test.mjs \
|
||||
// && node /tmp/cyrene-persona-mood-seed.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import type { PersonaRecord, SessionMeta } from "../lib/types";
|
||||
import { personaMoodSeed, selectCurrentMood } from "./personaSelectors";
|
||||
import { useAppStore } from "./useAppStore";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
const SUB_A = "web:sub-a";
|
||||
const SUB_B = "web:sub-b";
|
||||
|
||||
/** A catalog shaped like `GET /api/v1/personas` v0.43: 昔涟 has a tracked mood,
|
||||
* 雪 has none (the backend reports ""), and the third role carries no mood field
|
||||
* at all (an older backend). */
|
||||
const CATALOG: PersonaRecord[] = [
|
||||
{ id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene", mood: "happy" },
|
||||
{ id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki", mood: "" },
|
||||
{ id: "old", name: "旧端点", character: "old", isDefault: false, mode: "chat", memoryNamespace: "persona.old" },
|
||||
];
|
||||
|
||||
/** Main session → default role (昔涟); sub-a → 雪; sub-b → default again. */
|
||||
function sessions(): Record<string, SessionMeta> {
|
||||
return {
|
||||
[MAIN]: { id: MAIN, type: "main", createdAt: 1 },
|
||||
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" },
|
||||
[SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" },
|
||||
};
|
||||
}
|
||||
|
||||
/** 1. The pure seed: role id → role-level mood, and nothing for a role the
|
||||
* backend reported no mood for. */
|
||||
function testSeedShape(): void {
|
||||
same(personaMoodSeed(CATALOG), { cyrene: "happy" }, "only roles with a mood are seeded");
|
||||
same(personaMoodSeed([]), {}, "an empty catalog seeds nothing");
|
||||
same(personaMoodSeed([{ ...CATALOG[0], mood: "" }]), {}, "an empty mood seeds nothing (never a placeholder)");
|
||||
same(personaMoodSeed([{ ...CATALOG[0], id: "" , mood: "happy" }]), {}, "a role without an id seeds nothing");
|
||||
// Seeding is keyed by role id, so it can never key onto a session.
|
||||
ok(!Object.keys(personaMoodSeed(CATALOG)).includes(MAIN), "no session id ever becomes a mood bucket");
|
||||
}
|
||||
|
||||
/** 2. The refresh scenario this batch exists for: after loading the catalog the
|
||||
* active role's mood is already known — no blank badge while the next WS frame
|
||||
* is awaited. */
|
||||
function testSeedFillsBadgeOnLoad(): void {
|
||||
useAppStore.setState({ moods: {}, personas: [], sessions: {}, activeSessionId: "" });
|
||||
same(selectCurrentMood(useAppStore.getState()), null, "before the catalog loads there is no mood");
|
||||
|
||||
useAppStore.setState({ personas: CATALOG, sessions: sessions(), activeSessionId: MAIN });
|
||||
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
|
||||
same(useAppStore.getState().moods, { cyrene: "happy" }, "the catalog's role mood lands in its own bucket");
|
||||
same(selectCurrentMood(useAppStore.getState()), "happy", "the badge is filled on load (main session → 昔涟)");
|
||||
}
|
||||
|
||||
/** 3. The mood is role-level: two sessions of the same role show the same mood,
|
||||
* and switching to another role's session shows that role's own value. */
|
||||
function testMoodIsRoleLevel(): void {
|
||||
useAppStore.setState({
|
||||
moods: {},
|
||||
personas: CATALOG,
|
||||
sessions: sessions(),
|
||||
activeSessionId: MAIN,
|
||||
});
|
||||
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
|
||||
|
||||
same(selectCurrentMood(useAppStore.getState()), "happy", "main session (default role 昔涟) shows happy");
|
||||
useAppStore.setState({ activeSessionId: SUB_B });
|
||||
same(selectCurrentMood(useAppStore.getState()), "happy", "another session of the same role shows the SAME mood");
|
||||
useAppStore.setState({ activeSessionId: SUB_A });
|
||||
same(selectCurrentMood(useAppStore.getState()), null, "a role with no tracked mood shows nothing (no invented placeholder)");
|
||||
|
||||
// A live frame for the unseeded role fills it, and the role-scoped keying keeps
|
||||
// the two roles apart.
|
||||
dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "yuki" } });
|
||||
same(selectCurrentMood(useAppStore.getState()), "sad", "a mood frame updates the active role's badge");
|
||||
useAppStore.setState({ activeSessionId: MAIN });
|
||||
same(selectCurrentMood(useAppStore.getState()), "happy", "the other role's badge is untouched");
|
||||
}
|
||||
|
||||
/** 4. Seeding merges: it overwrites with the server's truth but never clears a
|
||||
* bucket, so a WS frame that arrived before the catalog loaded survives. */
|
||||
function testSeedMergesAndNeverClears(): void {
|
||||
useAppStore.setState({ moods: { yuki: "excited" }, personas: [], sessions: {}, activeSessionId: MAIN });
|
||||
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
|
||||
same(useAppStore.getState().moods, { yuki: "excited", cyrene: "happy" }, "an unmentioned bucket is kept, the seeded one added");
|
||||
|
||||
// The server is the truth for a role it does report a mood for.
|
||||
useAppStore.setState({ moods: { cyrene: "angry" } });
|
||||
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
|
||||
same(useAppStore.getState().moods.cyrene, "happy", "a reported role mood overwrites the stale local value");
|
||||
|
||||
// "" / absent (an untracked role) must not wipe a live bucket.
|
||||
useAppStore.setState({ moods: { yuki: "excited" } });
|
||||
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
|
||||
same(useAppStore.getState().moods.yuki, "excited", "a role the seed skips keeps its bucket");
|
||||
|
||||
// An empty seed is a no-op (no needless state churn).
|
||||
const before = useAppStore.getState().moods;
|
||||
useAppStore.getState().seedMoods({});
|
||||
ok(useAppStore.getState().moods === before, "an empty seed leaves the state object untouched");
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
testSeedShape();
|
||||
testSeedFillsBadgeOnLoad();
|
||||
testMoodIsRoleLevel();
|
||||
testSeedMergesAndNeverClears();
|
||||
console.log(`personaMoodSeed.test: ${checks} checks passed`);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,50 @@
|
||||
import { resolveSessionPersona } from "../lib/persona";
|
||||
import type { PersonaRecord } from "../lib/types";
|
||||
import { useAppStore, type AppStore } from "./useAppStore";
|
||||
|
||||
/** The role the UI renders (design/22 §4.6): the active session's bound role,
|
||||
* else the global default. THE single resolution point — the avatar, name,
|
||||
* wallpaper, mood bucket and message labels all read it, so none of them can
|
||||
* disagree about "who is speaking" (design/22 §5 risk row 1).
|
||||
*
|
||||
* Lives in its own module (not in useAppStore.ts, which is at its size ceiling)
|
||||
* so the store file keeps holding state/actions only. */
|
||||
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
|
||||
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
|
||||
}
|
||||
|
||||
/** The active role's mood (design/22 §4.6). `mood` frames are role-scoped and
|
||||
* carry no sessionId, so a role's mood is only shown above its own sessions —
|
||||
* the bucket is keyed by the *resolved* role id, never a global value. */
|
||||
export function selectCurrentMood(s: AppStore): string | null {
|
||||
const personaId = selectCurrentPersona(s)?.id ?? "";
|
||||
return personaId ? s.moods[personaId] ?? null : null;
|
||||
}
|
||||
|
||||
/** Subscribe to the active session's role. */
|
||||
export function useCurrentPersona(): PersonaRecord | null {
|
||||
return useAppStore(selectCurrentPersona);
|
||||
}
|
||||
|
||||
/** Subscribe to the active role's mood. */
|
||||
export function useCurrentMood(): string | null {
|
||||
return useAppStore(selectCurrentMood);
|
||||
}
|
||||
|
||||
/** Mood buckets to seed from the role catalog (`GET /api/v1/personas`): the role
|
||||
* id → its current role-level mood (design/01 §2: mood follows the role).
|
||||
*
|
||||
* Two rules, both about not lying with a placeholder:
|
||||
* - a role the backend reports no mood for (`""`/absent) contributes NOTHING, so
|
||||
* seeding never clears a bucket a WS `mood` frame already filled;
|
||||
* - only roles the catalog actually lists are seeded, so no bucket is invented.
|
||||
*
|
||||
* Pure and exported so the seeding rule is testable without a browser. */
|
||||
export function personaMoodSeed(personas: PersonaRecord[]): Record<string, string> {
|
||||
const seed: Record<string, string> = {};
|
||||
for (const p of personas) {
|
||||
if (p.id && p.mood) seed[p.id] = p.mood;
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
|
||||
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { selectCurrentPersona, useAppStore } from "./useAppStore";
|
||||
import { selectCurrentPersona } from "./personaSelectors";
|
||||
import { useAppStore } from "./useAppStore";
|
||||
import type { PersonaRecord, SessionMeta } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
ToolUsed,
|
||||
WsStatus,
|
||||
} from "../lib/types";
|
||||
import { resolveSessionPersona } from "../lib/persona";
|
||||
import { now, uid } from "../lib/util";
|
||||
|
||||
interface ChatContext {
|
||||
@@ -37,7 +36,8 @@ export interface AppStore {
|
||||
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
|
||||
* and the UI shows the bucket of the *active session's* role — role X's mood
|
||||
* must not sit above role Y's conversation. `""` holds events that arrived
|
||||
* without a persona (legacy/unscoped frames). */
|
||||
* without a persona (legacy/unscoped frames). Seeded from the role catalog's
|
||||
* role-level mood on load (`seedMoods` + `personaMoodSeed`). */
|
||||
moods: Record<string, string>;
|
||||
context: ChatContext;
|
||||
agents: AgentInfo[];
|
||||
@@ -72,6 +72,12 @@ export interface AppStore {
|
||||
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
|
||||
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
|
||||
setMood: (mood: string | null, personaId?: string) => void;
|
||||
/** Merge role-level moods read from `GET /personas` into the buckets (design/01
|
||||
* §2: mood follows the role, so the catalog is an authoritative seed). Values
|
||||
* already known are overwritten by the server's truth; buckets the seed does
|
||||
* not mention are left alone, so a WS frame that arrived first is never
|
||||
* cleared. Build the seed with `personaMoodSeed`. */
|
||||
seedMoods: (seed: Record<string, string>) => void;
|
||||
|
||||
/** Replace the session metadata list. */
|
||||
setSessions: (list: SessionMeta[]) => void;
|
||||
@@ -166,6 +172,8 @@ export const useAppStore = create<AppStore>()(
|
||||
else delete moods[personaId];
|
||||
return { moods };
|
||||
}),
|
||||
seedMoods: (seed) =>
|
||||
set((s) => (Object.keys(seed).length ? { moods: { ...s.moods, ...seed } } : s)),
|
||||
|
||||
setSessions: (list) =>
|
||||
set({
|
||||
@@ -450,26 +458,6 @@ export const useAppStore = create<AppStore>()(
|
||||
)
|
||||
);
|
||||
|
||||
/** The role the UI renders (design/22 §4.6): the active session's bound role,
|
||||
* else the global default. THE single resolution point — the avatar, name,
|
||||
* wallpaper, mood bucket and message labels all read it, so none of them can
|
||||
* disagree about "who is speaking" (design/22 §5 risk row 1). */
|
||||
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
|
||||
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
|
||||
}
|
||||
|
||||
/** Subscribe to the active session's role. */
|
||||
export function useCurrentPersona(): PersonaRecord | null {
|
||||
return useAppStore(selectCurrentPersona);
|
||||
}
|
||||
|
||||
/** Subscribe to the active role's mood. `mood` frames are role-scoped and carry
|
||||
* no sessionId, so a role's mood is only shown above its own sessions. */
|
||||
export function useCurrentMood(): string | null {
|
||||
const personaId = useAppStore((s) => selectCurrentPersona(s)?.id ?? "");
|
||||
return useAppStore((s) => (personaId ? s.moods[personaId] ?? null : null));
|
||||
}
|
||||
|
||||
/** Convenience selector for the last streaming assistant message id. */
|
||||
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
|
||||
const last = s.messages[s.messages.length - 1];
|
||||
|
||||
Reference in new issue
Block a user