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