From beb4c332e6b8d5864ad9824881da5a62d580b1f1 Mon Sep 17 00:00:00 2001 From: moran Date: Wed, 9 Sep 2026 17:50:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E9=80=9A=20Boss=20=E7=9B=B4=E8=81=98?= =?UTF-8?q?=E8=90=BD=E6=A1=A3=E6=8C=82=E8=BD=BD=E4=B8=8E=E7=9C=9F=E5=80=BC?= =?UTF-8?q?=E2=86=92=E6=8A=95=E5=BD=B1=E6=98=A0=E5=B0=84=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=B8=A6=20tools=20=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=BC=BA=E5=BA=A6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落档自动把产物挂进 context_order;创作期用 declare_variable/declare_map 钉目录与分档;真值变更持续重投影。顺带强制 tools 请求 reasoning_effort=none,并支持消息 restart。 Co-authored-by: Cursor --- src/config/api-profiles.ts | 3 +- src/dictate/index.ts | 19 ++ src/dictate/play-bind.ts | 351 ++++++++++++++++++++++++++++++++ src/dictate/prompt.ts | 68 +++---- src/dictate/tools.ts | 92 ++++++++- src/dictate/turn.ts | 105 ++++++++-- src/dictate/types.ts | 1 + src/llm/capabilities.ts | 19 +- src/llm/client.ts | 3 + src/llm/generation-compat.ts | 85 +++++++- src/runtime/phase-runtime.ts | 85 +++++++- src/server/session-manager.ts | 75 ++++++- src/server/web-server.ts | 4 +- src/skills/value-map.ts | 321 +++++++++++++++++++++++++++++ src/skills/variable-catalog.ts | 222 ++++++++++++++++++++ tests/dictate-play-bind.test.ts | 124 +++++++++++ tests/dictate.test.ts | 3 +- tests/generation-compat.test.ts | 111 ++++++++++ tests/value-map.test.ts | 152 ++++++++++++++ web/agent-ui.js | 18 +- web/app.js | 5 +- web/index.html | 6 +- web/settings.html | 3 +- 23 files changed, 1780 insertions(+), 95 deletions(-) create mode 100644 src/dictate/play-bind.ts create mode 100644 src/skills/value-map.ts create mode 100644 src/skills/variable-catalog.ts create mode 100644 tests/dictate-play-bind.test.ts create mode 100644 tests/value-map.test.ts diff --git a/src/config/api-profiles.ts b/src/config/api-profiles.ts index ecb47ba..4d86317 100644 --- a/src/config/api-profiles.ts +++ b/src/config/api-profiles.ts @@ -19,7 +19,8 @@ export type ApiProfile = { model: string; /** * 该配置默认思考强度(写入请求的 reasoning_effort)。 - * 省略 / auto = 不发送;探测永远用 low,不受此项影响。 + * 省略 / auto = 不发送;none = 显式关闭;带 tools 的请求会强制 none。 + * 探测永远用 low,不受此项影响。 */ reasoningEffort?: string; createdAt: string; diff --git a/src/dictate/index.ts b/src/dictate/index.ts index 398403f..e5368fd 100644 --- a/src/dictate/index.ts +++ b/src/dictate/index.ts @@ -19,3 +19,22 @@ export { } from "./context.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { runDictateTurn, type DictateTurnResult } from "./turn.js"; +export { + applyDictatePlayBind, + bindDictateProductsToPlaySpec, + buildDictateContextOrder, + collectDictateBindProducts, + type DictatePlayBindResult, +} from "./play-bind.js"; +export { + VARIABLE_CATALOG_TAG, + parseVariableCatalog, + type VariableCatalogDoc, +} from "../skills/variable-catalog.js"; +export { + VALUE_MAP_TAG, + parseValueMapDoc, + lookupValueMapContent, + reprojectValueMaps, + type ValueMapDoc, +} from "../skills/value-map.js"; diff --git a/src/dictate/play-bind.ts b/src/dictate/play-bind.ts new file mode 100644 index 0000000..1cc24d7 --- /dev/null +++ b/src/dictate/play-bind.ts @@ -0,0 +1,351 @@ +/** + * Boss 直聘式 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 + * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import { + CONTEXT_ORDER_SCHEMA, + CONTEXT_ORDER_TAG, + DIALOGUE_HISTORY_TAG, + WORKER_PERSONA_REF, + mergeContextOrderIntoWorkerSetJson, + renumberContextOrder, + type ContextOrderDoc, + type ContextOrderInsert, + type ContextOrderProjection, + type ContextOrderSlot, +} from "../skills/context-order.js"; +import { + WORKER_SET_DRAFT_TAG, + WORKER_SET_FINAL_TAG, +} from "../skills/creation-units.js"; +import { + defaultPlaySlots, + enabledPlaySlotIds, + parsePlaySlots, + refForSlot, + type PlaySlotsConfig, +} from "../skills/play-slots.js"; +import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { + VARIABLE_CATALOG_TAG, +} from "../skills/variable-catalog.js"; +import { + VALUE_MAP_TAG, + listValueMapTargetTags, +} from "../skills/value-map.js"; +import { + DICTATE_ORDER_META_KEY, + effectiveDictateOrder, + sortDictateProducts, + type DictateProduct, +} from "./types.js"; + +/** 开场白进对话历史,不重复挂进每轮投影 */ +const SKIP_BIND_TAGS = new Set([ + "设计.开场白", + "设计.worker集", + "设计.worker集.草稿", + CONTEXT_ORDER_TAG, + "创作.进料模式", + VARIABLE_CATALOG_TAG, + VALUE_MAP_TAG, +]); + +/** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */ +const STYLE_TAG_RE = /叙事指南|文风|美学|纲领|禁忌|示例|正文组成|回复格式|监控栏|篇幅|结构/; + +export type DictatePlayBindResult = { + workerSetJson: string; + contextOrder: ContextOrderDoc; + boundTags: string[]; + changed: boolean; +}; + +function isStyleProductTag(tag: string): boolean { + return STYLE_TAG_RE.test(tag); +} + +function shouldSkipBindTag(tag: string): boolean { + const t = tag.trim(); + if (!t) return true; + if (SKIP_BIND_TAGS.has(t)) return true; + if (t.startsWith("输出.")) return true; + if (t.startsWith("运行.")) return true; + if (t.startsWith("创作.")) return true; + return false; +} + +/** 从黑板收集 Boss 直聘产物(有正文的 用户.* / 设计.*) */ +export function collectDictateBindProducts(board: Blackboard): DictateProduct[] { + const products: DictateProduct[] = []; + for (const e of board.listTagIndex()) { + if (!e.tag.startsWith("用户.") && !e.tag.startsWith("设计.")) continue; + if (shouldSkipBindTag(e.tag)) continue; + const item = board.getLatestByTag(e.tag); + const content = item?.content?.trim() ?? ""; + if (!content) continue; + const rawOrder = item?.metadata?.[DICTATE_ORDER_META_KEY]; + const order = + typeof rawOrder === "number" && Number.isFinite(rawOrder) + ? rawOrder + : undefined; + products.push({ tag: e.tag, content, order }); + } + return sortDictateProducts(products); +} + +/** + * 真值 + 映射投影固定槽。 + * - 有目录或已有表 → 挂 变量.当前 + * - 映射声明的 target_tag 一律挂上(槽位固定;内容由重投影填充) + * - 已有正文的其它 上下文.* 也挂 + */ +function collectRuntimeStateTags(board: Blackboard): string[] { + const out: string[] = []; + const push = (tag: string) => { + if (!tag || out.includes(tag)) return; + out.push(tag); + }; + + const hasCatalog = Boolean(board.getContentByTag(VARIABLE_CATALOG_TAG)?.trim()); + const hasCurrent = Boolean(board.getContentByTag("变量.当前")?.trim()); + if (hasCatalog || hasCurrent) push("变量.当前"); + + for (const tag of listValueMapTargetTags(board.getContentByTag(VALUE_MAP_TAG))) { + push(tag); + } + + for (const tag of ["上下文.角色态度", "大纲.当前章", "上下文.旁观.状态摘要"]) { + if (board.getContentByTag(tag)?.trim()) push(tag); + } + for (const e of board.listTagIndex()) { + if (!e.tag.startsWith("上下文.")) continue; + if (e.tag === "上下文.定稿摘要") continue; + if (!board.getContentByTag(e.tag)?.trim()) continue; + push(e.tag); + } + return out; +} + +function insert( + order: number, + ref: string, + projection: ContextOrderProjection, + note?: string, +): ContextOrderInsert { + return { order, ref, projection, ...(note ? { note } : {}) }; +} + +function buildGmInserts(params: { + products: DictateProduct[]; + runtimeTags: string[]; + includeStyle: boolean; +}): ContextOrderInsert[] { + const inserts: ContextOrderInsert[] = [ + insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"), + ]; + let o = 1; + for (const p of params.products) { + if (!params.includeStyle && isStyleProductTag(p.tag)) continue; + inserts.push( + insert( + o++, + p.tag, + "full", + `Boss直聘固定产物〔order=${effectiveDictateOrder(p)}〕`, + ), + ); + } + inserts.push( + insert(o++, DIALOGUE_HISTORY_TAG, "summary", "对话历史(按投影裁剪)"), + ); + for (const tag of params.runtimeTags) { + inserts.push( + insert( + o++, + tag, + tag === "变量.当前" ? "fields" : "full", + tag.startsWith("上下文.") || tag.startsWith("大纲.") + ? "映射投影固定槽(内容随真值)" + : undefined, + ), + ); + } + inserts.push(insert(o++, "用户.最新输入", "full")); + return inserts; +} + +function buildNarratorInserts(products: DictateProduct[]): ContextOrderInsert[] { + const inserts: ContextOrderInsert[] = [ + insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"), + ]; + let o = 1; + for (const p of products) { + if (!isStyleProductTag(p.tag)) continue; + inserts.push(insert(o++, p.tag, "full", "文风/呈现参考")); + } + inserts.push(insert(o++, "运行.本轮.裁决", "full")); + return inserts; +} + +function buildAuditorInserts(params: { + products: DictateProduct[]; + runtimeTags: string[]; +}): ContextOrderInsert[] { + const inserts: ContextOrderInsert[] = [ + insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"), + ]; + let o = 1; + for (const p of params.products) { + if (!/变量|生成规则/.test(p.tag)) continue; + inserts.push(insert(o++, p.tag, "full")); + } + for (const tag of params.runtimeTags) { + inserts.push(insert(o++, tag, tag === "变量.当前" ? "fields" : "full")); + } + inserts.push(insert(o++, "运行.本轮.裁决", "full")); + inserts.push(insert(o++, "用户.最新输入", "full")); + return inserts; +} + +/** + * 按当前 play_slots 与产物,合成 context_order(主世界拿最多;转述只拿文风/呈现)。 + */ +export function buildDictateContextOrder(params: { + products: DictateProduct[]; + playSlots: PlaySlotsConfig; + runtimeTags?: string[]; +}): ContextOrderDoc { + const runtimeTags = params.runtimeTags ?? []; + const slots: ContextOrderSlot[] = []; + + for (const slotId of enabledPlaySlotIds(params.playSlots)) { + const ref = refForSlot(params.playSlots, slotId); + if (slotId === "gm") { + slots.push({ + ref, + label: "主世界层", + inserts: buildGmInserts({ + products: params.products, + runtimeTags, + // 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片 + includeStyle: true, + }), + }); + continue; + } + if (slotId === "narrator") { + slots.push({ + ref, + label: "叙事转述", + inserts: buildNarratorInserts(params.products), + }); + continue; + } + if (slotId === "auditor") { + slots.push({ + ref, + label: "旁观维护", + inserts: buildAuditorInserts({ + products: params.products, + runtimeTags, + }), + }); + continue; + } + if (slotId === "perspective") { + slots.push({ + ref, + label: "角色视角", + inserts: [ + insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"), + insert(1, "用户.最新输入", "full"), + insert(2, "运行.本轮.裁决", "full"), + ], + }); + } + } + + return renumberContextOrder({ + schema: CONTEXT_ORDER_SCHEMA, + brief: "Boss直聘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入", + play_slots: params.playSlots, + slots, + }); +} + +function resolvePlaySlots(existingRaw: string | undefined): PlaySlotsConfig { + const parsed = existingRaw?.trim() + ? parseWorkerSetYaml(existingRaw) + : null; + return parsed?.play_slots ?? defaultPlaySlots(); +} + +/** + * 生成/更新 设计.worker集:保留已有 play_slots,重写 context_order 为产物挂载表。 + */ +export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBindResult { + const products = collectDictateBindProducts(board); + const existing = + board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() || + board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || + ""; + const playSlots = resolvePlaySlots(existing || undefined); + const runtimeTags = collectRuntimeStateTags(board); + const contextOrder = buildDictateContextOrder({ + products, + playSlots, + runtimeTags, + }); + + const baseJson = existing.trim() + ? existing + : JSON.stringify( + { + play_slots: playSlots, + brief: "Boss直聘落档自动生成", + }, + null, + 2, + ); + + let row: Record; + try { + const parsed = JSON.parse(baseJson) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("bad"); + } + row = parsed as Record; + } catch { + row = { play_slots: playSlots, brief: "Boss直聘落档自动生成" }; + } + if (!row.play_slots) row.play_slots = playSlots; + const withSlots = JSON.stringify(row, null, 2); + const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); + + const boundTags = products.map((p) => p.tag); + const changed = workerSetJson.trim() !== existing.trim(); + return { workerSetJson, contextOrder, boundTags, changed }; +} + +/** 写入黑板:设计.worker集 + 设计.上下文投影排序 */ +export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult { + const result = bindDictateProductsToPlaySpec(board); + board.write({ + tag: WORKER_SET_FINAL_TAG, + content: result.workerSetJson, + source: "runtime", + }); + board.write({ + tag: CONTEXT_ORDER_TAG, + content: JSON.stringify(result.contextOrder, null, 2), + source: "runtime", + }); + return result; +} + +/** @internal 测试用:判断 tag 是否文风类 */ +export function isDictateStyleBindTag(tag: string): boolean { + return isStyleProductTag(tag); +} diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts index 10d2cf7..041d176 100644 --- a/src/dictate/prompt.ts +++ b/src/dictate/prompt.ts @@ -1,10 +1,8 @@ /** - * 转述整理式 · 创作进料专用系统提示 + * Boss 直聘式 · 创作进料专用系统提示 * * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 * 仅用于创作层;开玩后由游玩管线接棒。 - * - * 产物只许用 insert toolcall;聊天只做确认与建议。 */ export function buildDictateSystemPrompt(opts?: { @@ -21,55 +19,57 @@ ${opts.recipeBrief ? `要点:${opts.recipeBrief}` : ""} ` : ""; - return `你是转述整理助手:在**创作进料**阶段,把用户原话整理成黑板产物。作用对象是本局创作上下文(对话可清空,产物保留)。 + return `你是 Boss 直聘式创作助手:把用户原话整理成可落盘规格。作用对象是本局创作上下文(对话可清空,产物保留)。 -**本提示只用于创作。** 开玩后的叙事由程序另调游玩 worker;不要假装在跑游玩回合。 +**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;不要假装在跑游玩回合。 ## 落盘(仅允许 toolcall) -产物**只能**用工具 **insert** 写入,禁止把设定/格式/开场全文只写在聊天里冒充已落盘。 -- \`position\`:插入位置(tag),如 \`用户.需求\`、\`设计.正文组成\`、\`设计.开场白\` -- \`content\`:该位置正文 -- \`order\`(可选):相对顺序,数字可负、可为 0、可正;**越小越靠前** +禁止把设定/变量/映射全文只写在聊天里冒充已落盘。 -**order 怎么取:** -1. 变化越频繁 → 数字越大(越靠后)。例:开场白常改 > 正文组成 > 需求/美学。 -2. 改动频率差不多 → 越重要数字越小(越靠前)。例:用户.需求 通常最前。 -3. 同轮可多次 insert;程序按 order 升序拼进上下文。省略 order 时沿用该 position 原序,或按 tag 默认。 +### insert — 不变型固定产物 +- position:\`用户.*\` / \`设计.*\`(如需求、美学、正文组成、开场白) +- content:正文;order 可选(越小越靠前;越常改越大) +- 勿用 insert 写变量目录或映射(用下面两个工具) -**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C、提问。不要复述 insert 全文。 +### declare_variable — 跨轮真值 +- key / type / initial / user_visible(用户是否可见) +- 只立必须跨轮记住的状态;派生文案不要立成可写变量 -**分清两件事:** -- **正文组成(格式)**:insert → \`设计.正文组成\`(壳与分区,非故事)。 -- **开场白(内容)**:insert → \`设计.开场白\`(按格式写的首屏);落档/开玩同步 \`输出.开场白\`。 +### declare_map — 真值→投影上下文 +- id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[] +- bands:\`{min,max,content}\` 区间(min 含 max 不含),或 \`{value,content}\`,或 \`{when:"default",content}\` +- 槽位固定、内容随真值换档;旧档离开上下文 + +**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C、提问。不要复述 tool 全文。 + +**分清:** +- 正文组成(格式)→ insert \`设计.正文组成\` +- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\` +- 好感分档性格等 → declare_variable + declare_map,不要 insert 一整张表进设定散文 ${recipeBlock} ## 按用户本轮意图择一为主 ### A · 钉设定 -1. insert 设定类 position(保留原意原词);order 偏小(靠前)。 -2. 回复:确认体验(2~4 句 + 问是否这个)+ 建议补充(1~2 缺口,各 A/B/C)。 +insert 设定类;order 偏小。回复:确认体验 + 建议补充。 ### B · 钉正文格式 -1. 必须 insert \`设计.正文组成\`(见规格);order 居中(约 0)。 -2. 回复短说明已钉壳与主块。 +必须 insert \`设计.正文组成\`。 ### C · 生成开场白 -1. 先有 \`设计.正文组成\`(否则先 B;缺设定先 A)。 -2. insert \`设计.开场白\`:Markdown 主阅读;岔路用 \`## 岔路\`;勿写 【body】/【footer】。order 偏大(靠后)。 -3. 回复只短说已写入;全文在 content。 +先有正文组成;insert \`设计.开场白\`(Markdown;岔路用 \`## 岔路\`)。 -## 常用 position 与默认序感 -- 用户.需求、设计.美学纲领等设定:靠前(负) -- 设计.正文组成:中间(0 附近) -- 设计.开场白:靠后(正) +### D · 钉变量与映射 +用户提到跨轮状态、分档态度、章大纲切换时:declare_variable(含是否对用户可见)→ declare_map(分档正文)。可同轮多次调用。 -## 设计.正文组成 · 规格(格式) -JSON 即可。核心:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`(区域 body/footer 等)。 -文本局默认 shell_id=\`prose\`;至少一块 body。要选项再声明 footer。 +## 常用序感 +- 需求/美学等不变设定:靠前(负) +- 正文组成:中间(0) +- 开场白:靠后(正) -## 设计.开场白 · 规格(内容) -按正文组成职责写满主阅读;Markdown;确认与建议不进 content。 +## 设计.正文组成 +JSON:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`。文本局默认 prose;至少一块 body。 ## 其它 -对话明显过长时 clear_dialogue(产物保留);否则不要清。 +对话明显过长时 clear_dialogue(产物与变量/映射保留)。 `; } diff --git a/src/dictate/tools.ts b/src/dictate/tools.ts index bdea656..9abd33b 100644 --- a/src/dictate/tools.ts +++ b/src/dictate/tools.ts @@ -1,8 +1,7 @@ import type { ToolDefinition } from "../llm/client.js"; /** - * 转述创作期工具:只允许用 toolcall 写产物。 - * insert(position, content, order?) —— 打标记落盘并带相对序。 + * Boss 直聘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 */ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ { @@ -10,24 +9,24 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ function: { name: "insert", description: - "唯一写产物入口:把正文插入黑板。确认/建议/思考只写在普通回复,禁止放进 content。用 order 标相对先后以便拼装。", + "写入不变型固定产物(设定/格式/开场等)。确认与建议只写在普通回复。用 order 标相对先后。", parameters: { type: "object", properties: { position: { type: "string", description: - "插入位置(产物 tag)。须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白", + "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白。勿用本工具写变量目录/映射。", }, content: { type: "string", description: - "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场(Markdown,用 ## 分段,勿写【body】、勿夹确认/建议)", + "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场(Markdown)", }, order: { type: "number", description: - "相对顺序(可负、0、正)。越小越靠前。原则:越常改越大(靠后);改动频率差不多时,越重要越小(靠前)。省略则沿用原序或按 tag 默认。", + "相对顺序(可负)。越小越靠前。越常改越大。省略则沿用原序或默认。", }, }, required: ["position", "content"], @@ -38,9 +37,86 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ { type: "function", function: { - name: "clear_dialogue", + name: "declare_variable", description: - "对话过长时清空对话记录。产物保留。清空后继续用自然语言回复用户。", + "声明或更新一个跨轮真值变量(写入设计.变量目录)。可见性、初值在此钉死;映射用 declare_map。", + parameters: { + type: "object", + properties: { + key: { + type: "string", + description: "字段名,如 好、章节、地点", + }, + type: { + type: "string", + description: "number | string | boolean | enum", + }, + initial: { + description: "初值(数字/字符串/布尔)", + }, + user_visible: { + type: "boolean", + description: "是否对用户可见(监控栏等);默认 true", + }, + note: { + type: "string", + description: "备注(可选)", + }, + }, + required: ["key"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "declare_map", + description: + "声明真值→投影上下文的映射(写入设计.变量映射)。投影 tag 槽位固定,内容随真值换档。", + parameters: { + type: "object", + properties: { + id: { + type: "string", + description: "映射 id,如 affinity-attitude", + }, + field: { + type: "string", + description: "依赖的真值字段名(须已 declare_variable)", + }, + target_tag: { + type: "string", + description: "投影 tag,须以「上下文.」或「大纲.」开头,如 上下文.角色态度", + }, + bands: { + type: "array", + description: + "分档:区间用 min(含)/max(不含)+content;精确用 value+content;兜底用 when=default+content", + items: { + type: "object", + properties: { + when: { type: "string", description: "range | eq | default;可省略,有 min/max 即区间" }, + min: { type: "number" }, + max: { type: "number" }, + value: {}, + content: { type: "string" }, + }, + required: ["content"], + }, + }, + note: { type: "string" }, + }, + required: ["id", "field", "target_tag", "bands"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "clear_dialogue", + description: "对话过长时清空对话。产物与变量/映射保留。", parameters: { type: "object", properties: { diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts index 1f4814d..5990e8c 100644 --- a/src/dictate/turn.ts +++ b/src/dictate/turn.ts @@ -12,11 +12,28 @@ import { type DictateChatTurn, type DictateProduct, } from "./types.js"; +import { + VARIABLE_CATALOG_TAG, + parseVariableCatalog, + serializeVariableCatalog, + upsertVariableField, + type UpsertVariableInput, +} from "../skills/variable-catalog.js"; +import { + VALUE_MAP_TAG, + parseValueMapDoc, + serializeValueMapDoc, + upsertValueMapEntry, + type UpsertMapInput, +} from "../skills/value-map.js"; export type DictateTurnHandlers = { listProducts: () => DictateProduct[]; /** order 省略时由实现沿用原序或默认 */ writeProduct: (tag: string, content: string, order?: number) => void; + /** 读/写变量目录与映射(程序校验后的 JSON) */ + readTag: (tag: string) => string | undefined; + writeTag: (tag: string, content: string) => void; clearDialogue: () => void; onToolCall?: (name: string, detail: string) => void; onThinkingDelta?: (delta: string) => void; @@ -31,8 +48,7 @@ export type DictateTurnResult = { }; /** - * 跑一轮转述整理:上下文 = 预设提示 + 按相对序的产物 + 全量对话。 - * 产物只许 insert(position, content, order?);最终文本即对用户回复。 + * 跑一轮 Boss 直聘整理:上下文 = 预设提示 + 按相对序的产物 + 全量对话。 */ export async function runDictateTurn(params: { llm: LlmProvider; @@ -59,7 +75,6 @@ export async function runDictateTurn(params: { products: params.handlers.listProducts(), dialogue, }); - // driver 会再前置 system;此处只传 user 拼装块,避免双重 system const userBlock = assembled.filter((m) => m.role !== "system"); const run = await driver.run({ @@ -67,17 +82,20 @@ export async function runDictateTurn(params: { messages: userBlock, tools: DICTATE_TOOL_DEFINITIONS, caller: "dictate_agent", - label: "转述整理", + label: "Boss直聘", onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDone: params.handlers.onThinkingDone, handleStep: (calls) => { const results = calls.map((call) => { const args = safeParseArgs(call.arguments); if (call.name === "insert" || call.name === "write_product") { - const tag = String( - args.position ?? args.tag ?? "", - ).trim(); + const tag = String(args.position ?? args.tag ?? "").trim(); const content = String(args.content ?? ""); + if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) { + const err = `请用 declare_variable / declare_map,不要 insert ${tag}`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } if (!isAllowedProductTag(tag)) { const err = `拒绝写入:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`; params.handlers.onToolCall?.(call.name, err); @@ -103,6 +121,67 @@ export async function runDictateTurn(params: { }), }; } + if (call.name === "declare_variable") { + const input: UpsertVariableInput = { + key: String(args.key ?? "").trim(), + type: args.type != null ? String(args.type) : undefined, + initial: args.initial, + user_visible: + args.user_visible === undefined + ? undefined + : Boolean(args.user_visible), + note: args.note != null ? String(args.note) : undefined, + }; + const current = parseVariableCatalog( + params.handlers.readTag(VARIABLE_CATALOG_TAG), + ); + const { doc, error } = upsertVariableField(current, input); + if (error) { + params.handlers.onToolCall?.(call.name, error); + return { callId: call.id, content: JSON.stringify({ error }) }; + } + params.handlers.writeTag( + VARIABLE_CATALOG_TAG, + serializeVariableCatalog(doc), + ); + wroteTags.push(VARIABLE_CATALOG_TAG); + const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`; + params.handlers.onToolCall?.(call.name, detail); + return { + callId: call.id, + content: JSON.stringify({ ok: true, key: input.key, fields: doc.fields.length }), + }; + } + if (call.name === "declare_map") { + const input: UpsertMapInput = { + id: String(args.id ?? "").trim(), + field: String(args.field ?? "").trim(), + target_tag: String(args.target_tag ?? "").trim(), + bands: args.bands, + note: args.note != null ? String(args.note) : undefined, + }; + const current = parseValueMapDoc( + params.handlers.readTag(VALUE_MAP_TAG), + ); + const { doc, error } = upsertValueMapEntry(current, input); + if (error) { + params.handlers.onToolCall?.(call.name, error); + return { callId: call.id, content: JSON.stringify({ error }) }; + } + params.handlers.writeTag(VALUE_MAP_TAG, serializeValueMapDoc(doc)); + wroteTags.push(VALUE_MAP_TAG); + const detail = `${input.id} ${input.field}→${input.target_tag}`; + params.handlers.onToolCall?.(call.name, detail); + return { + callId: call.id, + content: JSON.stringify({ + ok: true, + id: input.id, + target_tag: input.target_tag, + maps: doc.maps.length, + }), + }; + } if (call.name === "clear_dialogue") { const reason = String(args.reason ?? "").trim() || "上下文过长"; params.handlers.clearDialogue(); @@ -114,7 +193,7 @@ export async function runDictateTurn(params: { content: JSON.stringify({ ok: true, cleared: true, - note: "对话已清空;产物仍在。后续上下文以产物为准。", + note: "对话已清空;产物与变量/映射仍在。", }), }; } @@ -126,8 +205,6 @@ export async function runDictateTurn(params: { }, }); - // driver 的 messages 在首轮已固定;clear 后若还要续写依赖模型已知工具结果。 - // 若模型只调工具不说话,给一个兜底回复。 let reply = ""; if (run.stop.kind === "text") { reply = run.stop.content.trim(); @@ -153,11 +230,11 @@ export async function runDictateTurn(params: { function safeParseArgs(raw: string): Record { try { const parsed = JSON.parse(raw || "{}"); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; } + return parsed as Record; } catch { - /* ignore */ + return {}; } - return {}; } diff --git a/src/dictate/types.ts b/src/dictate/types.ts index 07f2606..f3de02b 100644 --- a/src/dictate/types.ts +++ b/src/dictate/types.ts @@ -37,6 +37,7 @@ export function isAllowedProductTag(tag: string): boolean { export function defaultDictateOrder(tag: string): number { const t = tag.trim(); if (t === "用户.需求") return -40; + if (t === "设计.变量目录" || t === "设计.变量映射") return -25; if (/美学|纲领|禁忌|示例/.test(t)) return -30; if (/文风|叙事指南|篇幅|结构/.test(t)) return -20; if (/正文组成|回复格式/.test(t)) return 0; diff --git a/src/llm/capabilities.ts b/src/llm/capabilities.ts index db735c6..3fa9294 100644 --- a/src/llm/capabilities.ts +++ b/src/llm/capabilities.ts @@ -5,6 +5,7 @@ import { applyRejectedGenerationField, + isToolsReasoningEffortConflictError, parseRejectedGenerationField, } from "./generation-compat.js"; @@ -168,7 +169,7 @@ async function postOnce( /** * 探测请求始终带 reasoning_effort=low。 - * 若供应商拒收该字段,剥掉后再试一次。 + * 若供应商拒收该字段,剥掉后再试;tools 冲突则改 none 再试。 */ async function postChat( config: ProbeConfig, @@ -179,14 +180,20 @@ async function postChat( reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT, }; const first = await postOnce(config, withEffort); - if (first.ok || first.status !== 400) return first; - - const rejected = parseRejectedGenerationField(first.text); - if (rejected !== "reasoning_effort") return first; + if (first.ok || (first.status !== 400 && first.status !== 422)) return first; const retryBody = { ...withEffort }; const tried = new Set(); - if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) { + + if (isToolsReasoningEffortConflictError(first.text, first.status)) { + retryBody.reasoning_effort = "none"; + tried.add("reasoning_effort_none"); + return postOnce(config, retryBody); + } + + const rejected = parseRejectedGenerationField(first.text); + if (!rejected) return first; + if (!applyRejectedGenerationField(retryBody, rejected, tried)) { return first; } return postOnce(config, retryBody); diff --git a/src/llm/client.ts b/src/llm/client.ts index b69a45d..49c7ecc 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -3,6 +3,7 @@ import type { GenerationParameters } from "../types/preset.js"; import { applyOpenAiGeneration, fetchWithGenerationCompat, + forceReasoningEffortNoneWhenTools, sanitizeReasoningEffort, } from "./generation-compat.js"; import { consumeOpenAiToolStream } from "./stream-complete.js"; @@ -147,6 +148,8 @@ export function buildRequestBody( applyOpenAiGeneration(body, options?.generation ?? {}, config.model, { reasoningEffort: config.reasoningEffort, }); + // 对齐 imyai:带 tools 时强制 none(省略字段网关仍可能注入默认 effort) + forceReasoningEffortNoneWhenTools(body); if (options?.responseFormat === "json_object") { body.response_format = { type: "json_object" }; diff --git a/src/llm/generation-compat.ts b/src/llm/generation-compat.ts index 8c6f2d8..2339645 100644 --- a/src/llm/generation-compat.ts +++ b/src/llm/generation-compat.ts @@ -6,7 +6,8 @@ import type { GenerationParameters } from "../types/preset.js"; * - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发 * - 推理模型去掉 temperature / top_p / penalty * - o 系列与 gpt-5 用 max_completion_tokens - * - 仍 400 时按错误信息剥掉不支持字段再试 + * - 带 tools 时强制 reasoning_effort=none(gpt-5.6-luna 等网关:省略字段仍会注入默认 effort) + * - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则改 none 再试 */ const REASONING_EFFORT_VALUES = [ @@ -157,6 +158,46 @@ export function applyOpenAiGeneration( } } +/** chat/completions + function tools 时须显式 none,否则网关可能注入默认 effort 并 400。 */ +export function forceReasoningEffortNoneWhenTools( + body: Record, +): void { + const tools = body.tools; + if (!Array.isArray(tools) || tools.length === 0) return; + body.reasoning_effort = "none"; +} + +/** + * gpt-5.6-luna 等:/v1/chat/completions 下 function tools 不能与非 none 的 + * reasoning_effort 同用。需显式 reasoning_effort='none'。 + */ +export function isToolsReasoningEffortConflictError( + errorText: string, + status?: number, +): boolean { + if (status != null && status !== 400 && status !== 422) return false; + + let parsed: unknown; + try { + parsed = JSON.parse(errorText); + } catch { + parsed = undefined; + } + const message = `${readErrorMessage(parsed)}\n${errorText}`.toLowerCase(); + if (!message.trim()) return false; + + const mentionsTools = + /\b(function\s+)?tools?\b/.test(message) || + /tool_choice|function tools/.test(message); + const mentionsReasoningEffort = /reasoning_effort/.test(message); + const conflict = + /not support|unsupported|does not support|not supported|not allowed|cannot|can't/.test( + message, + ); + + return mentionsTools && mentionsReasoningEffort && conflict; +} + function readErrorMessage(raw: unknown): string { if (!raw || typeof raw !== "object") return ""; const err = (raw as { error?: unknown }).error; @@ -238,12 +279,42 @@ export function applyRejectedGenerationField( return true; } + // tools + reasoning_effort 冲突:删字段不够,须显式 none + if ( + field === "reasoning_effort" && + Array.isArray(body.tools) && + body.tools.length > 0 && + body.reasoning_effort !== "none" && + !tried.has("reasoning_effort_none") + ) { + body.reasoning_effort = "none"; + tried.add("reasoning_effort_none"); + return true; + } + if (!(field in body)) return false; delete body[field]; tried.add(field); return true; } +function applyToolsReasoningEffortNone( + body: Record, + tried: Set, +): boolean { + if ( + !Array.isArray(body.tools) || + body.tools.length === 0 || + body.reasoning_effort === "none" || + tried.has("reasoning_effort_none") + ) { + return false; + } + body.reasoning_effort = "none"; + tried.add("reasoning_effort_none"); + return true; +} + function replayResponse(source: Response, text: string): Response { return new Response(text, { status: source.status, @@ -262,11 +333,19 @@ export async function fetchWithGenerationCompat( for (let attempt = 0; attempt < 8; attempt++) { last = await send(body); - if (last.ok || last.status !== 400) return last; + if (last.ok || (last.status !== 400 && last.status !== 422)) return last; const text = await last.text(); + const next = { ...body }; + + if (isToolsReasoningEffortConflictError(text, last.status)) { + if (applyToolsReasoningEffortNone(next, tried)) { + body = next; + continue; + } + } + const field = parseRejectedGenerationField(text); if (!field) return replayResponse(last, text); - const next = { ...body }; if (!applyRejectedGenerationField(next, field, tried)) { return replayResponse(last, text); } diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 99068b1..d1ef9e9 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -136,6 +136,15 @@ import { CREATION_INTAKE_MODE_TAG, isDictateModeValue, } from "../dictate/types.js"; +import { applyDictatePlayBind } from "../dictate/play-bind.js"; +import { + VARIABLE_CATALOG_TAG, + seedVariablesFromCatalog, +} from "../skills/variable-catalog.js"; +import { + VALUE_MAP_TAG, + reprojectValueMaps, +} from "../skills/value-map.js"; import { readPlayTurnQueue, withPlayTurnQueue, @@ -1983,13 +1992,26 @@ export class PhaseRuntime { /** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */ private ensurePlaySpecReady(): void { - const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim(); - const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); - if (!finalTag) { - const spec = draft?.trim() - ? draft - : JSON.stringify({ play_slots: defaultPlaySlots() }); - this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup"); + // Boss 直聘:落档/开玩时自动挂载产物;种变量初值;按映射重投影 + if (this.isDictateIntakeMode()) { + const bound = applyDictatePlayBind(this.blackboard); + if (bound.boundTags.length) { + this.onMessage( + `[Boss直聘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`, + ); + } else { + this.onMessage("[Boss直聘] 已生成默认游玩规格(尚无产物可挂载)"); + } + this.seedVariablesAndReproject("opening-setup"); + } else { + const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim(); + const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); + if (!finalTag) { + const spec = draft?.trim() + ? draft + : JSON.stringify({ play_slots: defaultPlaySlots() }); + this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup"); + } } if (this.session.slots.designInstanceReady) return; this.session = { @@ -2001,6 +2023,52 @@ export class PhaseRuntime { }; } + /** 目录初值 → 变量.当前;再按 设计.变量映射 重写投影 tag */ + private seedVariablesAndReproject(actor: string): void { + const seeded = seedVariablesFromCatalog({ + catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG), + currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG), + initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG), + actor: `system:${actor}`, + }); + if (seeded.initial) { + this.blackboard.write({ + tag: OPENING_INITIAL_VARS_TAG, + content: seeded.initial, + source: actor, + }); + } + if (seeded.current) { + this.blackboard.write({ + tag: OPENING_CURRENT_VARS_TAG, + content: seeded.current, + source: actor, + }); + if (seeded.seededKeys.length) { + this.onMessage( + `[变量] 已种初值:${seeded.seededKeys.join("、")}`, + ); + } + } + this.applyValueMapProjections(actor); + } + + /** 按当前真值持续投影映射 tag(非 once) */ + private applyValueMapProjections(workerId: string): void { + if (!this.blackboard.getContentByTag(VALUE_MAP_TAG)?.trim()) return; + const result = reprojectValueMaps({ + blackboard: this.blackboard, + source: `value-map:${workerId}`, + }); + if (result.written.length) { + this.onMessage( + `[变量映射] 投影 ${result.written + .map((w) => `${w.field}=${String(w.value)}→${w.tag}`) + .join("、")}`, + ); + } + } + /** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */ private unsealCreationOpening(): void { const reopened = reopenCreationFlowRaw( @@ -2515,6 +2583,9 @@ export class PhaseRuntime { if (nextDoc) { this.applyTableSideEffects(prevDoc, nextDoc, workerId); } + if (tag === OPENING_CURRENT_VARS_TAG || tag === "变量.当前") { + this.applyValueMapProjections(workerId); + } // 裁决包 / 旁观维护包 → 合并进变量.当前(可触发 side_effects) if (tag === SETTLEMENT_TAG) { this.applySettlementVariableChanges(toWrite, workerId); diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 4d6fd42..c8800e7 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -163,6 +163,7 @@ import { createUserVariantMessage, ensureBranchForEdit, ensureBranchForRefresh, + findPrecedingUserIndex, isRefreshableMessage, recordPreMessageCheckpoint, switchBranchVariant, @@ -607,7 +608,7 @@ export class SessionManager { }。` : ""; const welcome = - `转述整理模式:直接说你想要的体验与设定。我会整理进产物;缺什么会问你。对话过长时可清空对话(产物保留)。${recipeLine}`; + `Boss 直聘模式:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`; s.messages.push({ id: randomUUID(), role: "system", @@ -615,7 +616,7 @@ export class SessionManager { createdAt: new Date().toISOString(), kind: "orchestrator_prompt", actor: "orchestrator", - title: "转述整理", + title: "Boss直聘", body: welcome, }); } @@ -711,6 +712,19 @@ export class SessionManager { }, }); }, + readTag: (tag) => s.runtime.getBlackboard().getContentByTag(tag), + writeTag: (tag, content) => { + const board = s.runtime.getBlackboard(); + board.write({ + tag, + content, + source: "agent", + metadata: { + ...(board.getLatestByTag(tag)?.metadata ?? {}), + [DICTATE_ORDER_META_KEY]: defaultDictateOrder(tag), + }, + }); + }, clearDialogue: () => { const kept = this.listDictateProducts(s); const lastUser = [...s.messages] @@ -736,6 +750,11 @@ export class SessionManager { dialogue = extractDictateDialogue(s.messages); }, onToolCall: (name, detail) => { + recordPreMessageCheckpoint( + s.branchState, + s.messages.length, + this.captureCheckpoint(s), + ); s.messages.push( this.msg("system", `[总管 tool] ${name}: ${detail}`), ); @@ -755,6 +774,11 @@ export class SessionManager { }); this.clearAgentThinking(s.runtime.getSession().id); + recordPreMessageCheckpoint( + s.branchState, + s.messages.length, + this.captureCheckpoint(s), + ); s.messages.push({ id: randomUUID(), role: "system", @@ -1655,9 +1679,22 @@ export class SessionManager { if (s.bookId) this.syncBookPreview(s.bookId, s.messages); try { - const owned = await this.runExclusive(id, s, () => - s.runtime.submitInput(trimmed), - ); + const owned = await this.runExclusive(id, s, async () => { + const sess = s.runtime.getSession(); + const reason = sess.waitingReason; + const inPlay = + isPlayLayerActive(sess.slots) || + inferLifecycleStage(sess) === "play"; + if ( + this.isDictateMode(s) && + reason?.kind === "input" && + !inPlay + ) { + await this.runDictateTurnForSession(s, trimmed); + return; + } + await s.runtime.submitInput(trimmed); + }); if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); @@ -1790,7 +1827,7 @@ export class SessionManager { return this.toView(id); } - /** 删除消息及其后的对话 */ + /** 删除消息及其后的对话(仅截断,不重生成) */ async deleteMessage(id: string, messageId: string): Promise { const s = this.require(id); const idx = s.messages.findIndex((m) => m.id === messageId); @@ -1810,11 +1847,37 @@ export class SessionManager { const n = Number(key); if (n >= idx) delete s.branchState.preMessageCheckpoints[n]; } + this.syncCreationDialogue(s); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); this.persist(s); return this.toView(id); } + /** + * 从这里重开:丢掉该条及之后内容,并按本轮用户输入重新生成(重 roll)。 + * 用户消息 → 同文编辑重跑;可刷新的 Worker 回复 → refresh;其余 → 回溯到上一轮用户输入再 edit。 + */ + async restartFromMessage(id: string, messageId: string): Promise { + const s = this.require(id); + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx < 0) throw new Error("消息不存在"); + const target = s.messages[idx]; + + if (target.role === "user") { + return this.editMessage(id, messageId, target.text); + } + if (isRefreshableMessage(target)) { + return this.refreshMessage(id, messageId); + } + + const userIdx = findPrecedingUserIndex(s.messages, idx); + if (userIdx < 0) { + return this.deleteMessage(id, messageId); + } + const userMsg = s.messages[userIdx]; + return this.editMessage(id, userMsg.id, userMsg.text); + } + /** 左右切换同位消息版本(swipe / branch) */ async switchMessageVariant( id: string, diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 71fc4e2..28dd48f 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -240,7 +240,7 @@ const server = createServer(async (req, res) => { } const messageActionMatch = sub.match( - /^\/messages\/([^/]+)\/(edit|refresh|variant|delete)$/, + /^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart)$/, ); if (req.method === "POST" && messageActionMatch) { const messageId = decodeURIComponent(messageActionMatch[1]); @@ -275,6 +275,8 @@ const server = createServer(async (req, res) => { ); } else if (action === "delete") { view = await sessionManager.deleteMessage(sessionId, messageId); + } else if (action === "restart") { + view = await sessionManager.restartFromMessage(sessionId, messageId); } else { json(res, 400, { error: "未知 action" }); return; diff --git a/src/skills/value-map.ts b/src/skills/value-map.ts new file mode 100644 index 0000000..20789f4 --- /dev/null +++ b/src/skills/value-map.ts @@ -0,0 +1,321 @@ +/** + * 真值 → 投影 tag:按当前值查档,改投影 tag 内容(槽位固定)。 + * schema: value-map.v1 · 存黑板「设计.变量映射」 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import { parseTableDoc, type TableDoc } from "../blackboard/table-cells.js"; +import { tableValueMap } from "../blackboard/table-side-effects.js"; +import { extractJsonObjectText } from "./worker-set-parse.js"; + +export const VALUE_MAP_SCHEMA = "value-map.v1" as const; +export const VALUE_MAP_TAG = "设计.变量映射"; +const VARS_TAG = "变量.当前"; + +export type ValueMapBand = + | { when: "range"; min?: number; max?: number; content: string } + | { when: "eq"; value: unknown; content: string } + | { when: "default"; content: string }; + +export type ValueMapEntry = { + id: string; + /** 真值字段名(变量.当前 的 key) */ + field: string; + /** 投影 tag(固定槽;内容随真值替换) */ + target_tag: string; + bands: ValueMapBand[]; + note?: string; +}; + +export type ValueMapDoc = { + schema: typeof VALUE_MAP_SCHEMA; + maps: ValueMapEntry[]; +}; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function toNumber(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) { + return Number(v); + } + return null; +} + +function sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return a == null && b == null; + const na = toNumber(a); + const nb = toNumber(b); + if (na != null && nb != null) return na === nb; + return String(a) === String(b); +} + +function parseBand(raw: unknown): ValueMapBand | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const content = asString(row.content) ?? asString(row.正文) ?? asString(row.text); + if (!content) return null; + + const when = asString(row.when) ?? asString(row.op); + if (when === "default" || when === "默认" || row.default === true) { + return { when: "default", content }; + } + if (when === "eq" || when === "等于") { + return { + when: "eq", + value: row.value ?? row.eq ?? row.值, + content, + }; + } + + const min = + typeof row.min === "number" + ? row.min + : typeof row.gte === "number" + ? row.gte + : toNumber(row.min); + const max = + typeof row.max === "number" + ? row.max + : typeof row.lt === "number" + ? row.lt + : toNumber(row.max); + + if (min != null || max != null || when === "range" || when === "区间") { + return { + when: "range", + min: min ?? undefined, + max: max ?? undefined, + content, + }; + } + + // 仅给了 value → 当精确匹配 + if (row.value !== undefined || row.eq !== undefined || row.值 !== undefined) { + return { + when: "eq", + value: row.value ?? row.eq ?? row.值, + content, + }; + } + return { when: "default", content }; +} + +export function parseValueMapDoc(raw: string | undefined | null): ValueMapDoc | null { + if (!raw?.trim()) return null; + const text = extractJsonObjectText(raw) ?? raw.trim(); + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const row = parsed as Record; + const mapsRaw = row.maps ?? row.映射 ?? row.Data映射; + if (!Array.isArray(mapsRaw)) return null; + const maps: ValueMapEntry[] = []; + for (const item of mapsRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const m = item as Record; + const id = asString(m.id) ?? asString(m.映射id) ?? ""; + const field = asString(m.field) ?? asString(m.键依真值) ?? asString(m.key) ?? ""; + const target = + asString(m.target_tag) ?? + asString(m.targetTag) ?? + asString(m.投影tag) ?? + asString(m.tag) ?? + ""; + if (!id || !field || !target) continue; + const bandsRaw = m.bands ?? m.档 ?? m.分档; + if (!Array.isArray(bandsRaw)) continue; + const bands = bandsRaw.map(parseBand).filter((b): b is ValueMapBand => Boolean(b)); + if (!bands.length) continue; + maps.push({ + id, + field, + target_tag: target, + bands, + note: asString(m.note) ?? asString(m.说明), + }); + } + if (!maps.length) return null; + return { schema: VALUE_MAP_SCHEMA, maps }; + } catch { + return null; + } +} + +export function serializeValueMapDoc(doc: ValueMapDoc): string { + return JSON.stringify( + { + schema: VALUE_MAP_SCHEMA, + maps: doc.maps.map((m) => ({ + id: m.id, + field: m.field, + target_tag: m.target_tag, + bands: m.bands, + ...(m.note ? { note: m.note } : {}), + })), + }, + null, + 2, + ); +} + +export function isValidProjectionTag(tag: string): boolean { + const t = tag.trim(); + if (!t || t.includes("\n")) return false; + return t.startsWith("上下文.") || t.startsWith("大纲."); +} + +export type UpsertMapInput = { + id: string; + field: string; + target_tag: string; + bands: unknown; + note?: string; +}; + +export function upsertValueMapEntry( + current: ValueMapDoc | null, + input: UpsertMapInput, +): { doc: ValueMapDoc; error?: string } { + const id = input.id.trim(); + const field = input.field.trim(); + const target = input.target_tag.trim(); + if (!id) { + return { doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, error: "id 不能为空" }; + } + if (!field) { + return { doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, error: "field 不能为空" }; + } + if (!isValidProjectionTag(target)) { + return { + doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, + error: "target_tag 须以「上下文.」或「大纲.」开头", + }; + } + let bandsRaw = input.bands; + if (typeof bandsRaw === "string") { + try { + bandsRaw = JSON.parse(bandsRaw); + } catch { + return { + doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, + error: "bands 须为 JSON 数组", + }; + } + } + if (!Array.isArray(bandsRaw) || !bandsRaw.length) { + return { + doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, + error: "bands 至少一档", + }; + } + const bands = bandsRaw.map(parseBand).filter((b): b is ValueMapBand => Boolean(b)); + if (!bands.length) { + return { + doc: current ?? { schema: VALUE_MAP_SCHEMA, maps: [] }, + error: "bands 无法解析(需 content,以及 min/max 或 value 或 default)", + }; + } + const entry: ValueMapEntry = { + id, + field, + target_tag: target, + bands, + note: input.note?.trim() || undefined, + }; + const maps = [...(current?.maps ?? [])]; + const idx = maps.findIndex((m) => m.id === id); + if (idx >= 0) maps[idx] = entry; + else maps.push(entry); + return { doc: { schema: VALUE_MAP_SCHEMA, maps } }; +} + +/** 按真值查一档正文;无命中返回 null */ +export function lookupValueMapContent( + entry: ValueMapEntry, + fieldValue: unknown, +): string | null { + let defaultContent: string | null = null; + for (const band of entry.bands) { + if (band.when === "default") { + defaultContent = band.content; + continue; + } + if (band.when === "eq") { + if (sameValue(fieldValue, band.value)) return band.content; + continue; + } + const n = toNumber(fieldValue); + if (n == null) continue; + const minOk = band.min == null || n >= band.min; + const maxOk = band.max == null || n < band.max; + if (minOk && maxOk) return band.content; + } + return defaultContent; +} + +export type ReprojectResult = { + written: Array<{ mapId: string; tag: string; field: string; value: unknown }>; + skipped: Array<{ mapId: string; reason: string }>; +}; + +/** + * 按 变量.当前 重写所有映射投影 tag(持续投影,非 once)。 + */ +export function reprojectValueMaps(params: { + blackboard: Blackboard; + mapsRaw?: string | null; + currentRaw?: string | null; + source?: string; +}): ReprojectResult { + const maps = + parseValueMapDoc( + params.mapsRaw ?? params.blackboard.getContentByTag(VALUE_MAP_TAG), + )?.maps ?? []; + const written: ReprojectResult["written"] = []; + const skipped: ReprojectResult["skipped"] = []; + if (!maps.length) return { written, skipped }; + + const currentDoc = + parseTableDoc( + params.currentRaw ?? params.blackboard.getContentByTag(VARS_TAG), + ) ?? ({ rows: [] } as TableDoc); + const values = tableValueMap(currentDoc); + const source = params.source ?? "system:value-map"; + + for (const entry of maps) { + if (!values.has(entry.field)) { + skipped.push({ mapId: entry.id, reason: `缺真值字段 ${entry.field}` }); + continue; + } + const value = values.get(entry.field); + const content = lookupValueMapContent(entry, value); + if (content == null) { + skipped.push({ mapId: entry.id, reason: "无命中档" }); + continue; + } + params.blackboard.write({ + tag: entry.target_tag, + content, + source, + }); + written.push({ + mapId: entry.id, + tag: entry.target_tag, + field: entry.field, + value, + }); + } + return { written, skipped }; +} + +/** 映射表里声明的全部投影 tag(供 context_order 挂固定槽) */ +export function listValueMapTargetTags(raw: string | undefined | null): string[] { + const doc = parseValueMapDoc(raw); + if (!doc) return []; + return [...new Set(doc.maps.map((m) => m.target_tag))]; +} diff --git a/src/skills/variable-catalog.ts b/src/skills/variable-catalog.ts new file mode 100644 index 0000000..85eabc4 --- /dev/null +++ b/src/skills/variable-catalog.ts @@ -0,0 +1,222 @@ +/** + * 变量目录(创作期对话钉死;落档时种进 变量.当前)。 + * schema: variable-catalog.v1 + */ +import { + createTableFromValues, + parseTableDoc, + stringifyTableDoc, + type TableDoc, +} from "../blackboard/table-cells.js"; +import { extractJsonObjectText } from "./worker-set-parse.js"; + +export const VARIABLE_CATALOG_SCHEMA = "variable-catalog.v1" as const; +export const VARIABLE_CATALOG_TAG = "设计.变量目录"; +export const CURRENT_VARS_TAG = "变量.当前"; + +export type VariableFieldType = "number" | "string" | "boolean" | "enum"; + +export type VariableCatalogField = { + key: string; + type: VariableFieldType; + initial: unknown; + /** 对用户可见(监控栏等);默认 true */ + user_visible: boolean; + note?: string; +}; + +export type VariableCatalogDoc = { + schema: typeof VARIABLE_CATALOG_SCHEMA; + fields: VariableCatalogField[]; +}; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function parseType(raw: unknown): VariableFieldType { + const s = asString(raw)?.toLowerCase(); + if (s === "number" || s === "数字") return "number"; + if (s === "boolean" || s === "布尔") return "boolean"; + if (s === "enum" || s === "枚举") return "enum"; + return "string"; +} + +function coerceInitial(type: VariableFieldType, raw: unknown): unknown { + if (type === "number") { + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "string" && raw.trim() && !Number.isNaN(Number(raw))) { + return Number(raw); + } + return 0; + } + if (type === "boolean") { + if (typeof raw === "boolean") return raw; + if (raw === "true" || raw === "是" || raw === 1) return true; + if (raw === "false" || raw === "否" || raw === 0) return false; + return false; + } + if (raw === undefined || raw === null) return type === "string" ? "" : raw; + return raw; +} + +export function parseVariableCatalog(raw: string | undefined | null): VariableCatalogDoc | null { + if (!raw?.trim()) return null; + const text = extractJsonObjectText(raw) ?? raw.trim(); + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const row = parsed as Record; + const fieldsRaw = row.fields ?? row.真值 ?? row.变量; + if (!Array.isArray(fieldsRaw)) return null; + const fields: VariableCatalogField[] = []; + for (const item of fieldsRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const f = item as Record; + const key = asString(f.key) ?? asString(f.名) ?? asString(f.name); + if (!key) continue; + const type = parseType(f.type ?? f.类型); + const userVisible = + f.user_visible === false || + f.对用户可见 === false || + f.userVisible === false + ? false + : true; + fields.push({ + key, + type, + initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value), + user_visible: userVisible, + note: asString(f.note) ?? asString(f.备注), + }); + } + if (!fields.length) return null; + return { schema: VARIABLE_CATALOG_SCHEMA, fields }; + } catch { + return null; + } +} + +export function serializeVariableCatalog(doc: VariableCatalogDoc): string { + return JSON.stringify( + { + schema: VARIABLE_CATALOG_SCHEMA, + fields: doc.fields.map((f) => ({ + key: f.key, + type: f.type, + initial: f.initial, + user_visible: f.user_visible, + ...(f.note ? { note: f.note } : {}), + })), + }, + null, + 2, + ); +} + +export type UpsertVariableInput = { + key: string; + type?: string; + initial?: unknown; + user_visible?: boolean; + note?: string; +}; + +export function upsertVariableField( + current: VariableCatalogDoc | null, + input: UpsertVariableInput, +): { doc: VariableCatalogDoc; error?: string } { + const key = input.key.trim(); + if (!key) return { doc: current ?? { schema: VARIABLE_CATALOG_SCHEMA, fields: [] }, error: "key 不能为空" }; + const type = parseType(input.type); + const field: VariableCatalogField = { + key, + type, + initial: coerceInitial(type, input.initial), + user_visible: input.user_visible !== false, + note: input.note?.trim() || undefined, + }; + const fields = [...(current?.fields ?? [])]; + const idx = fields.findIndex((f) => f.key === key); + if (idx >= 0) fields[idx] = field; + else fields.push(field); + return { doc: { schema: VARIABLE_CATALOG_SCHEMA, fields } }; +} + +/** 目录 → 表文档(初值);用于种 运行.初始变量 / 变量.当前 */ +export function catalogToTableDoc( + catalog: VariableCatalogDoc, + actor: string, +): TableDoc { + const values: Record = {}; + const meta: Record = {}; + for (const f of catalog.fields) { + values[f.key] = f.initial; + meta[f.key] = { + visibility: f.user_visible ? "visible" : "hidden", + note: f.note, + }; + } + return createTableFromValues(values, actor, meta); +} + +/** + * 若 变量.当前 为空则用目录初值种入;已有格不覆盖。 + * 同步补齐 运行.初始变量(仅当其为空)。 + */ +export function seedVariablesFromCatalog(params: { + catalogRaw: string | undefined | null; + currentRaw: string | undefined | null; + initialRaw: string | undefined | null; + actor?: string; +}): { + current: string | null; + initial: string | null; + seededKeys: string[]; +} { + const catalog = parseVariableCatalog(params.catalogRaw); + if (!catalog?.fields.length) { + return { current: null, initial: null, seededKeys: [] }; + } + const actor = params.actor ?? "system:variable-catalog"; + const fromCatalog = catalogToTableDoc(catalog, actor); + const seededKeys = fromCatalog.rows.map((r) => r.key); + + const existingCurrent = parseTableDoc(params.currentRaw); + let currentOut: string | null = null; + if (!existingCurrent?.rows.length) { + currentOut = stringifyTableDoc(fromCatalog); + } else { + // 补缺键,不覆盖已有 + const have = new Set(existingCurrent.rows.map((r) => r.key)); + const patchValues: Record = {}; + const meta: Record = {}; + for (const f of catalog.fields) { + if (have.has(f.key)) continue; + patchValues[f.key] = f.initial; + meta[f.key] = { + visibility: f.user_visible ? "visible" : "hidden", + note: f.note, + }; + } + if (Object.keys(patchValues).length) { + const patch = createTableFromValues(patchValues, actor, meta); + const merged = { + rows: [...existingCurrent.rows, ...patch.rows].sort((a, b) => + a.key.localeCompare(b.key), + ), + }; + currentOut = stringifyTableDoc(merged); + } + } + + const existingInitial = parseTableDoc(params.initialRaw); + let initialOut: string | null = null; + if (!existingInitial?.rows.length) { + initialOut = stringifyTableDoc(fromCatalog); + } + + return { current: currentOut, initial: initialOut, seededKeys }; +} diff --git a/tests/dictate-play-bind.test.ts b/tests/dictate-play-bind.test.ts new file mode 100644 index 0000000..5604fd4 --- /dev/null +++ b/tests/dictate-play-bind.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + buildDictateContextOrder, + bindDictateProductsToPlaySpec, + collectDictateBindProducts, + isDictateStyleBindTag, +} from "../src/dictate/play-bind.js"; +import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js"; +import { DIALOGUE_HISTORY_TAG, WORKER_PERSONA_REF } from "../src/skills/context-order.js"; +import { defaultPlaySlots } from "../src/skills/play-slots.js"; + +describe("dictate play-bind", () => { + it("classifies style tags", () => { + expect(isDictateStyleBindTag("设计.美学纲领")).toBe(true); + expect(isDictateStyleBindTag("设计.正文组成")).toBe(true); + expect(isDictateStyleBindTag("用户.需求")).toBe(false); + }); + + it("builds gm context_order: persona → products → history → input", () => { + const order = buildDictateContextOrder({ + products: [ + { tag: "用户.需求", content: "雨夜", order: -40 }, + { tag: "设计.美学纲领", content: "冷色", order: -30 }, + { tag: "设计.正文组成", content: "{}", order: 0 }, + ], + playSlots: defaultPlaySlots(), + }); + const gm = order.slots.find((s) => s.ref === "world-simulator"); + expect(gm).toBeTruthy(); + const refs = gm!.inserts.map((i) => i.ref); + expect(refs[0]).toBe(WORKER_PERSONA_REF); + expect(refs).toContain("用户.需求"); + expect(refs).toContain("设计.美学纲领"); + expect(refs).toContain("设计.正文组成"); + expect(refs).toContain(DIALOGUE_HISTORY_TAG); + expect(refs[refs.length - 1]).toBe("用户.最新输入"); + const histIdx = refs.indexOf(DIALOGUE_HISTORY_TAG); + expect(refs.indexOf("用户.需求")).toBeLessThan(histIdx); + expect(refs.indexOf("设计.正文组成")).toBeLessThan(histIdx); + }); + + it("skips opening and worker-set tags; narrator only gets style", () => { + const board = new Blackboard(); + board.write({ + tag: "用户.需求", + content: "要压抑", + source: "dictate", + metadata: { [DICTATE_ORDER_META_KEY]: -40 }, + }); + board.write({ + tag: "设计.美学纲领", + content: "冷色短句", + source: "dictate", + metadata: { [DICTATE_ORDER_META_KEY]: -30 }, + }); + board.write({ + tag: "设计.开场白", + content: "雨落在檐上。", + source: "dictate", + }); + board.write({ + tag: "设计.worker集", + content: JSON.stringify({ + play_slots: { gm: true, narrator: true, auditor: false, perspective: false }, + }), + source: "test", + }); + + const products = collectDictateBindProducts(board); + expect(products.map((p) => p.tag)).toEqual(["用户.需求", "设计.美学纲领"]); + expect(products.every((p) => p.tag !== "设计.开场白")).toBe(true); + + const result = bindDictateProductsToPlaySpec(board); + const narr = result.contextOrder.slots.find((s) => s.ref === "narrator"); + expect(narr).toBeTruthy(); + const narrRefs = narr!.inserts.map((i) => i.ref); + expect(narrRefs).toContain("设计.美学纲领"); + expect(narrRefs).not.toContain("用户.需求"); + expect(narrRefs).toContain("运行.本轮.裁决"); + + const gm = result.contextOrder.slots.find((s) => s.ref === "world-simulator"); + expect(gm!.inserts.map((i) => i.ref)).toContain("用户.需求"); + expect(result.workerSetJson).toContain("context_order"); + }); + + it("does not invent variable mappings; binds declared map slots and catalog", () => { + const board = new Blackboard(); + board.write({ tag: "用户.需求", content: "x", source: "dictate" }); + const without = bindDictateProductsToPlaySpec(board); + const refsWithout = without.contextOrder.slots[0]!.inserts.map((i) => i.ref); + expect(refsWithout).not.toContain("变量.当前"); + expect(refsWithout).not.toContain("上下文.角色态度"); + + board.write({ + tag: "设计.变量目录", + content: JSON.stringify({ + schema: "variable-catalog.v1", + fields: [{ key: "好感", type: "number", initial: 10, user_visible: true }], + }), + source: "test", + }); + board.write({ + tag: "设计.变量映射", + content: JSON.stringify({ + schema: "value-map.v1", + maps: [ + { + id: "aff", + field: "好感", + target_tag: "上下文.角色态度", + bands: [{ min: 0, content: "冷" }], + }, + ], + }), + source: "test", + }); + const withMap = bindDictateProductsToPlaySpec(board); + const refs = withMap.contextOrder.slots[0]!.inserts.map((i) => i.ref); + expect(refs).toContain("变量.当前"); + expect(refs).toContain("上下文.角色态度"); + expect(refs).not.toContain("设计.变量映射"); + }); +}); diff --git a/tests/dictate.test.ts b/tests/dictate.test.ts index 1f1ebb1..2ff42fe 100644 --- a/tests/dictate.test.ts +++ b/tests/dictate.test.ts @@ -71,9 +71,10 @@ describe("dictate context", () => { expect(prompt).toContain("设计.开场白"); expect(prompt).toContain("输出.开场白"); expect(prompt).toContain("insert"); + expect(prompt).toContain("declare_variable"); + expect(prompt).toContain("declare_map"); expect(prompt).toMatch(/order/); expect(prompt).toContain("仅允许 toolcall"); - expect(prompt).toContain("变化越频繁"); }); it("sorts products by relative order: neg then 0 then pos", () => { diff --git a/tests/generation-compat.test.ts b/tests/generation-compat.test.ts index 77f8c25..f5588fe 100644 --- a/tests/generation-compat.test.ts +++ b/tests/generation-compat.test.ts @@ -4,6 +4,8 @@ import { applyRejectedGenerationField, classifyChatModel, fetchWithGenerationCompat, + forceReasoningEffortNoneWhenTools, + isToolsReasoningEffortConflictError, parseRejectedGenerationField, sanitizeReasoningEffort, sanitizeVerbosity, @@ -102,6 +104,58 @@ describe("applyOpenAiGeneration", () => { }); }); +describe("forceReasoningEffortNoneWhenTools", () => { + it("forces none when tools are present", () => { + const body: Record = { + tools: [{ type: "function", function: { name: "x" } }], + reasoning_effort: "high", + }; + forceReasoningEffortNoneWhenTools(body); + expect(body.reasoning_effort).toBe("none"); + }); + + it("sets none even when effort was omitted", () => { + const body: Record = { + tools: [{ type: "function", function: { name: "x" } }], + }; + forceReasoningEffortNoneWhenTools(body); + expect(body.reasoning_effort).toBe("none"); + }); + + it("leaves body alone without tools", () => { + const body: Record = { reasoning_effort: "high" }; + forceReasoningEffortNoneWhenTools(body); + expect(body.reasoning_effort).toBe("high"); + }); +}); + +describe("isToolsReasoningEffortConflictError", () => { + it("detects gpt-5.6-luna style conflict", () => { + const text = JSON.stringify({ + error: { + message: + "Provider API error: Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.", + type: "invalid_request_error", + }, + }); + expect(isToolsReasoningEffortConflictError(text, 400)).toBe(true); + }); + + it("ignores unrelated 400s", () => { + expect( + isToolsReasoningEffortConflictError( + JSON.stringify({ + error: { + message: "Unsupported parameter: temperature", + param: "temperature", + }, + }), + 400, + ), + ).toBe(false); + }); +}); + describe("parseRejectedGenerationField", () => { it("reads OpenAI unsupported parameter errors", () => { expect( @@ -151,6 +205,16 @@ describe("applyRejectedGenerationField", () => { expect(body.max_completion_tokens).toBeUndefined(); expect(body.max_tokens).toBeUndefined(); }); + + it("sets reasoning_effort to none when tools are present", () => { + const body: Record = { + tools: [{ type: "function" }], + reasoning_effort: "high", + }; + const tried = new Set(); + expect(applyRejectedGenerationField(body, "reasoning_effort", tried)).toBe(true); + expect(body.reasoning_effort).toBe("none"); + }); }); describe("fetchWithGenerationCompat", () => { @@ -177,6 +241,34 @@ describe("fetchWithGenerationCompat", () => { expect(sent[0].temperature).toBe(1); expect(sent[1].temperature).toBeUndefined(); }); + + it("retries tools+reasoning conflict by setting none", async () => { + const sent: Array> = []; + const response = await fetchWithGenerationCompat(async (body) => { + sent.push({ ...body }); + if (body.reasoning_effort !== "none") { + return new Response( + JSON.stringify({ + error: { + message: + "Function tools with reasoning_effort are not supported for gpt-5.6-luna. Set reasoning_effort to 'none'.", + }, + }), + { status: 400 }, + ); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, { + model: "gpt-5.6-luna", + tools: [{ type: "function", function: { name: "run" } }], + messages: [], + }); + + expect(response.ok).toBe(true); + expect(sent).toHaveLength(2); + expect(sent[0].reasoning_effort).toBeUndefined(); + expect(sent[1].reasoning_effort).toBe("none"); + }); }); describe("buildRequestBody", () => { @@ -234,6 +326,25 @@ describe("buildRequestBody", () => { expect(body.reasoning_effort).toBe("low"); }); + it("forces reasoning_effort none when tools are attached", () => { + const config: LlmConfig = { + baseUrl: "https://example.test/v1", + apiKey: "k", + model: "gpt-5.6-luna", + reasoningEffort: "high", + }; + const body = buildRequestBody(config, messages, { + tools: [ + { + type: "function", + function: { name: "run_worker", parameters: { type: "object" } }, + }, + ], + }); + expect(body.reasoning_effort).toBe("none"); + expect(body.tools).toHaveLength(1); + }); + it("does not forward top_k from an imported ST preset", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", diff --git a/tests/value-map.test.ts b/tests/value-map.test.ts new file mode 100644 index 0000000..ccacb8c --- /dev/null +++ b/tests/value-map.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + lookupValueMapContent, + parseValueMapDoc, + reprojectValueMaps, + serializeValueMapDoc, + upsertValueMapEntry, + VALUE_MAP_TAG, +} from "../src/skills/value-map.js"; +import { + parseVariableCatalog, + seedVariablesFromCatalog, + serializeVariableCatalog, + upsertVariableField, + VARIABLE_CATALOG_TAG, +} from "../src/skills/variable-catalog.js"; +import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js"; + +describe("variable-catalog", () => { + it("upserts fields and seeds empty 变量.当前", () => { + let doc = null as ReturnType; + const a = upsertVariableField(doc, { + key: "好感", + type: "number", + initial: 10, + user_visible: true, + }); + expect(a.error).toBeUndefined(); + const b = upsertVariableField(a.doc, { + key: "地点", + type: "string", + initial: "旧港", + user_visible: false, + }); + const raw = serializeVariableCatalog(b.doc); + const parsed = parseVariableCatalog(raw); + expect(parsed?.fields).toHaveLength(2); + expect(parsed?.fields.find((f) => f.key === "地点")?.user_visible).toBe(false); + + const seeded = seedVariablesFromCatalog({ + catalogRaw: raw, + currentRaw: null, + initialRaw: null, + }); + expect(seeded.seededKeys).toEqual(expect.arrayContaining(["好感", "地点"])); + expect(seeded.current).toContain("好感"); + expect(seeded.initial).toContain("旧港"); + }); +}); + +describe("value-map", () => { + it("looks up range bands (min inclusive, max exclusive)", () => { + const { doc } = upsertValueMapEntry(null, { + id: "aff", + field: "好感", + target_tag: "上下文.角色态度", + bands: [ + { min: 0, max: 30, content: "冷淡" }, + { min: 30, max: 60, content: "熟悉" }, + { min: 60, content: "恋爱" }, + ], + }); + const entry = doc.maps[0]!; + expect(lookupValueMapContent(entry, 0)).toBe("冷淡"); + expect(lookupValueMapContent(entry, 29)).toBe("冷淡"); + expect(lookupValueMapContent(entry, 30)).toBe("熟悉"); + expect(lookupValueMapContent(entry, 60)).toBe("恋爱"); + }); + + it("reprojects target tag when 变量.当前 changes", () => { + const board = new Blackboard(); + const { doc } = upsertValueMapEntry(null, { + id: "aff", + field: "好感", + target_tag: "上下文.角色态度", + bands: [ + { min: 0, max: 50, content: "档A" }, + { min: 50, content: "档B" }, + ], + }); + board.write({ + tag: VALUE_MAP_TAG, + content: serializeValueMapDoc(doc), + source: "test", + }); + const tableRaw = stringifyTableDoc( + createTableFromValues({ ["好感"]: 20 }, "test"), + ); + board.write({ + tag: "变量.当前", + content: tableRaw, + source: "test", + }); + expect(parseTableDoc(board.getContentByTag("变量.当前"))?.rows.map((r) => r.key)).toEqual([ + "好感", + ]); + const r1 = reprojectValueMaps({ blackboard: board, currentRaw: tableRaw }); + expect(r1.skipped, JSON.stringify(r1)).toEqual([]); + expect(r1.written[0]?.tag).toBe("上下文.角色态度"); + expect(board.getContentByTag("上下文.角色态度")).toBe("档A"); + + const tableRaw2 = stringifyTableDoc( + createTableFromValues({ ["好感"]: 80 }, "test"), + ); + board.write({ + tag: "变量.当前", + content: tableRaw2, + source: "test", + }); + reprojectValueMaps({ blackboard: board, currentRaw: tableRaw2 }); + expect(board.getContentByTag("上下文.角色态度")).toBe("档B"); + }); + + it("rejects bad target_tag", () => { + const { error } = upsertValueMapEntry(null, { + id: "x", + field: "好感", + target_tag: "设计.美学纲领", + bands: [{ content: "no" }], + }); + expect(error).toMatch(/上下文/); + }); + + it("round-trips parse", () => { + const raw = serializeValueMapDoc({ + schema: "value-map.v1", + maps: [ + { + id: "ch", + field: "章节", + target_tag: "大纲.当前章", + bands: [ + { when: "eq", value: 1, content: "第一章" }, + { when: "default", content: "未定" }, + ], + }, + ], + }); + const parsed = parseValueMapDoc(raw); + expect(parsed?.maps[0]?.bands).toHaveLength(2); + expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章"); + expect(lookupValueMapContent(parsed!.maps[0]!, 9)).toBe("未定"); + }); +}); + +describe("catalog tag constants", () => { + it("uses stable design tags", () => { + expect(VARIABLE_CATALOG_TAG).toBe("设计.变量目录"); + expect(VALUE_MAP_TAG).toBe("设计.变量映射"); + }); +}); diff --git a/web/agent-ui.js b/web/agent-ui.js index 2c63dcb..fae9134 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -47,7 +47,7 @@ const MSG_LABEL = { orchestrator_thinking: "编排器 · 思考", orchestrator_prompt: "编排器", orchestrator_assessment: "编排器 · 内容评价", - dictate_reply: "转述整理", + dictate_reply: "Boss直聘", worker_running: "执行单元", worker_output: "执行单元", worker_questions: "提问", @@ -409,9 +409,11 @@ function wireMsgActionMenu(handlers) { } return; } - if (action === "delete") { - if (!confirm("从这里重开?这条消息及之后的对话和创作状态将被移除。")) return; - msgMenuState.handlers?.onDeleteMessage?.(messageId); + if (action === "restart" || action === "delete") { + if (!confirm("从这里重开?将丢掉这条及之后的内容,并按本轮输入重新生成。")) return; + const restart = msgMenuState.handlers?.onRestartFromMessage; + if (typeof restart === "function") restart(messageId); + else msgMenuState.handlers?.onDeleteMessage?.(messageId); } }); } @@ -4926,7 +4928,7 @@ function renderSpeakWorkspace(view) { wr.message || "描述本轮行动、对话或想说的话;由游玩管线推进。"; } else if (view.creationMode === "dictate") { - title = "转述整理"; + title = "Boss 直聘"; hint = view.hints?.[0] || wr.message || @@ -4977,7 +4979,7 @@ function renderSpeakWorkspace(view) { ? workspaceViewSwitchHtml("flow") : `${ view.creationMode === "dictate" - ? "转述" + ? "Boss直聘" : canOfferSaveProduct(view) ? "落档" : "说话" @@ -5211,8 +5213,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { ? esc(view.selectedRecipe.name) : ""; stage.innerHTML = recipeName - ? `
转述配方 · ${recipeName}
` - : `
转述
`; + ? `
Boss直聘配方 · ${recipeName}
` + : `
Boss直聘
`; if (panelFeed) ensureQuestionsHostIn(panelFeed); appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, { emptyText: diff --git a/web/app.js b/web/app.js index 5c99b96..dfcf2aa 100644 --- a/web/app.js +++ b/web/app.js @@ -2092,6 +2092,7 @@ function renderSession(view, loading = false) { onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }), onRefreshMessage: (messageId) => messageAction("refresh", messageId), onDeleteMessage: (messageId) => messageAction("delete", messageId), + onRestartFromMessage: (messageId) => messageAction("restart", messageId), onRollbackToInput: (messageId, draft) => { pendingComposerDraft = String(draft ?? ""); pendingComposerDraftSeq = 0; @@ -2493,8 +2494,8 @@ async function populateDirectorSelect() { if (modeDesc) { modeDesc.hidden = false; modeDesc.textContent = dictate - ? "转述整理:直接对话写入产物;下方为转述专用配方。" - : "节点流程:按配方预置起点走工作流计划;下方为节点流程配方。"; + ? "Boss 直聘:对话写入产物;变量/映射用工具钉死;下方为直聘专用配方。" + : "Worker 式:按工序图逐步验收;下方为编排专用配方。"; } syncDesc(); }; diff --git a/web/index.html b/web/index.html index 9442584..fe9215b 100644 --- a/web/index.html +++ b/web/index.html @@ -197,8 +197,8 @@ 进料方式 @@ -237,7 +237,7 @@ - +