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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user