对话落盘同一轮填写失败后可以按次数补写。

游玩投影不再送进只给程序用的壳源码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 08:29:38 +08:00
co-authored by Cursor
parent bc77bc4a3c
commit b864591afa
23 changed files with 980 additions and 113 deletions
+12
View File
@@ -51,6 +51,18 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM
**不是** agent 在游玩时自由往 prompt 里插段落;agent 只决定 **invoke 哪个 skill**;该 skill 的契约决定看见什么。
### 2.1 落档不进游玩投影
对话落盘绑定时(`src/dictate/play-bind.ts`)下列 tag **不**写入 `设计.上下文投影排序`:
| 类别 | 例 | 原因 |
|------|----|------|
| 创作中转 `feeds: design_only` | `设计.本局创作方案`、`设计.美学纲领与交互范式` | 只指导后续写作 |
| 呈现壳 `feeds: program` | `设计.正文组成`、`设计.0层正文组成` | 程序持有 html/css/js;模型只经 `contractOutputInstruction` 看见数据契约 |
| 开场 / 编排 | `设计.开场白#*`、`设计.worker集`、排序表自身 | 开场进历史;编排不重复挂 |
游玩填楼时:程序读壳并灌 `present.v1`;主世界/转述 system 里只追加契约区块列表,不附 frontend 源码。
---
## 3. 三分工
+18
View File
@@ -17,11 +17,18 @@ export type AppSettings = {
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
*/
preferenceCollectEveryTurns?: number;
/**
* 对话落盘同一轮里,第一次填写失败后最多再补写几次。
* 0 = 只填一次,失败留给下一句用户消息。默认 0。
*/
dictateFillRetries?: number;
};
const FILE_NAME = "settings.json";
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
const DEFAULT_PREF_COLLECT_EVERY = 10;
const DEFAULT_DICTATE_FILL_RETRIES = 0;
const MAX_DICTATE_FILL_RETRIES = 12;
function settingsPath(): string {
return path.join(getUserDataDir(), FILE_NAME);
@@ -34,6 +41,7 @@ function defaultSettings(): AppSettings {
activePresetId: null,
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY,
dictateFillRetries: DEFAULT_DICTATE_FILL_RETRIES,
};
}
@@ -49,6 +57,13 @@ export function normalizePreferenceCollectEveryTurns(raw: unknown): number {
return Math.min(100, Math.floor(n));
}
/** 同轮补写次数。0 = 只填一次;非法值回落到 0。 */
export function normalizeDictateFillRetries(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_DICTATE_FILL_RETRIES;
return Math.min(MAX_DICTATE_FILL_RETRIES, Math.floor(n));
}
export function loadAppSettings(): AppSettings {
ensureUserDataDirs();
try {
@@ -65,6 +80,9 @@ export function loadAppSettings(): AppSettings {
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY,
),
dictateFillRetries: normalizeDictateFillRetries(
parsed.dictateFillRetries ?? DEFAULT_DICTATE_FILL_RETRIES,
),
};
} catch {
return defaultSettings();
+7 -7
View File
@@ -1,6 +1,7 @@
import {
catalogModulesForIntake,
isDictateProgramModule,
moduleOwnsArtifactTag,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
@@ -318,8 +319,8 @@ export function formatCreationPlanProgress(params: {
for (const item of params.plan.items) {
const module = modules.get(item.module_id);
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
const landedTags = productTags.filter(
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
const landedTags = productTags.filter((tag) =>
moduleOwnsArtifactTag(module, tag),
);
const scores = landedTags
.map((tag) => {
@@ -382,12 +383,11 @@ export function requiredCreationPlanGaps(params: {
return plan.items
.filter((item) => item.tier === "必须")
.filter((item) => {
const artifact = modules.get(item.module_id)?.artifact;
const module = modules.get(item.module_id);
return (
!artifact ||
!params.products.some(
(product) =>
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
!module ||
!params.products.some((product) =>
moduleOwnsArtifactTag(module, product.tag),
)
);
})
+3 -3
View File
@@ -5,7 +5,7 @@
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领与交互范式";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领";
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
@@ -94,7 +94,7 @@ export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): strin
lines.push(
score.anchored
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
);
return lines.join("\n");
}
@@ -115,7 +115,7 @@ export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领与交互范式」的「体验锚定」,再写开场。`;
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领」的「体验锚定」,再写开场。`;
}
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
+1
View File
@@ -93,6 +93,7 @@ export {
bindDictateProductsToPlaySpec,
buildDictateContextOrder,
collectDictateBindProducts,
isExcludedFromPlayBind,
type DictatePlayBindResult,
} from "./play-bind.js";
export {
+4
View File
@@ -17,8 +17,12 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
"设计.开场白": "opening-setup",
"设计.开场白与开场变量": "opening-setup",
"设计.美学纲领": "aesthetics-interaction",
"设计.美学纲领与交互范式": "aesthetics-interaction",
"设计.故事推进": "story-progression",
"设计.故事推进与交互范式": "story-progression",
"设计.文风": "narrative-guide",
"设计.监控栏": "status-bar",
"设计.实现机制": "mechanism",
};
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
+51 -7
View File
@@ -68,10 +68,32 @@ const SKIP_BIND_TAGS = new Set([
MAINTENANCE_STRATEGY_TAG,
]);
/**
* 创作中转(模块 meta `feeds: design_only`)。
* 只指导后续写作,不进游玩每轮投影。
*/
const DESIGN_ONLY_BIND_TAGS = new Set([
"设计.本局创作方案",
"设计.美学纲领与交互范式",
"设计.美学纲领",
]);
/**
* 呈现壳由程序持有:游玩读 frontend 灌 present.v1,
* 模型只经 `contractOutputInstruction` 看见数据契约,不把 html/css/js 塞进上下文。
*/
const REPLY_SHELL_BIND_TAGS = new Set([
"设计.正文组成",
"设计.0层正文组成",
"设计.回复格式",
]);
function shouldSkipBindTag(tag: string): boolean {
const t = tag.trim();
if (!t) return true;
if (SKIP_BIND_TAGS.has(t)) return true;
if (DESIGN_ONLY_BIND_TAGS.has(t)) return true;
if (REPLY_SHELL_BIND_TAGS.has(t)) return true;
if (t.startsWith("设计.开场白#")) return true;
if (t.startsWith("输出.")) return true;
if (t.startsWith("运行.")) return true;
@@ -79,9 +101,14 @@ function shouldSkipBindTag(tag: string): boolean {
return false;
}
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
/** 是否不应挂进游玩投影(创作中转、呈现壳、开场等) */
export function isExcludedFromPlayBind(tag: string): boolean {
return shouldSkipBindTag(tag);
}
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层(壳与 design_only 已在收集阶段剔除) */
const STYLE_TAG_RE =
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
/叙事指南|故事推进|文风|禁忌|用户约束|用户需求|示例|模仿|回复呈现|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
@@ -102,6 +129,20 @@ function isStyleProductTag(tag: string): boolean {
return STYLE_TAG_RE.test(tag);
}
/** 只挂旁观的产物。主世界层与转述不读。 */
const AUDITOR_ONLY_TAG_RE = /画面与生图/;
function isAuditorOnlyProduct(product: DictateProduct): boolean {
if (AUDITOR_ONLY_TAG_RE.test(product.tag)) return true;
const mounts = parseContextFragment(product.content)?.mount ?? [];
if (!mounts.length) return false;
const set = new Set(mounts);
const onAuditor = set.has("auditor");
const onGm = set.has("world-simulator") || set.has("gm");
const onNarrator = set.has("narrator");
return onAuditor && !onGm && !onNarrator;
}
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
const stability = parseContextFragment(product.content)?.稳变;
return stability === "semi" || stability === "volatile";
@@ -178,7 +219,9 @@ function buildGmInserts(params: {
];
let o = 1;
const included = params.products.filter(
(p) => params.includeStyle || !isStyleProductTag(p.tag),
(p) =>
(params.includeStyle || !isStyleProductTag(p.tag)) &&
!isAuditorOnlyProduct(p),
);
const stable = included.filter((p) => !isFrequentlyChangingProduct(p));
const changing = included.filter(isFrequentlyChangingProduct);
@@ -243,7 +286,7 @@ function buildAuditorInserts(params: {
];
let o = 1;
for (const p of params.products) {
if (!/变量|生成规则/.test(p.tag)) continue;
if (!/变量|生成规则/.test(p.tag) && !isAuditorOnlyProduct(p)) continue;
inserts.push(insert(o++, p.tag, "full"));
}
for (const tag of params.runtimeTags) {
@@ -263,6 +306,7 @@ export function buildDictateContextOrder(params: {
runtimeTags?: string[];
}): ContextOrderDoc {
const runtimeTags = params.runtimeTags ?? [];
const products = params.products.filter((p) => !shouldSkipBindTag(p.tag));
const slots: ContextOrderSlot[] = [];
for (const slotId of enabledPlaySlotIds(params.playSlots)) {
@@ -272,7 +316,7 @@ export function buildDictateContextOrder(params: {
ref,
label: "主世界层",
inserts: buildGmInserts({
products: params.products,
products,
runtimeTags,
// 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片
includeStyle: true,
@@ -284,7 +328,7 @@ export function buildDictateContextOrder(params: {
slots.push({
ref,
label: "叙事转述",
inserts: buildNarratorInserts(params.products),
inserts: buildNarratorInserts(products),
});
continue;
}
@@ -293,7 +337,7 @@ export function buildDictateContextOrder(params: {
ref,
label: "旁观维护",
inserts: buildAuditorInserts({
products: params.products,
products,
runtimeTags,
}),
});
+8 -7
View File
@@ -23,12 +23,13 @@ export type DictatePromptTailOpts = {
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
export function buildDictateSelectPrompt(): string {
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
return `你只做选题。对照能力目录和用户这句话,只调用一个工具。
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
这一步不写产物正文,不回复用户。`;
这段话已经说清要落盘时,调用 select_nodes,nodes 里放目录中的 module_id,通常 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
若末尾 checklist 标了「失败待补」,且用户这句仍在推进落盘,这些节点必须再次纳入 nodes(可与新选的一起)。
用户只要讨论、核对,或明确先不要落盘时,调用 reply_user:直接回答,或写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。这种一轮不落盘,也不把失败待补写回去。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或世界法则。
select_nodes 不写产物正文。`;
}
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
@@ -116,8 +117,8 @@ ${legacyExample}`;
const phase =
opts?.phase === "fill"
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准)。失败待补若附了底稿,在底稿上只改失败点,已写对的段落、字段和已接受的 self_score 维度保持原样。写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
: "本步只调用一个工具。要落盘就调用 select_nodes,只填 nodes。只要讨论或先不要落盘,就调用 reply_user 直接回答或提问。";
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
return opts?.phase === "fill"
+38
View File
@@ -19,6 +19,10 @@ import {
getPreference,
listActivePreferences,
} from "../preference/store.js";
import {
getImageWorkflow,
listImageWorkflows,
} from "../image-workflow/store.js";
import {
getStylePack,
listActiveStylePacks,
@@ -26,6 +30,13 @@ import {
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
/** 改名后旧目录名仍能 read_module,已有对话不用重学 id。 */
const MODULE_READ_NAME_ALIASES: Record<string, readonly string[]> = {
"aesthetics-interaction": ["美学纲领与交互范式"],
"story-progression": ["故事推进"],
mechanism: ["实现机制"],
};
export type DictateModuleReadPayload = {
module_id: string;
name: string;
@@ -99,6 +110,9 @@ export async function buildDictateModuleReadIndex(params: {
};
index.set(module.id, payload);
index.set(module.name, payload);
for (const alias of MODULE_READ_NAME_ALIASES[module.id] ?? []) {
if (!index.has(alias)) index.set(alias, payload);
}
}
return index;
}
@@ -171,6 +185,19 @@ export function readLibraryEntries(params: {
})),
};
}
if (libraryId === "image-workflows") {
const entries = ids.map(getImageWorkflow);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `工作流库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
name: entry!.name,
summary: entry!.summary,
...(entry!.promptNotes ? { prompt_notes: entry!.promptNotes } : {}),
})),
};
}
return { error: `库 ${libraryId} 尚不支持按条目读取` };
}
@@ -205,6 +232,17 @@ function libraryDirectories(module: ModuleCatalogEntry): DictateModuleReadPayloa
})),
};
}
if (id === "image-workflows") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listImageWorkflows().map((entry) => ({
id: entry.id,
name: entry.name,
summary: entry.summary,
})),
};
}
return { library_id: id, label: provider?.label ?? id, entries: [] };
});
}
+43 -1
View File
@@ -1,6 +1,7 @@
/**
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
* 同轮补写用尽后,失败项写入「待补」状态,下轮 select/fill 继续补。
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
* draft / keptDims 只给下一枪做增量:底稿上改失败点,已接受的自评维度带回去。
*/
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
@@ -8,11 +9,23 @@ export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
export type DictateRemediateKind = "insert" | "delete";
/** 已经合法、可在下一枪原样带回的自评维度。 */
export type DictateKeptDim = {
name: string;
score: number;
gap?: string;
options?: string[];
};
export type DictateRemediateItem = {
tag: string;
moduleId?: string;
error: string;
kind: DictateRemediateKind;
/** 未通过时的正文底稿 */
draft?: string;
/** 这次没整份通过、但单项已经合法的自评维度 */
keptDims?: DictateKeptDim[];
};
export type DictateRemediateDoc = {
@@ -34,11 +47,15 @@ export function parseDictateRemediate(raw: string | undefined | null): DictateRe
const kind = rec.kind === "delete" ? "delete" : "insert";
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
const draft = String(rec.draft ?? "").trim();
const keptDims = parseKeptDims(rec.keptDims ?? rec.kept_dims);
parsed.push({
tag,
error,
kind,
...(moduleId ? { moduleId } : {}),
...(draft ? { draft } : {}),
...(keptDims.length ? { keptDims } : {}),
});
}
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
@@ -70,6 +87,31 @@ export function formatRemediateChecklist(items: readonly DictateRemediateItem[])
].join("\n");
}
function parseKeptDims(raw: unknown): DictateKeptDim[] {
if (!Array.isArray(raw)) return [];
const out: DictateKeptDim[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name = String(row.name ?? "").trim();
const score = Number(row.score);
if (!name || seen.has(name) || !Number.isInteger(score) || score < 1 || score > 5) continue;
const gap = String(row.gap ?? "").trim();
const options = Array.isArray(row.options)
? row.options.map((option) => String(option).trim()).filter(Boolean)
: [];
seen.add(name);
out.push({
name,
score,
...(gap ? { gap } : {}),
...(options.length ? { options } : {}),
});
}
return out;
}
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
const seen = new Set<string>();
const out: DictateRemediateItem[] = [];
+282 -47
View File
@@ -1,15 +1,17 @@
/**
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
* 再单次 fill 写入。已通过的不再覆盖。
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,再 fill 写入。
* 已通过的不再覆盖。未通过的可在同轮按设置补写若干次:底稿上只改失败点,已接受的自评维度带回。
* 补写次数用尽后,失败项记入「失败待补」,下轮继续。
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
* 选题与回复二选一:调用 reply_user 则本轮到此为止,不再填写,也不再补回复。
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
*/
import { randomUUID } from "node:crypto";
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
import type { AgentDriver } from "../runtime/driver.js";
import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { normalizeDictateFillRetries } from "../config/settings.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
import {
CREATION_PLAN_SCHEMA,
@@ -40,6 +42,7 @@ import {
formatRemediateChecklist,
parseDictateRemediate,
serializeDictateRemediate,
type DictateKeptDim,
type DictateRemediateItem,
} from "./remediate.js";
import { resolveModuleForInsertTag } from "./insert-feedback.js";
@@ -81,6 +84,11 @@ export type PlannedRoundParams = {
systemPrompt?: string;
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
historyTurns?: number;
/**
* 第一次填写失败后,同轮最多再补写几次。
* 缺省 0:只填一次,失败留给下一句。
*/
fillRetries?: number;
};
type PlanInsert = {
@@ -92,11 +100,12 @@ type PlanInsert = {
};
type SelectPlan = {
/** 本轮不落盘时的提问 */
clarify: string[];
/** 本轮不落盘时的回答 */
reply: string;
inserts: PlanInsert[];
deletes: string[];
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
keys: string[];
};
@@ -137,31 +146,31 @@ async function runPlannedDictateRound(
const selected = await params.llm.completeWithTools(selectCtx.messages, {
caller: "dictate_agent",
label: "对话落盘 选题",
tools: [DICTATE_SELECT_TOOL],
toolChoice: { type: "function", function: { name: "select_nodes" } },
tools: [DICTATE_SELECT_TOOL, DICTATE_SELECT_REPLY_TOOL],
toolChoice: "required",
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
usageSink: sinkFor(turnId, "select"),
});
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
mergeRemediationsIntoPlan(plan, remediations.items, params);
if (
plan.clarify.length >= 2 &&
plan.inserts.length === 0 &&
plan.deletes.length === 0
) {
return {
modelCalls: 1,
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
};
const replyCall = selected.toolCalls.find((call) => call.name === DICTATE_REPLY_TOOL.function.name);
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes");
const earlyReply = replyCall
? replyText({
id: replyCall.id || "reply",
name: replyCall.name,
arguments: replyCall.arguments,
})
: "";
if (earlyReply && !selectCall) {
return { modelCalls: 1, text: earlyReply };
}
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
const noWork = plan.inserts.length === 0 && plan.deletes.length === 0;
const speaking = noWork && (plan.clarify.length > 0 || plan.reply.length > 0);
if (!speaking && !earlyReply) mergeRemediationsIntoPlan(plan, remediations.items, params);
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
return {
modelCalls: 1,
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
};
const text = earlyReply || (speaking ? formatSpeak(plan) : "") || selected.content?.trim() || "这轮先不落盘。";
return { modelCalls: 1, text };
}
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
@@ -177,6 +186,13 @@ async function runPlannedDictateRound(
[...rest, ...openings].some((row) => row.tag === item.tag) ||
plan.deletes.includes(item.tag),
);
const drafts = new Map<string, string>();
const keptDims = new Map<string, DictateKeptDim[]>();
for (const item of priorFailures) {
if (item.kind === "delete") continue;
if (item.draft) drafts.set(item.tag, item.draft);
if (item.keptDims?.length) keptDims.set(item.tag, item.keptDims);
}
const tail = formatDictateRoundTail({
...tailOpts,
phase: "fill",
@@ -189,6 +205,7 @@ async function runPlannedDictateRound(
moduleId: item.moduleId,
kind: item.kind,
})),
drafts,
),
});
const ctx = buildDictateContext({
@@ -207,35 +224,64 @@ async function runPlannedDictateRound(
let replyFromTool = "";
let lastProse = "";
let modelCalls = 1;
const fillRetries = normalizeDictateFillRetries(params.fillRetries);
let messages: ChatMessage[] = ctx.messages;
let fillAttempts = 0;
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
if (pending.size > 0 || pendingDeletes.size > 0) {
const filled = await params.llm.completeWithTools(ctx.messages, {
while (pending.size > 0 || pendingDeletes.size > 0) {
if (fillAttempts > fillRetries) break;
const filled = await params.llm.completeWithTools(messages, {
caller: "dictate_agent",
label: "对话落盘 填写",
label: fillAttempts === 0 ? "对话落盘 填写" : `对话落盘 补写 ${fillAttempts}`,
tools: DICTATE_WRITE_LOOP_TOOLS,
toolChoice: "required",
usageDetail: {
...detail(turnId, "fill", 0, ctx.segmentChars),
...detail(
turnId,
fillAttempts === 0 ? "fill" : "fill-retry",
fillAttempts,
ctx.segmentChars,
),
plannedTags: [...pending, ...pendingDeletes],
},
usageSink: sinkFor(turnId, "fill"),
usageSink: sinkFor(turnId, fillAttempts === 0 ? "fill" : "fill-retry"),
});
fillAttempts += 1;
modelCalls += 1;
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const prose = filled.content?.trim() ?? "";
if (prose) lastProse = prose;
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
id: call.id || `call-0-${index}`,
id: call.id || `call-${fillAttempts}-${index}`,
name: call.name,
arguments: call.arguments,
}));
if (calls.length > 0) {
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
const settled =
calls.length > 0
? await settleWriteStep(
params,
apply,
calls,
passed,
pending,
pendingDeletes,
errors,
drafts,
keptDims,
)
: { results: [], reply: "" };
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
break;
}
}
if (fillAttempts > fillRetries) break;
messages = appendFillContinuation(
messages,
filled.content,
calls,
settled.results,
incrementalRetryNote(passed, pending, pendingDeletes, errors),
);
}
const moduleByTag = new Map(
@@ -247,11 +293,13 @@ async function runPlannedDictateRound(
errors,
moduleByTag,
prior: remediations.items,
drafts,
keptDims,
});
let reply = replyFromTool;
let reply = replyFromTool || earlyReply;
if (
!replyFromTool &&
!reply &&
pending.size === 0 &&
pendingDeletes.size === 0 &&
passed.size > 0
@@ -266,10 +314,11 @@ async function runPlannedDictateRound(
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
);
const pendingLines = [...blockedLines, ...failed];
const retryNote = fillAttempts > 1 ? `已同轮补写 ${fillAttempts - 1} 次。` : "";
const text = pendingLines.length
? [
reply,
`失败待补(下轮继续):${pendingLines.join("、")}。`,
`失败待补(下轮继续):${pendingLines.join("、")}。${retryNote}`,
]
.filter(Boolean)
.join("\n")
@@ -277,6 +326,15 @@ async function runPlannedDictateRound(
return { text, modelCalls };
}
function formatSpeak(plan: SelectPlan): string {
const questions =
plan.clarify.length <= 1
? plan.clarify[0] ?? ""
: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n");
if (questions && plan.reply && questions !== plan.reply) return `${questions}\n\n${plan.reply}`;
return questions || plan.reply;
}
async function runLeanReply(
params: PlannedRoundParams,
turnId: string,
@@ -359,6 +417,8 @@ async function settleWriteStep(
pending: Set<string>,
pendingDeletes: Set<string>,
errors: Map<string, string>,
drafts: Map<string, string>,
keptDims: Map<string, DictateKeptDim[]>,
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
const results: Array<{ callId: string; content: string }> = [];
const forward: DriverToolCall[] = [];
@@ -382,7 +442,12 @@ async function settleWriteStep(
}
if (call.name === "insert" && tag) {
const args = asRecord(safeJson(call.arguments));
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
const merged = mergeKeptDimsIntoArgs(
args,
keptDims.get(tag),
scoreDimensionsForTag(params, tag),
);
forward.push({ ...call, arguments: JSON.stringify({ ...merged, position: tag }) });
continue;
}
forward.push(call);
@@ -409,7 +474,11 @@ async function settleWriteStep(
const planned = [...pending, ...pendingDeletes].find(
(item) => item === tag || tag.startsWith(`${item}#`),
);
errors.set(planned ?? tag, payload.error);
const plannedTag = planned ?? tag;
errors.set(plannedTag, payload.error);
if (call?.name === "insert") {
rememberFailedInsert(call, plannedTag, params, drafts, keptDims);
}
}
}
}
@@ -444,6 +513,137 @@ function replyText(call: DriverToolCall): string {
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
}
const MAX_FAILED_DRAFT_CHARS = 24_000;
function rememberFailedInsert(
call: DriverToolCall,
tag: string,
params: PlannedRoundParams,
drafts: Map<string, string>,
keptDims: Map<string, DictateKeptDim[]>,
): void {
const args = asRecord(safeJson(call.arguments));
const content = String(args.content ?? "").trim();
if (content) {
drafts.set(
tag,
content.length > MAX_FAILED_DRAFT_CHARS
? `${content.slice(0, MAX_FAILED_DRAFT_CHARS)}\n…(底稿已截断)`
: content,
);
}
const expected = scoreDimensionsForTag(params, tag);
const harvested = harvestKeptDims(args.self_score, expected);
if (!harvested.length) return;
const merged = new Map((keptDims.get(tag) ?? []).map((dim) => [dim.name, dim] as const));
for (const dim of harvested) merged.set(dim.name, dim);
keptDims.set(tag, [...merged.values()]);
}
function scoreDimensionsForTag(params: PlannedRoundParams, tag: string): string[] {
const module = resolveModuleForInsertTag(tag, params.moduleCatalog);
if (!module) return [];
return params.moduleReadIndex?.get(module.id)?.score_dimensions ?? [];
}
function mergeKeptDimsIntoArgs(
args: Record<string, unknown>,
kept: readonly DictateKeptDim[] | undefined,
expected: readonly string[],
): Record<string, unknown> {
if (!kept?.length || !expected.length) return args;
const score = asRecord(args.self_score);
const dims = Array.isArray(score.dims) ? [...score.dims] : [];
const names = new Set(
dims.map((item) => String(asRecord(item).name ?? "").trim()).filter(Boolean),
);
const additions = kept.filter((dim) => expected.includes(dim.name) && !names.has(dim.name));
if (!additions.length) return args;
return { ...args, self_score: { ...score, dims: [...dims, ...additions] } };
}
function harvestKeptDims(raw: unknown, expected: readonly string[]): DictateKeptDim[] {
if (!expected.length) return [];
const dims = Array.isArray(asRecord(raw).dims) ? asRecord(raw).dims : [];
const out: DictateKeptDim[] = [];
const seen = new Set<string>();
for (const item of dims) {
const row = asRecord(item);
const name = String(row.name ?? "").trim();
const score = Number(row.score);
if (!name || !expected.includes(name) || seen.has(name)) continue;
if (!Number.isInteger(score) || score < 1 || score > 5) continue;
const gap = String(row.gap ?? "").trim();
if (score <= 3 && !gap) continue;
const options = Array.isArray(row.options)
? row.options.map((option) => String(option).trim()).filter(Boolean)
: [];
if (options.length > 0 && (options.length < 2 || options.length > 3)) continue;
seen.add(name);
out.push({
name,
score,
...(gap ? { gap } : {}),
...(options.length ? { options } : {}),
});
}
return out;
}
function appendFillContinuation(
messages: ChatMessage[],
assistantContent: string | null,
calls: DriverToolCall[],
results: Array<{ callId: string; content: string }>,
note: string,
): ChatMessage[] {
const next = [...messages];
if (calls.length === 0) {
next.push({ role: "user", content: note });
return next;
}
next.push({
role: "assistant",
content: assistantContent,
tool_calls: calls.map((call) => ({
id: call.id,
type: "function" as const,
function: { name: call.name, arguments: call.arguments },
})),
});
const byId = new Map(results.map((item) => [item.callId, item.content]));
for (const call of calls) {
next.push({
role: "tool",
tool_call_id: call.id,
content: byId.get(call.id) ?? JSON.stringify({ error: "无结果" }),
});
}
next.push({ role: "user", content: note });
return next;
}
function incrementalRetryNote(
passed: ReadonlySet<string>,
pending: ReadonlySet<string>,
pendingDeletes: ReadonlySet<string>,
errors: ReadonlyMap<string, string>,
): string {
const kept = [...passed].filter((tag) => !tag.startsWith("del:"));
const deleted = [...passed].filter((tag) => tag.startsWith("del:")).map((tag) => tag.slice(4));
return [
"同轮补写:只改未通过项。",
kept.length ? `已通过,不要再 insert:${kept.join("、")}` : "",
deleted.length ? `已删除,不要再 delete:${deleted.join("、")}` : "",
"未通过:",
...[...pending].map((tag) => `- 写入 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
...[...pendingDeletes].map((tag) => `- 删除 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
"未通过的正文以上一次 insert 为底稿,只改失败点。已写对的段落、字段,以及已接受的 self_score 维度,保持原样。不要整份重写。",
]
.filter(Boolean)
.join("\n");
}
function sinkFor(_turnId: string, _step: string): UsageSink {
return {};
}
@@ -483,7 +683,13 @@ function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
const row = asRecord(raw);
const clarify = stringList(row.clarify);
const clarify = [
...askList(row.ask),
...askList(row.askUser),
...askList(row.ask_user),
...stringList(row.clarify),
].filter((item, index, all) => all.indexOf(item) === index);
const reply = String(row.reply ?? "").trim();
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
const inserts: PlanInsert[] = [];
let items = Array.isArray(row.nodes)
@@ -491,7 +697,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
: Array.isArray(row.inserts)
? row.inserts
: [];
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
if (items.length === 0 && deletes.length === 0) {
const recovered = recoverSelectWork(row);
items = recovered.items;
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
@@ -549,7 +755,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
reason: reason || undefined,
});
}
return { clarify, inserts, deletes, keys: Object.keys(row) };
return { clarify, reply, inserts, deletes };
}
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
@@ -699,7 +905,12 @@ function canonicalProductTag(
}
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
function formatChecklist(
group: PlanInsert[],
deletes: string[],
errors: ItemFailure[],
drafts?: ReadonlyMap<string, string>,
): string {
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
const failedTags = new Set(errors.map((item) => item.tag));
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
@@ -709,9 +920,15 @@ function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFai
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
errors.length
? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
? `失败待补:\n${errors
.map((item) => {
const draft = item.kind === "delete" ? "" : drafts?.get(item.tag)?.trim() ?? "";
if (!draft) return `- ${item.tag}:${item.error}`;
return `- ${item.tag}:${item.error}\n 底稿(只改失败点,已写对的段落和字段保持原样):\n${draft}`;
})
.join("\n")}`
: "",
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准。有底稿时只改失败点,不要整份重写。",
];
return lines.filter(Boolean).join("\n");
}
@@ -753,6 +970,8 @@ function persistRemediations(
errors: Map<string, string>;
moduleByTag: Map<string, string>;
prior: readonly DictateRemediateItem[];
drafts: ReadonlyMap<string, string>;
keptDims: ReadonlyMap<string, DictateKeptDim[]>;
},
): void {
const priorByKey = new Map(
@@ -761,6 +980,8 @@ function persistRemediations(
const next: DictateRemediateItem[] = [];
for (const tag of state.pending) {
const prev = priorByKey.get(`insert:${tag}`);
const draft = state.drafts.get(tag) ?? prev?.draft;
const kept = state.keptDims.get(tag) ?? prev?.keptDims;
next.push({
tag,
kind: "insert",
@@ -768,6 +989,8 @@ function persistRemediations(
...(state.moduleByTag.get(tag) || prev?.moduleId
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
: {}),
...(draft ? { draft } : {}),
...(kept?.length ? { keptDims: kept } : {}),
});
}
for (const tag of state.pendingDeletes) {
@@ -823,6 +1046,18 @@ function asRecord(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
function askList(value: unknown): string[] {
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (typeof item === "string") return item.trim();
const rec = asRecord(item);
return String(rec.prompt ?? rec.text ?? rec.question ?? "").trim();
})
.filter(Boolean);
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
+23 -2
View File
@@ -3,13 +3,13 @@ import type { ToolDefinition } from "../llm/client.js";
/**
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
/** 第一次请求与 reply_user 二选一:点出下一步要写的节点。 */
export const DICTATE_SELECT_TOOL: ToolDefinition = {
type: "function",
function: {
name: "select_nodes",
description:
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。本轮只回答或只提问时不要调用本工具,改调 reply_user。",
parameters: {
type: "object",
additionalProperties: false,
@@ -32,6 +32,27 @@ export const DICTATE_SELECT_TOOL: ToolDefinition = {
},
};
/** 选题步的回复。与 select_nodes 二选一;调用后不再填写、不再另起回复。 */
export const DICTATE_SELECT_REPLY_TOOL: ToolDefinition = {
type: "function",
function: {
name: "reply_user",
description:
"本轮不写入、不删除。直接回答,或提出 1~2 个问题,每个问题给 2~3 个可接着说的选项。与 select_nodes 二选一。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
text: {
type: "string",
description: "给用户的回复。提问时写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。",
},
},
required: ["text"],
},
},
};
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
export const DICTATE_REPLY_TOOL: ToolDefinition = {
type: "function",
+14 -1
View File
@@ -173,6 +173,8 @@ export async function runDictateTurn(params: {
repeatableFamilies?: ReadonlySet<string> | readonly string[];
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
historyTurns?: number;
/** 第一次填写失败后同轮最多再补写几次;缺省 0 */
fillRetries?: number;
driver?: AgentDriver;
}): Promise<DictateTurnResult> {
const driver =
@@ -198,6 +200,7 @@ export async function runDictateTurn(params: {
recipeBrief: params.recipeBrief,
systemPrompt: params.systemPrompt,
historyTurns: params.historyTurns,
fillRetries: params.fillRetries,
});
const wroteTags: string[] = [];
const deletedTags: string[] = [];
@@ -1074,8 +1077,18 @@ function parseAndValidateSelfScore(
expectedNames.some((name) => !actual.includes(name)) ||
actual.some((name) => !expectedNames.includes(name))
) {
const kept = actual.filter((name) => expectedNames.includes(name));
const missing = expectedNames.filter((name) => !actual.includes(name));
const extra = actual.filter((name) => !expectedNames.includes(name));
return {
error: `self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
error: [
`self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
kept.length ? `已接受、请原样保留:${kept.join("、")}` : "",
missing.length ? `缺少:${missing.join("、")}` : "",
extra.length ? `多余:${extra.join("、")}` : "",
]
.filter(Boolean)
.join(";"),
};
}
return { value: { dims: parsed } };
+16 -6
View File
@@ -58,6 +58,7 @@ import {
import {
loadAppSettings,
normalizeContextTraceKeepLatest,
normalizeDictateFillRetries,
normalizePreferenceCollectEveryTurns,
} from "../config/settings.js";
import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js";
@@ -139,6 +140,7 @@ import {
formatRecipeExamplesForCreationPlan,
formatModuleCatalogForDictate,
findModuleByName,
readModuleArtifactContent,
findStepByRef,
type FlowStepTitleHint,
isProgressPointerTag,
@@ -597,10 +599,13 @@ export class SessionManager {
const s = this.require(sessionId);
const trimmedTag = tag.trim();
if (!trimmedTag) throw new Error("tag 不能为空");
s.runtime.getBlackboard().write({
const board = s.runtime.getBlackboard();
const existing = board.getLatestByTag(trimmedTag);
board.write({
tag: trimmedTag,
content: content ?? "",
source: "user",
...(existing?.metadata ? { metadata: existing.metadata } : {}),
});
if (s.bookId) this.persist(s);
return this.toView(sessionId);
@@ -1145,6 +1150,7 @@ export class SessionManager {
moduleCatalog,
moduleReadIndex,
repeatableFamilies,
fillRetries: normalizeDictateFillRetries(loadAppSettings().dictateFillRetries),
handlers: {
listProducts: () => this.listDictateProducts(s),
writeProduct: (tag, content, order, metadata) => {
@@ -3123,11 +3129,15 @@ export class SessionManager {
const step = findStepByRef(cleaned, stepId.trim());
if (!step) throw new Error(`找不到节点:${stepId}`);
const mod = findModuleByName(catalog, step.name);
const tag = mod?.artifact?.trim() || null;
const content = tag
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
: "";
return { stepId: step.id, name: step.name, tag, content };
const stored = readModuleArtifactContent(mod, (tag) =>
s.runtime.getBlackboard().getContentByTag(tag),
);
return {
stepId: step.id,
name: step.name,
tag: stored?.tag ?? null,
content: stored?.content ?? "",
};
}
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
+5
View File
@@ -19,6 +19,7 @@ import {
ensureActiveProfileDefault,
loadAppSettings,
normalizeContextTraceKeepLatest,
normalizeDictateFillRetries,
normalizePreferenceCollectEveryTurns,
saveAppSettings,
setActivePresetId,
@@ -138,6 +139,7 @@ export async function handleSettingsApi(
activePresetId?: string | null;
contextTraceKeepLatest?: number;
preferenceCollectEveryTurns?: number;
dictateFillRetries?: number;
};
const settings = loadAppSettings();
if (body.activeProfileId !== undefined) {
@@ -156,6 +158,9 @@ export async function handleSettingsApi(
body.preferenceCollectEveryTurns,
);
}
if (body.dictateFillRetries !== undefined) {
settings.dictateFillRetries = normalizeDictateFillRetries(body.dictateFillRetries);
}
saveAppSettings(settings);
json(res, 200, { settings });
return true;
+2 -2
View File
@@ -46,7 +46,7 @@ async function fixture() {
describe("creation plan", () => {
it("covers all current content modules and excludes approved mechanical or legacy nodes", async () => {
const { modules } = await fixture();
expect(modules).toHaveLength(14);
expect(modules).toHaveLength(15);
expect(modules.map((module) => module.id)).not.toEqual(
expect.arrayContaining([
"creation-plan",
@@ -99,7 +99,7 @@ describe("creation plan", () => {
expect(catalog).toBeTruthy();
const zeroModules = creationPlanModules(catalog!, "zero_layer");
const ids = zeroModules.map((module) => module.id);
expect(zeroModules).toHaveLength(14);
expect(zeroModules).toHaveLength(15);
expect(ids).toEqual(
expect.arrayContaining([
"zero-layer-status",
+76 -9
View File
@@ -14,22 +14,58 @@ 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(true);
expect(isDictateStyleBindTag("设计.模仿要点")).toBe(true);
expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true);
expect(isDictateStyleBindTag("设计.故事推进")).toBe(true);
expect(isDictateStyleBindTag("设计.用户需求")).toBe(true);
expect(isDictateStyleBindTag("设计.用户约束")).toBe(true);
expect(isDictateStyleBindTag("设计.监控栏")).toBe(true);
expect(isDictateStyleBindTag("用户.需求")).toBe(false);
// 中转与壳不进游玩文风挂载
expect(isDictateStyleBindTag("设计.美学纲领")).toBe(false);
expect(isDictateStyleBindTag("设计.正文组成")).toBe(false);
});
it("excludes design_only and reply shell from play bind products", () => {
const board = new Blackboard();
board.write({
tag: "设计.实现机制",
content: "因果",
source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: 10 },
});
board.write({
tag: "设计.本局创作方案",
content: '{"schema":"creation-plan.v1","items":[]}',
source: "dictate",
});
board.write({
tag: "设计.美学纲领与交互范式",
content: "体验",
source: "dictate",
});
board.write({
tag: "设计.正文组成",
content: JSON.stringify({
schema: "context-fragment.v1",
正文: {
frontend: { html: "<div>", css: "x", js: "y" },
数据契约: { blocks: { body: { 类型: "markdown", 来源: "model_generated" } } },
},
}),
source: "dictate",
});
const tags = collectDictateBindProducts(board).map((p) => p.tag);
expect(tags).toEqual(["设计.实现机制"]);
});
it("builds gm context_order: persona → products → history → input", () => {
const order = buildDictateContextOrder({
products: [
{ tag: "用户.需求", content: "雨夜", order: -40 },
{ tag: "设计.美学纲领", content: "冷色", order: -30 },
{ tag: "设计.叙事指南", content: "冷色短句", order: -30 },
{ tag: "设计.本局创作方案", content: "{}", order: -50 },
{ tag: "设计.正文组成", content: "{}", order: 0 },
],
playSlots: defaultPlaySlots(),
@@ -39,13 +75,44 @@ describe("dictate play-bind", () => {
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("设计.叙事指南");
expect(refs).not.toContain("设计.本局创作方案");
expect(refs).not.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);
expect(refs.indexOf("设计.叙事指南")).toBeLessThan(histIdx);
});
it("keeps image prompt on the auditor and off the main world", () => {
const image = JSON.stringify({
schema: "context-fragment.v1",
技能: "画面与生图",
brief: "西幻",
mount: ["auditor"],
稳变: "stable",
正文: { 工作流: "western-fantasy" },
});
const order = buildDictateContextOrder({
products: [
{ tag: "设计.舞台骨架", content: "舞台", order: 1 },
{ tag: "设计.画面与生图", content: image, order: 2 },
{ tag: "设计.变量设计与更新规则", content: "真值", order: 3 },
],
playSlots: { ...defaultPlaySlots(), auditor: true },
});
const gm = order.slots
.find((s) => s.ref === "world-simulator")!
.inserts.map((i) => i.ref);
const auditor = order.slots
.find((s) => s.ref === "auditor")!
.inserts.map((i) => i.ref);
expect(gm).toContain("设计.舞台骨架");
expect(gm).not.toContain("设计.画面与生图");
expect(auditor).toContain("设计.画面与生图");
expect(auditor).toContain("设计.变量设计与更新规则");
expect(auditor).not.toContain("设计.舞台骨架");
});
it("groups context by change frequency before applying importance order", () => {
@@ -84,7 +151,7 @@ describe("dictate play-bind", () => {
metadata: { [DICTATE_ORDER_META_KEY]: -40 },
});
board.write({
tag: "设计.美学纲领",
tag: "设计.叙事指南",
content: "冷色短句",
source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: -30 },
@@ -103,14 +170,14 @@ describe("dictate play-bind", () => {
});
const products = collectDictateBindProducts(board);
expect(products.map((p) => p.tag)).toEqual(["用户.需求", "设计.美学纲领"]);
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).toContain("设计.叙事指南");
expect(narrRefs).not.toContain("用户.需求");
expect(narrRefs).toContain("输出.用户展示");
expect(narrRefs).not.toContain("运行.本轮.裁决");
+4 -4
View File
@@ -33,7 +33,7 @@ describe("dictate read_module", () => {
self_score: {
dims: [
{ name: "美学纲领", score: 5 },
{ name: "交互范式", score: 5 },
{ name: "体验锚定", score: 5 },
{ name: "背景与规则", score: 5 },
],
},
@@ -84,7 +84,7 @@ describe("dictate read_module", () => {
self_score: {
dims: [
{ name: "美学纲领", score: 5 },
{ name: "交互范式", score: 5 },
{ name: "体验锚定", score: 5 },
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
],
},
@@ -208,7 +208,7 @@ describe("dictate read_module", () => {
self_score: {
dims: [
{ name: "美学纲领", score: 3 },
{ name: "交互范式", score: 5 },
{ name: "体验锚定", score: 5 },
{ name: "背景与规则", score: 5 },
],
},
@@ -230,7 +230,7 @@ describe("dictate read_module", () => {
self_score: {
dims: [
{ name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
{ name: "交互范式", score: 5 },
{ name: "体验锚定", score: 5 },
{ name: "背景与规则", score: 5 },
],
},
+309 -5
View File
@@ -253,6 +253,7 @@ describe("dictate planned round", () => {
expect(result.wroteTags).toEqual(["设计.氛围"]);
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]);
expect(result.reply).toContain("已写成细雨");
expect(result.iterations).toBe(2);
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
expect(state.modules.mood).toBe("v-test");
expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe("");
@@ -287,10 +288,75 @@ describe("dictate planned round", () => {
expect(result.reply).toContain("改成暖色");
});
it("says the returned fields when the select plan writes nothing", async () => {
it("returns an answer without writing when the first call is reply_user", async () => {
let writes = 0;
let calls = 0;
const result = await runDictateTurn({
llm: scriptedLlm([{ note: "看世界" }]),
dialogue: [{ role: "user", text: "超级英雄" }],
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools() {
calls += 1;
return {
content: null,
toolCalls: [
{
id: "reply",
name: "reply_user",
arguments: JSON.stringify({ text: "下风位最适合先改顾晚棠。" }),
},
],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "先不要落盘,告诉我该改谁" }],
handlers: {
listProducts: () => [],
writeProduct: () => {
writes += 1;
},
deleteProduct: () => false,
readTag: () => undefined,
writeTag: () => undefined,
deleteTag: () => false,
clearDialogue: () => undefined,
},
});
expect(writes).toBe(0);
expect(calls).toBe(1);
expect(result.wroteTags).toEqual([]);
expect(result.iterations).toBe(1);
expect(result.reply).toContain("顾晚棠");
expect(result.reply).not.toContain("没有写入产物");
});
it("returns a question without writing when the first call is reply_user", async () => {
let calls = 0;
const result = await runDictateTurn({
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools() {
calls += 1;
return {
content: null,
toolCalls: [
{
id: "reply",
name: "reply_user",
arguments: JSON.stringify({
text: "先改顾晚棠,还是先改表世界的同辈?",
}),
},
],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "先讨论,不要落盘" }],
handlers: {
listProducts: () => [],
writeProduct: () => undefined,
@@ -301,10 +367,10 @@ describe("dictate planned round", () => {
clearDialogue: () => undefined,
},
});
expect(calls).toBe(1);
expect(result.wroteTags).toEqual([]);
expect(result.iterations).toBe(1);
expect(result.reply).toContain("note");
expect(result.reply).toContain("没有写入产物");
expect(result.reply).toContain("顾晚棠");
});
it("follows a select result with a fill request that carries specs and products", async () => {
@@ -973,4 +1039,242 @@ describe("dictate planned round", () => {
expect(replyReq).not.toContain("#### task");
expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } });
});
it("retries a failed fill in the same turn and patches from the previous draft", async () => {
const products: Array<{ tag: string; content: string }> = [];
const tags = new Map<string, string>();
const seen: string[] = [];
const rounds = [
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
[
{
id: "a",
name: "insert",
arguments: JSON.stringify({
position: "设计.氛围",
content: "细雨连绵,石板发亮",
self_score: { dims: [{ name: "短句", score: 4 }] },
}),
},
],
[
{
id: "b",
name: "insert",
arguments: JSON.stringify({
position: "设计.氛围",
content: "细雨连绵,石板发亮",
self_score: {
dims: [
{ name: "短句", score: 4 },
{ name: "画面", score: 5 },
],
},
}),
},
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "氛围补上了" }) },
],
];
let index = 0;
const result = await runDictateTurn({
fillRetries: 1,
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools(messages) {
seen.push(JSON.stringify(messages));
return {
content: null,
toolCalls: rounds[index++] ?? [],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "写成细雨" }],
moduleCatalog: catalog,
moduleReadIndex: new Map([
["mood", { ...modulePayload, score_dimensions: ["短句", "画面"] }],
]),
handlers: {
listProducts: () => products,
writeProduct: (tag, content) => {
products.push({ tag, content });
},
deleteProduct: () => false,
readTag: (tag) => tags.get(tag),
writeTag: (tag, content) => {
tags.set(tag, content);
},
deleteTag: (tag) => tags.delete(tag),
clearDialogue: () => undefined,
},
});
expect(seen).toHaveLength(3);
expect(seen[2]).toContain("同轮补写");
expect(seen[2]).toContain("细雨连绵,石板发亮");
expect(seen[2]).toContain("只改失败点");
expect(seen[2]).toContain("已接受、请原样保留:短句");
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨连绵,石板发亮" }]);
expect(result.reply).toContain("氛围补上了");
expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false);
});
it("keeps accepted self_score dims when a later attempt drops them", async () => {
const scores: unknown[] = [];
const rounds = [
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
[
{
id: "a",
name: "insert",
arguments: JSON.stringify({
position: "设计.氛围",
content: "细雨",
self_score: { dims: [{ name: "短句", score: 4 }] },
}),
},
],
[
{
id: "b",
name: "insert",
arguments: JSON.stringify({
position: "设计.氛围",
content: "细雨",
self_score: { dims: [{ name: "画面", score: 5 }] },
}),
},
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) },
],
];
let index = 0;
const tags = new Map<string, string>();
await runDictateTurn({
fillRetries: 1,
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools() {
return {
content: null,
toolCalls: rounds[index++] ?? [],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "写成细雨" }],
moduleCatalog: catalog,
moduleReadIndex: new Map([
["mood", { ...modulePayload, score_dimensions: ["短句", "画面"] }],
]),
handlers: {
listProducts: () => [],
writeProduct: (_tag, _content, _order, metadata) => {
scores.push(metadata?.selfScore);
},
deleteProduct: () => false,
readTag: (tag) => tags.get(tag),
writeTag: (tag, content) => {
tags.set(tag, content);
},
deleteTag: () => false,
clearDialogue: () => undefined,
},
});
expect(scores).toHaveLength(1);
const dims = (scores[0] as { dims: Array<{ name: string }> }).dims.map((dim) => dim.name).sort();
expect(dims).toEqual(["画面", "短句"]);
});
it("stores the failed draft and feeds it to the next turn", async () => {
const tags = new Map<string, string>();
const seen: string[] = [];
const first = [
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
[
{
id: "a",
name: "insert",
arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵" }),
},
],
];
let index = 0;
const handlers = {
listProducts: () => [],
writeProduct: () => undefined,
deleteProduct: () => false,
readTag: (tag: string) => tags.get(tag),
writeTag: (tag: string, content: string) => {
tags.set(tag, content);
},
deleteTag: () => false,
clearDialogue: () => undefined,
};
const failed = await runDictateTurn({
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools() {
return {
content: null,
toolCalls: first[index++] ?? [],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "写成细雨" }],
moduleCatalog: catalog,
moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]),
handlers,
});
expect(failed.reply).toContain("失败待补(下轮继续)");
expect(failed.reply).not.toContain("已同轮补写");
const stored = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
expect(stored.items[0]?.draft).toBe("细雨连绵");
const second = [
[{ id: "select2", name: "select_nodes", arguments: JSON.stringify({ nodes: [] }) }],
[
{
id: "b",
name: "insert",
arguments: JSON.stringify({
position: "设计.氛围",
content: "细雨连绵",
self_score: { dims: [{ name: "短句", score: 4 }] },
}),
},
{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) },
],
];
index = 0;
await runDictateTurn({
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools(messages) {
seen.push(messages.map((message) => message.content ?? "").join("\n"));
return {
content: null,
toolCalls: second[index++] ?? [],
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "继续" }],
moduleCatalog: catalog,
moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]),
handlers,
});
expect(seen[1]).toContain("底稿");
expect(seen[1]).toContain("细雨连绵");
expect(seen[1]).toContain("只改失败点");
});
});
+13 -9
View File
@@ -124,8 +124,8 @@ describe("dictate context", () => {
expect(catalog).toBeTruthy();
const block = formatModuleCatalogForDictate(catalog!);
expect(block).toContain("【能力 · 何时落盘】");
expect(block).toContain("美学纲领与交互范式");
expect(block).toContain("落盘:insert 「设计.美学纲领与交互范式」");
expect(block).toContain("美学纲领:");
expect(block).toContain("落盘:insert 「设计.美学纲领」");
expect(block).toContain("何时用");
expect(block).toContain("何时不用");
expect(block).toContain("declare_variable");
@@ -136,7 +136,7 @@ describe("dictate context", () => {
expect(block).toContain("可增殖");
expect(block).not.toContain("role=prototype");
expect(block).toContain("落盘:insert 「设计.叙事指南」");
expect(block).toContain("落盘:insert 「设计.故事推进」");
expect(block).toContain("落盘:insert 「设计.故事推进与交互范式」");
expect(block).not.toMatch(/^- 叙事指南与故事推进/m);
});
@@ -180,11 +180,12 @@ describe("dictate context", () => {
it("resolves insert tag to module and builds user-facing brief", async () => {
const catalog = await loadModuleCatalog("dialogue/world-simulator");
expect(catalog).toBeTruthy();
const mod = resolveModuleForInsertTag(
"设计.美学纲领与交互范式",
catalog!,
expect(resolveModuleForInsertTag("设计.美学纲领", catalog!)?.id).toBe(
"aesthetics-interaction",
);
expect(mod?.id).toBe("aesthetics-interaction");
expect(
resolveModuleForInsertTag("设计.美学纲领与交互范式", catalog!)?.id,
).toBe("aesthetics-interaction");
expect(
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
).toBe("opening-setup");
@@ -203,6 +204,9 @@ describe("dictate context", () => {
expect(resolveModuleForInsertTag("设计.叙事指南", catalog!)?.id).toBe(
"narrative-guide",
);
expect(
resolveModuleForInsertTag("设计.故事推进与交互范式", catalog!)?.id,
).toBe("story-progression");
expect(resolveModuleForInsertTag("设计.故事推进", catalog!)?.id).toBe(
"story-progression",
);
@@ -214,9 +218,9 @@ describe("dictate context", () => {
catalog: catalog!,
skillPackRoot: "dialogue/world-simulator",
});
const brief = index.get("设计.美学纲领与交互范式");
const brief = index.get("设计.美学纲领");
expect(brief).toBeTruthy();
expect(brief!).toContain("# 本轮关注 · 美学纲领与交互范式");
expect(brief!).toContain("# 本轮关注 · 美学纲领");
expect(brief!).toContain("不要求固定标题");
expect(brief!).toContain("本模块内容包括");
expect(brief!).toContain("## 写入用户回复时做什么");
+4
View File
@@ -1405,6 +1405,10 @@ code {
background: var(--st-surface);
}
.settings-section .storage-card + .storage-card {
margin-top: 16px;
}
.storage-card h3 {
margin: 0 0 8px;
font-size: 15px;
+22
View File
@@ -18,6 +18,7 @@
<a class="st-brand" href="/">Writing Agent</a>
<nav class="st-nav" aria-label="主导航">
<a href="/">对话</a>
<a href="/chat.html">直聊</a>
<a href="/modules.html">技能</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
@@ -133,6 +134,27 @@
<button type="button" class="btn-secondary danger-outline" id="btn-clear-context">清除全部痕迹</button>
</div>
</div>
<div class="settings-card storage-card">
<h3>对话落盘补写</h3>
<p class="settings-help">
同一句用户消息里,填写没通过时可以再自动补几枪。每一枪只改失败点:已经通过的产物不动,没通过的用上一份正文当底稿,已经填对的自评维度会带回去。次数用尽后仍标成失败待补,等下一句继续。
</p>
<label class="field">
同轮最多补写次数
<input
type="number"
id="dictate-fill-retries"
min="0"
max="12"
step="1"
value="0"
/>
</label>
<p class="settings-help muted">0 = 只填一次。最大 12。例如设为 7,就是第一次填写之外最多再试 7 次。</p>
<div class="storage-actions">
<button type="button" class="btn-primary" id="btn-save-fill-retries">保存次数</button>
</div>
</div>
</section>
</div>
</main>
+23 -1
View File
@@ -62,6 +62,7 @@ const preferenceCollectEveryEl = document.getElementById("preference-collect-eve
const activeSettingsBarEl = document.getElementById("active-settings-bar");
const settingsToastEl = document.getElementById("settings-toast");
const contextTraceKeepEl = document.getElementById("context-trace-keep");
const dictateFillRetriesEl = document.getElementById("dictate-fill-retries");
const probeDialog = document.getElementById("preset-probe-dialog");
const probePresetNameEl = document.getElementById("probe-preset-name");
const probeMessageEl = document.getElementById("probe-message");
@@ -193,7 +194,7 @@ function renderPanelHeader() {
panelActionsEl.appendChild(btn);
} else {
panelTitleEl.textContent = "上下文";
panelSubtitleEl.textContent = "控制每个对话保留多少条完整 prompt 痕迹";
panelSubtitleEl.textContent = "上下文痕迹,以及对话落盘同轮补写次数";
}
}
@@ -776,6 +777,9 @@ async function loadAll() {
state.settings.contextTraceKeepLatest ?? 5,
);
}
if (dictateFillRetriesEl) {
dictateFillRetriesEl.value = String(state.settings.dictateFillRetries ?? 0);
}
renderActiveBar();
renderProfiles();
renderPersonas();
@@ -1798,6 +1802,24 @@ probeMessageEl?.addEventListener("keydown", (e) => {
}
});
document.getElementById("btn-save-fill-retries")?.addEventListener("click", async () => {
try {
const n = Number(dictateFillRetriesEl?.value ?? 0);
const data = await api("/api/settings", {
method: "PUT",
body: JSON.stringify({ dictateFillRetries: n }),
});
state.settings = data.settings;
if (dictateFillRetriesEl) {
dictateFillRetriesEl.value = String(data.settings.dictateFillRetries ?? 0);
}
const saved = data.settings.dictateFillRetries ?? 0;
showToast(saved === 0 ? "已保存:对话落盘只填一次" : `已保存:同轮最多再补写 ${saved} 次`);
} catch (err) {
showToast(err.message, true);
}
});
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
try {
const n = Number(contextTraceKeepEl?.value ?? 5);