打通 Boss 直聘落档挂载与真值→投影映射,并修正带 tools 时的推理强度。

落档自动把产物挂进 context_order;创作期用 declare_variable/declare_map 钉目录与分档;真值变更持续重投影。顺带强制 tools 请求 reasoning_effort=none,并支持消息 restart。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
moran
2026-09-09 17:50:07 +08:00
co-authored by Cursor
parent bf0b218f7c
commit beb4c332e6
23 changed files with 1780 additions and 95 deletions
+2 -1
View File
@@ -19,7 +19,8 @@ export type ApiProfile = {
model: string; model: string;
/** /**
* 该配置默认思考强度(写入请求的 reasoning_effort)。 * 该配置默认思考强度(写入请求的 reasoning_effort)。
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。 * 省略 / auto = 不发送;none = 显式关闭;带 tools 的请求会强制 none。
* 探测永远用 low,不受此项影响。
*/ */
reasoningEffort?: string; reasoningEffort?: string;
createdAt: string; createdAt: string;
+19
View File
@@ -19,3 +19,22 @@ export {
} from "./context.js"; } from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.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";
+351
View File
@@ -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<string, unknown>;
try {
const parsed = JSON.parse(baseJson) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("bad");
}
row = parsed as Record<string, unknown>;
} 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);
}
+34 -34
View File
@@ -1,10 +1,8 @@
/** /**
* 转述整理式 · 创作进料专用系统提示 * Boss 直聘式 · 创作进料专用系统提示
* *
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
* 仅用于创作层;开玩后由游玩管线接棒。 * 仅用于创作层;开玩后由游玩管线接棒。
*
* 产物只许用 insert toolcall;聊天只做确认与建议。
*/ */
export function buildDictateSystemPrompt(opts?: { export function buildDictateSystemPrompt(opts?: {
@@ -21,55 +19,57 @@ ${opts.recipeBrief ? `要点:${opts.recipeBrief}` : ""}
` `
: ""; : "";
return `你是转述整理助手:在**创作进料**阶段,把用户原话整理成黑板产物。作用对象是本局创作上下文(对话可清空,产物保留)。 return `你是 Boss 直聘式创作助手:把用户原话整理成可落盘规格。作用对象是本局创作上下文(对话可清空,产物保留)。
**本提示只用于创作。** 开玩后的叙事由程序另调游玩 worker;不要假装在跑游玩回合。 **本提示只用于创作。** 开玩后叙事由游玩 worker 执行;不要假装在跑游玩回合。
## 落盘(仅允许 toolcall) ## 落盘(仅允许 toolcall)
产物**只能**用工具 **insert** 写入,禁止把设定/格式/开场全文只写在聊天里冒充已落盘。 禁止把设定/变量/映射全文只写在聊天里冒充已落盘。
- \`position\`:插入位置(tag),如 \`用户.需求\`、\`设计.正文组成\`、\`设计.开场白\`
- \`content\`:该位置正文
- \`order\`(可选):相对顺序,数字可负、可为 0、可正;**越小越靠前**
**order 怎么取:** ### insert — 不变型固定产物
1. 变化越频繁 → 数字越大(越靠后)。例:开场白常改 > 正文组成 > 需求/美学。 - position:\`用户.*\` / \`设计.*\`(如需求、美学、正文组成、开场白)
2. 改动频率差不多 → 越重要数字越小(越靠前)。例:用户.需求 通常最前。 - content:正文;order 可选(越小越靠前;越常改越大)
3. 同轮可多次 insert;程序按 order 升序拼进上下文。省略 order 时沿用该 position 原序,或按 tag 默认。 - 勿用 insert 写变量目录或映射(用下面两个工具)
**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C、提问。不要复述 insert 全文。 ### declare_variable — 跨轮真值
- key / type / initial / user_visible(用户是否可见)
- 只立必须跨轮记住的状态;派生文案不要立成可写变量
**分清两件事:** ### declare_map — 真值→投影上下文
- **正文组成(格式)**:insert → \`设计.正文组成\`(壳与分区,非故事)。 - id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[]
- **开场白(内容)**:insert → \`设计.开场白\`(按格式写的首屏);落档/开玩同步 \`输出.开场白\`。 - bands:\`{min,max,content}\` 区间(min 含 max 不含),或 \`{value,content}\`,或 \`{when:"default",content}\`
- 槽位固定、内容随真值换档;旧档离开上下文
**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C、提问。不要复述 tool 全文。
**分清:**
- 正文组成(格式)→ insert \`设计.正文组成\`
- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`
- 好感分档性格等 → declare_variable + declare_map,不要 insert 一整张表进设定散文
${recipeBlock} ${recipeBlock}
## 按用户本轮意图择一为主 ## 按用户本轮意图择一为主
### A · 钉设定 ### A · 钉设定
1. insert 设定类 position(保留原意原词);order 偏小(靠前)。 insert 设定类;order 偏小。回复:确认体验 + 建议补充。
2. 回复:确认体验(2~4 句 + 问是否这个)+ 建议补充(1~2 缺口,各 A/B/C)。
### B · 钉正文格式 ### B · 钉正文格式
1. 必须 insert \`设计.正文组成\`(见规格);order 居中(约 0)。 必须 insert \`设计.正文组成\`。
2. 回复短说明已钉壳与主块。
### C · 生成开场白 ### C · 生成开场白
1. 先有 \`设计.正文组成\`(否则先 B;缺设定先 A)。 先有正文组成;insert \`设计.开场白\`(Markdown;岔路用 \`## 岔路\`)。
2. insert \`设计.开场白\`:Markdown 主阅读;岔路用 \`## 岔路\`;勿写 【body】/【footer】。order 偏大(靠后)。
3. 回复只短说已写入;全文在 content。
## 常用 position 与默认序感 ### D · 钉变量与映射
- 用户.需求、设计.美学纲领等设定:靠前(负) 用户提到跨轮状态、分档态度、章大纲切换时:declare_variable(含是否对用户可见)→ declare_map(分档正文)。可同轮多次调用。
- 设计.正文组成:中间(0 附近)
- 设计.开场白:靠后(正)
## 设计.正文组成 · 规格(格式) ## 常用序感
JSON 即可。核心:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`(区域 body/footer 等)。 - 需求/美学等不变设定:靠前(负)
文本局默认 shell_id=\`prose\`;至少一块 body。要选项再声明 footer。 - 正文组成:中间(0)
- 开场白:靠后(正)
## 设计.开场白 · 规格(内容) ## 设计.正文组成
按正文组成职责写满主阅读;Markdown;确认与建议不进 content。 JSON:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`。文本局默认 prose;至少一块 body。
## 其它 ## 其它
对话明显过长时 clear_dialogue(产物保留);否则不要清。 对话明显过长时 clear_dialogue(产物与变量/映射保留)。
`; `;
} }
+84 -8
View File
@@ -1,8 +1,7 @@
import type { ToolDefinition } from "../llm/client.js"; import type { ToolDefinition } from "../llm/client.js";
/** /**
* 转述创作期工具:只允许用 toolcall 写产物。 * Boss 直聘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
* insert(position, content, order?) —— 打标记落盘并带相对序。
*/ */
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{ {
@@ -10,24 +9,24 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
function: { function: {
name: "insert", name: "insert",
description: description:
"唯一写产物入口:把正文插入黑板。确认/建议/思考只写在普通回复,禁止放进 content。用 order 标相对先后以便拼装。", "写入不变型固定产物(设定/格式/开场等)。确认与建议只写在普通回复。用 order 标相对先后。",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
position: { position: {
type: "string", type: "string",
description: description:
"插入位置(产物 tag)。须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白", "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白。勿用本工具写变量目录/映射。",
}, },
content: { content: {
type: "string", type: "string",
description: description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场(Markdown,用 ## 分段,勿写【body】、勿夹确认/建议)", "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场(Markdown)",
}, },
order: { order: {
type: "number", type: "number",
description: description:
"相对顺序(可负、0、正)。越小越靠前。原则:越常改越大(靠后);改动频率差不多时,越重要越小(靠前)。省略则沿用原序或按 tag 默认。", "相对顺序(可负)。越小越靠前。越常改越大。省略则沿用原序或默认。",
}, },
}, },
required: ["position", "content"], required: ["position", "content"],
@@ -38,9 +37,86 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{ {
type: "function", type: "function",
function: { function: {
name: "clear_dialogue", name: "declare_variable",
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {
+91 -14
View File
@@ -12,11 +12,28 @@ import {
type DictateChatTurn, type DictateChatTurn,
type DictateProduct, type DictateProduct,
} from "./types.js"; } 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 = { export type DictateTurnHandlers = {
listProducts: () => DictateProduct[]; listProducts: () => DictateProduct[];
/** order 省略时由实现沿用原序或默认 */ /** order 省略时由实现沿用原序或默认 */
writeProduct: (tag: string, content: string, order?: number) => void; writeProduct: (tag: string, content: string, order?: number) => void;
/** 读/写变量目录与映射(程序校验后的 JSON) */
readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void;
clearDialogue: () => void; clearDialogue: () => void;
onToolCall?: (name: string, detail: string) => void; onToolCall?: (name: string, detail: string) => void;
onThinkingDelta?: (delta: string) => void; onThinkingDelta?: (delta: string) => void;
@@ -31,8 +48,7 @@ export type DictateTurnResult = {
}; };
/** /**
* 跑一轮转述整理:上下文 = 预设提示 + 按相对序的产物 + 全量对话。 * 跑一轮 Boss 直聘整理:上下文 = 预设提示 + 按相对序的产物 + 全量对话。
* 产物只许 insert(position, content, order?);最终文本即对用户回复。
*/ */
export async function runDictateTurn(params: { export async function runDictateTurn(params: {
llm: LlmProvider; llm: LlmProvider;
@@ -59,7 +75,6 @@ export async function runDictateTurn(params: {
products: params.handlers.listProducts(), products: params.handlers.listProducts(),
dialogue, dialogue,
}); });
// driver 会再前置 system;此处只传 user 拼装块,避免双重 system
const userBlock = assembled.filter((m) => m.role !== "system"); const userBlock = assembled.filter((m) => m.role !== "system");
const run = await driver.run({ const run = await driver.run({
@@ -67,17 +82,20 @@ export async function runDictateTurn(params: {
messages: userBlock, messages: userBlock,
tools: DICTATE_TOOL_DEFINITIONS, tools: DICTATE_TOOL_DEFINITIONS,
caller: "dictate_agent", caller: "dictate_agent",
label: "转述整理", label: "Boss直聘",
onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone, onThinkingDone: params.handlers.onThinkingDone,
handleStep: (calls) => { handleStep: (calls) => {
const results = calls.map((call) => { const results = calls.map((call) => {
const args = safeParseArgs(call.arguments); const args = safeParseArgs(call.arguments);
if (call.name === "insert" || call.name === "write_product") { if (call.name === "insert" || call.name === "write_product") {
const tag = String( const tag = String(args.position ?? args.tag ?? "").trim();
args.position ?? args.tag ?? "",
).trim();
const content = String(args.content ?? ""); 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)) { if (!isAllowedProductTag(tag)) {
const err = `拒绝写入:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`; const err = `拒绝写入:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`;
params.handlers.onToolCall?.(call.name, err); 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") { if (call.name === "clear_dialogue") {
const reason = String(args.reason ?? "").trim() || "上下文过长"; const reason = String(args.reason ?? "").trim() || "上下文过长";
params.handlers.clearDialogue(); params.handlers.clearDialogue();
@@ -114,7 +193,7 @@ export async function runDictateTurn(params: {
content: JSON.stringify({ content: JSON.stringify({
ok: true, ok: true,
cleared: true, cleared: true,
note: "对话已清空;产物仍在。后续上下文以产物为准。", note: "对话已清空;产物与变量/映射仍在。",
}), }),
}; };
} }
@@ -126,8 +205,6 @@ export async function runDictateTurn(params: {
}, },
}); });
// driver 的 messages 在首轮已固定;clear 后若还要续写依赖模型已知工具结果。
// 若模型只调工具不说话,给一个兜底回复。
let reply = ""; let reply = "";
if (run.stop.kind === "text") { if (run.stop.kind === "text") {
reply = run.stop.content.trim(); reply = run.stop.content.trim();
@@ -153,11 +230,11 @@ export async function runDictateTurn(params: {
function safeParseArgs(raw: string): Record<string, unknown> { function safeParseArgs(raw: string): Record<string, unknown> {
try { try {
const parsed = JSON.parse(raw || "{}"); const parsed = JSON.parse(raw || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed as Record<string, unknown>; return {};
} }
return parsed as Record<string, unknown>;
} catch { } catch {
/* ignore */ return {};
} }
return {};
} }
+1
View File
@@ -37,6 +37,7 @@ export function isAllowedProductTag(tag: string): boolean {
export function defaultDictateOrder(tag: string): number { export function defaultDictateOrder(tag: string): number {
const t = tag.trim(); const t = tag.trim();
if (t === "用户.需求") return -40; if (t === "用户.需求") return -40;
if (t === "设计.变量目录" || t === "设计.变量映射") return -25;
if (/美学|纲领|禁忌|示例/.test(t)) return -30; if (/美学|纲领|禁忌|示例/.test(t)) return -30;
if (/文风|叙事指南|篇幅|结构/.test(t)) return -20; if (/文风|叙事指南|篇幅|结构/.test(t)) return -20;
if (/正文组成|回复格式/.test(t)) return 0; if (/正文组成|回复格式/.test(t)) return 0;
+13 -6
View File
@@ -5,6 +5,7 @@
import { import {
applyRejectedGenerationField, applyRejectedGenerationField,
isToolsReasoningEffortConflictError,
parseRejectedGenerationField, parseRejectedGenerationField,
} from "./generation-compat.js"; } from "./generation-compat.js";
@@ -168,7 +169,7 @@ async function postOnce(
/** /**
* 探测请求始终带 reasoning_effort=low。 * 探测请求始终带 reasoning_effort=low。
* 若供应商拒收该字段,剥掉后再试一次。 * 若供应商拒收该字段,剥掉后再试;tools 冲突则改 none 再试。
*/ */
async function postChat( async function postChat(
config: ProbeConfig, config: ProbeConfig,
@@ -179,14 +180,20 @@ async function postChat(
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT, reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
}; };
const first = await postOnce(config, withEffort); const first = await postOnce(config, withEffort);
if (first.ok || first.status !== 400) return first; if (first.ok || (first.status !== 400 && first.status !== 422)) return first;
const rejected = parseRejectedGenerationField(first.text);
if (rejected !== "reasoning_effort") return first;
const retryBody = { ...withEffort }; const retryBody = { ...withEffort };
const tried = new Set<string>(); const tried = new Set<string>();
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 first;
} }
return postOnce(config, retryBody); return postOnce(config, retryBody);
+3
View File
@@ -3,6 +3,7 @@ import type { GenerationParameters } from "../types/preset.js";
import { import {
applyOpenAiGeneration, applyOpenAiGeneration,
fetchWithGenerationCompat, fetchWithGenerationCompat,
forceReasoningEffortNoneWhenTools,
sanitizeReasoningEffort, sanitizeReasoningEffort,
} from "./generation-compat.js"; } from "./generation-compat.js";
import { consumeOpenAiToolStream } from "./stream-complete.js"; import { consumeOpenAiToolStream } from "./stream-complete.js";
@@ -147,6 +148,8 @@ export function buildRequestBody(
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, { applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort, reasoningEffort: config.reasoningEffort,
}); });
// 对齐 imyai:带 tools 时强制 none(省略字段网关仍可能注入默认 effort)
forceReasoningEffortNoneWhenTools(body);
if (options?.responseFormat === "json_object") { if (options?.responseFormat === "json_object") {
body.response_format = { type: "json_object" }; body.response_format = { type: "json_object" };
+82 -3
View File
@@ -6,7 +6,8 @@ import type { GenerationParameters } from "../types/preset.js";
* - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发 * - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发
* - 推理模型去掉 temperature / top_p / penalty * - 推理模型去掉 temperature / top_p / penalty
* - o 系列与 gpt-5 用 max_completion_tokens * - o 系列与 gpt-5 用 max_completion_tokens
* - 仍 400 时按错误信息剥掉不支持字段再试 * - 带 tools 时强制 reasoning_effort=none(gpt-5.6-luna 等网关:省略字段仍会注入默认 effort)
* - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则改 none 再试
*/ */
const REASONING_EFFORT_VALUES = [ const REASONING_EFFORT_VALUES = [
@@ -157,6 +158,46 @@ export function applyOpenAiGeneration(
} }
} }
/** chat/completions + function tools 时须显式 none,否则网关可能注入默认 effort 并 400。 */
export function forceReasoningEffortNoneWhenTools(
body: Record<string, unknown>,
): 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 { function readErrorMessage(raw: unknown): string {
if (!raw || typeof raw !== "object") return ""; if (!raw || typeof raw !== "object") return "";
const err = (raw as { error?: unknown }).error; const err = (raw as { error?: unknown }).error;
@@ -238,12 +279,42 @@ export function applyRejectedGenerationField(
return true; 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; if (!(field in body)) return false;
delete body[field]; delete body[field];
tried.add(field); tried.add(field);
return true; return true;
} }
function applyToolsReasoningEffortNone(
body: Record<string, unknown>,
tried: Set<string>,
): 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 { function replayResponse(source: Response, text: string): Response {
return new Response(text, { return new Response(text, {
status: source.status, status: source.status,
@@ -262,11 +333,19 @@ export async function fetchWithGenerationCompat(
for (let attempt = 0; attempt < 8; attempt++) { for (let attempt = 0; attempt < 8; attempt++) {
last = await send(body); 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 text = await last.text();
const next = { ...body };
if (isToolsReasoningEffortConflictError(text, last.status)) {
if (applyToolsReasoningEffortNone(next, tried)) {
body = next;
continue;
}
}
const field = parseRejectedGenerationField(text); const field = parseRejectedGenerationField(text);
if (!field) return replayResponse(last, text); if (!field) return replayResponse(last, text);
const next = { ...body };
if (!applyRejectedGenerationField(next, field, tried)) { if (!applyRejectedGenerationField(next, field, tried)) {
return replayResponse(last, text); return replayResponse(last, text);
} }
+78 -7
View File
@@ -136,6 +136,15 @@ import {
CREATION_INTAKE_MODE_TAG, CREATION_INTAKE_MODE_TAG,
isDictateModeValue, isDictateModeValue,
} from "../dictate/types.js"; } 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 { import {
readPlayTurnQueue, readPlayTurnQueue,
withPlayTurnQueue, withPlayTurnQueue,
@@ -1983,13 +1992,26 @@ export class PhaseRuntime {
/** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */ /** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */
private ensurePlaySpecReady(): void { private ensurePlaySpecReady(): void {
const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim(); // Boss 直聘:落档/开玩时自动挂载产物;种变量初值;按映射重投影
const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); if (this.isDictateIntakeMode()) {
if (!finalTag) { const bound = applyDictatePlayBind(this.blackboard);
const spec = draft?.trim() if (bound.boundTags.length) {
? draft this.onMessage(
: JSON.stringify({ play_slots: defaultPlaySlots() }); `[Boss直聘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup"); );
} 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; if (this.session.slots.designInstanceReady) return;
this.session = { 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 能在收口前插入节点 */ /** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
private unsealCreationOpening(): void { private unsealCreationOpening(): void {
const reopened = reopenCreationFlowRaw( const reopened = reopenCreationFlowRaw(
@@ -2515,6 +2583,9 @@ export class PhaseRuntime {
if (nextDoc) { if (nextDoc) {
this.applyTableSideEffects(prevDoc, nextDoc, workerId); this.applyTableSideEffects(prevDoc, nextDoc, workerId);
} }
if (tag === OPENING_CURRENT_VARS_TAG || tag === "变量.当前") {
this.applyValueMapProjections(workerId);
}
// 裁决包 / 旁观维护包 → 合并进变量.当前(可触发 side_effects) // 裁决包 / 旁观维护包 → 合并进变量.当前(可触发 side_effects)
if (tag === SETTLEMENT_TAG) { if (tag === SETTLEMENT_TAG) {
this.applySettlementVariableChanges(toWrite, workerId); this.applySettlementVariableChanges(toWrite, workerId);
+69 -6
View File
@@ -163,6 +163,7 @@ import {
createUserVariantMessage, createUserVariantMessage,
ensureBranchForEdit, ensureBranchForEdit,
ensureBranchForRefresh, ensureBranchForRefresh,
findPrecedingUserIndex,
isRefreshableMessage, isRefreshableMessage,
recordPreMessageCheckpoint, recordPreMessageCheckpoint,
switchBranchVariant, switchBranchVariant,
@@ -607,7 +608,7 @@ export class SessionManager {
}。` }。`
: ""; : "";
const welcome = const welcome =
`转述整理模式:直接说你想要的体验与设定。我会整理进产物;缺什么会问你。对话过长时可清空对话(产物保留)。${recipeLine}`; `Boss 直聘模式:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`;
s.messages.push({ s.messages.push({
id: randomUUID(), id: randomUUID(),
role: "system", role: "system",
@@ -615,7 +616,7 @@ export class SessionManager {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
kind: "orchestrator_prompt", kind: "orchestrator_prompt",
actor: "orchestrator", actor: "orchestrator",
title: "转述整理", title: "Boss直聘",
body: welcome, 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: () => { clearDialogue: () => {
const kept = this.listDictateProducts(s); const kept = this.listDictateProducts(s);
const lastUser = [...s.messages] const lastUser = [...s.messages]
@@ -736,6 +750,11 @@ export class SessionManager {
dialogue = extractDictateDialogue(s.messages); dialogue = extractDictateDialogue(s.messages);
}, },
onToolCall: (name, detail) => { onToolCall: (name, detail) => {
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push( s.messages.push(
this.msg("system", `[总管 tool] ${name}: ${detail}`), this.msg("system", `[总管 tool] ${name}: ${detail}`),
); );
@@ -755,6 +774,11 @@ export class SessionManager {
}); });
this.clearAgentThinking(s.runtime.getSession().id); this.clearAgentThinking(s.runtime.getSession().id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push({ s.messages.push({
id: randomUUID(), id: randomUUID(),
role: "system", role: "system",
@@ -1655,9 +1679,22 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try { try {
const owned = await this.runExclusive(id, s, () => const owned = await this.runExclusive(id, s, async () => {
s.runtime.submitInput(trimmed), 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); if (!owned) return this.toView(id);
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
@@ -1790,7 +1827,7 @@ export class SessionManager {
return this.toView(id); return this.toView(id);
} }
/** 删除消息及其后的对话 */ /** 删除消息及其后的对话(仅截断,不重生成) */
async deleteMessage(id: string, messageId: string): Promise<SessionView> { async deleteMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id); const s = this.require(id);
const idx = s.messages.findIndex((m) => m.id === messageId); const idx = s.messages.findIndex((m) => m.id === messageId);
@@ -1810,11 +1847,37 @@ export class SessionManager {
const n = Number(key); const n = Number(key);
if (n >= idx) delete s.branchState.preMessageCheckpoints[n]; if (n >= idx) delete s.branchState.preMessageCheckpoints[n];
} }
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
this.persist(s); this.persist(s);
return this.toView(id); return this.toView(id);
} }
/**
* 从这里重开:丢掉该条及之后内容,并按本轮用户输入重新生成(重 roll)。
* 用户消息 → 同文编辑重跑;可刷新的 Worker 回复 → refresh;其余 → 回溯到上一轮用户输入再 edit。
*/
async restartFromMessage(id: string, messageId: string): Promise<SessionView> {
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) */ /** 左右切换同位消息版本(swipe / branch) */
async switchMessageVariant( async switchMessageVariant(
id: string, id: string,
+3 -1
View File
@@ -240,7 +240,7 @@ const server = createServer(async (req, res) => {
} }
const messageActionMatch = sub.match( const messageActionMatch = sub.match(
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete)$/, /^\/messages\/([^/]+)\/(edit|refresh|variant|delete|restart)$/,
); );
if (req.method === "POST" && messageActionMatch) { if (req.method === "POST" && messageActionMatch) {
const messageId = decodeURIComponent(messageActionMatch[1]); const messageId = decodeURIComponent(messageActionMatch[1]);
@@ -275,6 +275,8 @@ const server = createServer(async (req, res) => {
); );
} else if (action === "delete") { } else if (action === "delete") {
view = await sessionManager.deleteMessage(sessionId, messageId); view = await sessionManager.deleteMessage(sessionId, messageId);
} else if (action === "restart") {
view = await sessionManager.restartFromMessage(sessionId, messageId);
} else { } else {
json(res, 400, { error: "未知 action" }); json(res, 400, { error: "未知 action" });
return; return;
+321
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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))];
}
+222
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
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<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
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 };
}
+124
View File
@@ -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("设计.变量映射");
});
});
+2 -1
View File
@@ -71,9 +71,10 @@ describe("dictate context", () => {
expect(prompt).toContain("设计.开场白"); expect(prompt).toContain("设计.开场白");
expect(prompt).toContain("输出.开场白"); expect(prompt).toContain("输出.开场白");
expect(prompt).toContain("insert"); expect(prompt).toContain("insert");
expect(prompt).toContain("declare_variable");
expect(prompt).toContain("declare_map");
expect(prompt).toMatch(/order/); expect(prompt).toMatch(/order/);
expect(prompt).toContain("仅允许 toolcall"); expect(prompt).toContain("仅允许 toolcall");
expect(prompt).toContain("变化越频繁");
}); });
it("sorts products by relative order: neg then 0 then pos", () => { it("sorts products by relative order: neg then 0 then pos", () => {
+111
View File
@@ -4,6 +4,8 @@ import {
applyRejectedGenerationField, applyRejectedGenerationField,
classifyChatModel, classifyChatModel,
fetchWithGenerationCompat, fetchWithGenerationCompat,
forceReasoningEffortNoneWhenTools,
isToolsReasoningEffortConflictError,
parseRejectedGenerationField, parseRejectedGenerationField,
sanitizeReasoningEffort, sanitizeReasoningEffort,
sanitizeVerbosity, sanitizeVerbosity,
@@ -102,6 +104,58 @@ describe("applyOpenAiGeneration", () => {
}); });
}); });
describe("forceReasoningEffortNoneWhenTools", () => {
it("forces none when tools are present", () => {
const body: Record<string, unknown> = {
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<string, unknown> = {
tools: [{ type: "function", function: { name: "x" } }],
};
forceReasoningEffortNoneWhenTools(body);
expect(body.reasoning_effort).toBe("none");
});
it("leaves body alone without tools", () => {
const body: Record<string, unknown> = { 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", () => { describe("parseRejectedGenerationField", () => {
it("reads OpenAI unsupported parameter errors", () => { it("reads OpenAI unsupported parameter errors", () => {
expect( expect(
@@ -151,6 +205,16 @@ describe("applyRejectedGenerationField", () => {
expect(body.max_completion_tokens).toBeUndefined(); expect(body.max_completion_tokens).toBeUndefined();
expect(body.max_tokens).toBeUndefined(); expect(body.max_tokens).toBeUndefined();
}); });
it("sets reasoning_effort to none when tools are present", () => {
const body: Record<string, unknown> = {
tools: [{ type: "function" }],
reasoning_effort: "high",
};
const tried = new Set<string>();
expect(applyRejectedGenerationField(body, "reasoning_effort", tried)).toBe(true);
expect(body.reasoning_effort).toBe("none");
});
}); });
describe("fetchWithGenerationCompat", () => { describe("fetchWithGenerationCompat", () => {
@@ -177,6 +241,34 @@ describe("fetchWithGenerationCompat", () => {
expect(sent[0].temperature).toBe(1); expect(sent[0].temperature).toBe(1);
expect(sent[1].temperature).toBeUndefined(); expect(sent[1].temperature).toBeUndefined();
}); });
it("retries tools+reasoning conflict by setting none", async () => {
const sent: Array<Record<string, unknown>> = [];
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", () => { describe("buildRequestBody", () => {
@@ -234,6 +326,25 @@ describe("buildRequestBody", () => {
expect(body.reasoning_effort).toBe("low"); 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", () => { it("does not forward top_k from an imported ST preset", () => {
const config: LlmConfig = { const config: LlmConfig = {
baseUrl: "https://example.test/v1", baseUrl: "https://example.test/v1",
+152
View File
@@ -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<typeof parseVariableCatalog>;
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("设计.变量映射");
});
});
+10 -8
View File
@@ -47,7 +47,7 @@ const MSG_LABEL = {
orchestrator_thinking: "编排器 · 思考", orchestrator_thinking: "编排器 · 思考",
orchestrator_prompt: "编排器", orchestrator_prompt: "编排器",
orchestrator_assessment: "编排器 · 内容评价", orchestrator_assessment: "编排器 · 内容评价",
dictate_reply: "转述整理", dictate_reply: "Boss直聘",
worker_running: "执行单元", worker_running: "执行单元",
worker_output: "执行单元", worker_output: "执行单元",
worker_questions: "提问", worker_questions: "提问",
@@ -409,9 +409,11 @@ function wireMsgActionMenu(handlers) {
} }
return; return;
} }
if (action === "delete") { if (action === "restart" || action === "delete") {
if (!confirm("从这里重开?这条消息及之后的对话和创作状态将被移除。")) return; if (!confirm("从这里重开?将丢掉这条及之后的内容,并按本轮输入重新生成。")) return;
msgMenuState.handlers?.onDeleteMessage?.(messageId); 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 || wr.message ||
"描述本轮行动、对话或想说的话;由游玩管线推进。"; "描述本轮行动、对话或想说的话;由游玩管线推进。";
} else if (view.creationMode === "dictate") { } else if (view.creationMode === "dictate") {
title = "转述整理"; title = "Boss 直聘";
hint = hint =
view.hints?.[0] || view.hints?.[0] ||
wr.message || wr.message ||
@@ -4977,7 +4979,7 @@ function renderSpeakWorkspace(view) {
? workspaceViewSwitchHtml("flow") ? workspaceViewSwitchHtml("flow")
: `<span class="workspace-surface-kicker">${ : `<span class="workspace-surface-kicker">${
view.creationMode === "dictate" view.creationMode === "dictate"
? "转述" ? "Boss直聘"
: canOfferSaveProduct(view) : canOfferSaveProduct(view)
? "落档" ? "落档"
: "说话" : "说话"
@@ -5211,8 +5213,8 @@ export function renderMessageFeed(view, loading, handlers = {}) {
? esc(view.selectedRecipe.name) ? esc(view.selectedRecipe.name)
: ""; : "";
stage.innerHTML = recipeName stage.innerHTML = recipeName
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">转述</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>` ? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">转述</span></header>`; : `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span></header>`;
if (panelFeed) ensureQuestionsHostIn(panelFeed); if (panelFeed) ensureQuestionsHostIn(panelFeed);
appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, { appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, {
emptyText: emptyText:
+3 -2
View File
@@ -2092,6 +2092,7 @@ function renderSession(view, loading = false) {
onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }), onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }),
onRefreshMessage: (messageId) => messageAction("refresh", messageId), onRefreshMessage: (messageId) => messageAction("refresh", messageId),
onDeleteMessage: (messageId) => messageAction("delete", messageId), onDeleteMessage: (messageId) => messageAction("delete", messageId),
onRestartFromMessage: (messageId) => messageAction("restart", messageId),
onRollbackToInput: (messageId, draft) => { onRollbackToInput: (messageId, draft) => {
pendingComposerDraft = String(draft ?? ""); pendingComposerDraft = String(draft ?? "");
pendingComposerDraftSeq = 0; pendingComposerDraftSeq = 0;
@@ -2493,8 +2494,8 @@ async function populateDirectorSelect() {
if (modeDesc) { if (modeDesc) {
modeDesc.hidden = false; modeDesc.hidden = false;
modeDesc.textContent = dictate modeDesc.textContent = dictate
? "转述整理:直接对话写入产物;下方为转述专用配方。" ? "Boss 直聘:对话写入产物;变量/映射用工具钉死;下方为直聘专用配方。"
: "节点流程:按配方预置起点走工作流计划;下方为节点流程配方。"; : "Worker 式:按工序图逐步验收;下方为编排专用配方。";
} }
syncDesc(); syncDesc();
}; };
+3 -3
View File
@@ -197,8 +197,8 @@
进料方式 进料方式
<select id="select-creation-mode" required> <select id="select-creation-mode" required>
<option value="" selected disabled>请选择…</option> <option value="" selected disabled>请选择…</option>
<option value="recipe">节点流程</option> <option value="recipe">Worker 式(工序图)</option>
<option value="dictate">转述整理(对话直填)</option> <option value="dictate">Boss 直聘(对话直填)</option>
</select> </select>
</label> </label>
<p class="dialog-desc" id="creation-mode-desc" hidden></p> <p class="dialog-desc" id="creation-mode-desc" hidden></p>
@@ -237,7 +237,7 @@
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button> <button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button> <button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button> <button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button> <button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="restart">从这里重开</button>
</menu> </menu>
<menu id="flow-node-menu" class="context-menu" hidden> <menu id="flow-node-menu" class="context-menu" hidden>
+2 -1
View File
@@ -152,12 +152,13 @@
思考强度 思考强度
<select name="reasoningEffort"> <select name="reasoningEffort">
<option value="">默认(不发送)</option> <option value="">默认(不发送)</option>
<option value="none">none · 关闭思考(带 tools 时自动用)</option>
<option value="low">low · 轻量</option> <option value="low">low · 轻量</option>
<option value="medium">medium</option> <option value="medium">medium</option>
<option value="high">high · 增强</option> <option value="high">high · 增强</option>
<option value="max">max · 深度</option> <option value="max">max · 深度</option>
</select> </select>
<span class="field-hint">写入 reasoning_effort;「探测」始终用 low,与此项无关。GLM 建议 low / high / max。</span> <span class="field-hint">写入 reasoning_effort;「探测」始终用 low,与此项无关。带 function tools 的请求会强制 none(兼容 gpt-5.6-luna 等)。GLM 建议 low / high / max。</span>
</label> </label>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" id="profile-cancel" class="btn-secondary">取消</button> <button type="button" id="profile-cancel" class="btn-secondary">取消</button>