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

+63 -17
View File
@@ -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>
);
}