feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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:
AskaEth committed 2026-10-03 20:40:44 +08:00
1 parent 6dc1f218d5
commit 2aa3233f84
20 files changed
+1042 -61

No files matched your search

+10 -22
View File
@@ -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];