From b864591afa16907e2d656490c7d881fdec9305fd Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 14 Oct 2026 08:29:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E8=90=BD=E7=9B=98=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E5=A1=AB=E5=86=99=E5=A4=B1=E8=B4=A5=E5=90=8E?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=8C=89=E6=AC=A1=E6=95=B0=E8=A1=A5=E5=86=99?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 游玩投影不再送进只给程序用的壳源码。 Co-authored-by: Cursor --- docs/context-assembly.md | 12 ++ src/config/settings.ts | 18 ++ src/dictate/creation-plan.ts | 14 +- src/dictate/experience-anchor.ts | 6 +- src/dictate/index.ts | 1 + src/dictate/insert-feedback.ts | 4 + src/dictate/play-bind.ts | 58 +++++- src/dictate/prompt.ts | 15 +- src/dictate/read-module.ts | 38 ++++ src/dictate/remediate.ts | 44 +++- src/dictate/round.ts | 333 +++++++++++++++++++++++++----- src/dictate/tools.ts | 25 ++- src/dictate/turn.ts | 15 +- src/server/session-manager.ts | 22 +- src/server/settings-handlers.ts | 5 + tests/creation-plan.test.ts | 4 +- tests/dictate-play-bind.test.ts | 85 +++++++- tests/dictate-read-module.test.ts | 8 +- tests/dictate-round.test.ts | 314 +++++++++++++++++++++++++++- tests/dictate.test.ts | 22 +- web/settings.css | 4 + web/settings.html | 22 ++ web/settings.js | 24 ++- 23 files changed, 980 insertions(+), 113 deletions(-) diff --git a/docs/context-assembly.md b/docs/context-assembly.md index 58f582d..c0f44a4 100644 --- a/docs/context-assembly.md +++ b/docs/context-assembly.md @@ -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. 三分工 diff --git a/src/config/settings.ts b/src/config/settings.ts index fa2b181..09e9e6f 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -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(); diff --git a/src/dictate/creation-plan.ts b/src/dictate/creation-plan.ts index 1189123..3c45be4 100644 --- a/src/dictate/creation-plan.ts +++ b/src/dictate/creation-plan.ts @@ -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), ) ); }) diff --git a/src/dictate/experience-anchor.ts b/src/dictate/experience-anchor.ts index c2f37c5..06a5109 100644 --- a/src/dictate/experience-anchor.ts +++ b/src/dictate/experience-anchor.ts @@ -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 { diff --git a/src/dictate/index.ts b/src/dictate/index.ts index b9e1385..9d032e1 100644 --- a/src/dictate/index.ts +++ b/src/dictate/index.ts @@ -93,6 +93,7 @@ export { bindDictateProductsToPlaySpec, buildDictateContextOrder, collectDictateBindProducts, + isExcludedFromPlayBind, type DictatePlayBindResult, } from "./play-bind.js"; export { diff --git a/src/dictate/insert-feedback.ts b/src/dictate/insert-feedback.ts index 96fcffb..c6cb291 100644 --- a/src/dictate/insert-feedback.ts +++ b/src/dictate/insert-feedback.ts @@ -17,8 +17,12 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly> = { "设计.开场白": "opening-setup", "设计.开场白与开场变量": "opening-setup", "设计.美学纲领": "aesthetics-interaction", + "设计.美学纲领与交互范式": "aesthetics-interaction", + "设计.故事推进": "story-progression", + "设计.故事推进与交互范式": "story-progression", "设计.文风": "narrative-guide", "设计.监控栏": "status-bar", + "设计.实现机制": "mechanism", }; /** 多模块同轮:写进每条 insert tool result 的 reply_hint */ diff --git a/src/dictate/play-bind.ts b/src/dictate/play-bind.ts index b667fcd..eaff648 100644 --- a/src/dictate/play-bind.ts +++ b/src/dictate/play-bind.ts @@ -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, }), }); diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts index faf044d..bb02f52 100644 --- a/src/dictate/prompt.ts +++ b/src/dictate/prompt.ts @@ -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" diff --git a/src/dictate/read-module.ts b/src/dictate/read-module.ts index cd231ce..d75b648 100644 --- a/src/dictate/read-module.ts +++ b/src/dictate/read-module.ts @@ -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 = { + "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: [] }; }); } diff --git a/src/dictate/remediate.ts b/src/dictate/remediate.ts index 6cd08e1..42b99f1 100644 --- a/src/dictate/remediate.ts +++ b/src/dictate/remediate.ts @@ -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(); + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + 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(); const out: DictateRemediateItem[] = []; diff --git a/src/dictate/round.ts b/src/dictate/round.ts index 5ed946f..10db8e9 100644 --- a/src/dictate/round.ts +++ b/src/dictate/round.ts @@ -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(); + const keptDims = new Map(); + 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); - if (pending.size === 0 && pendingDeletes.size === 0) { - replyFromTool = settled.reply; - } + 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, pendingDeletes: Set, errors: Map, + drafts: Map, + keptDims: Map, ): 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, + keptDims: Map, +): 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, + kept: readonly DictateKeptDim[] | undefined, + expected: readonly string[], +): Record { + 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(); + 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, + pending: ReadonlySet, + pendingDeletes: ReadonlySet, + errors: ReadonlyMap, +): 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 { 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; moduleByTag: Map; prior: readonly DictateRemediateItem[]; + drafts: ReadonlyMap; + keptDims: ReadonlyMap; }, ): 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 { return value as Record; } +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); diff --git a/src/dictate/tools.ts b/src/dictate/tools.ts index 5597931..8b4c468 100644 --- a/src/dictate/tools.ts +++ b/src/dictate/tools.ts @@ -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", diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts index 689a2d8..f3869a8 100644 --- a/src/dictate/turn.ts +++ b/src/dictate/turn.ts @@ -173,6 +173,8 @@ export async function runDictateTurn(params: { repeatableFamilies?: ReadonlySet | readonly string[]; /** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */ historyTurns?: number; + /** 第一次填写失败后同轮最多再补写几次;缺省 0 */ + fillRetries?: number; driver?: AgentDriver; }): Promise { 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 } }; diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index c3166c0..afd1419 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -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 { diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index 3df7763..e9b277e 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -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; diff --git a/tests/creation-plan.test.ts b/tests/creation-plan.test.ts index 69cd607..a980f30 100644 --- a/tests/creation-plan.test.ts +++ b/tests/creation-plan.test.ts @@ -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", diff --git a/tests/dictate-play-bind.test.ts b/tests/dictate-play-bind.test.ts index facfe7e..ab0d490 100644 --- a/tests/dictate-play-bind.test.ts +++ b/tests/dictate-play-bind.test.ts @@ -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: "
", 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("运行.本轮.裁决"); diff --git a/tests/dictate-read-module.test.ts b/tests/dictate-read-module.test.ts index 43b4c37..ee4ff81 100644 --- a/tests/dictate-read-module.test.ts +++ b/tests/dictate-read-module.test.ts @@ -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 }, ], }, diff --git a/tests/dictate-round.test.ts b/tests/dictate-round.test.ts index f3a4afc..7860134 100644 --- a/tests/dictate-round.test.ts +++ b/tests/dictate-round.test.ts @@ -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(); + 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(); + 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(); + 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("只改失败点"); + }); }); diff --git a/tests/dictate.test.ts b/tests/dictate.test.ts index 0d2f7b7..87a72aa 100644 --- a/tests/dictate.test.ts +++ b/tests/dictate.test.ts @@ -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("## 写入用户回复时做什么"); diff --git a/web/settings.css b/web/settings.css index a1c9de4..aad2e98 100644 --- a/web/settings.css +++ b/web/settings.css @@ -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; diff --git a/web/settings.html b/web/settings.html index 8c4280e..0ffc0db 100644 --- a/web/settings.html +++ b/web/settings.html @@ -18,6 +18,7 @@ Writing Agent
+
+

对话落盘补写

+

+ 同一句用户消息里,填写没通过时可以再自动补几枪。每一枪只改失败点:已经通过的产物不动,没通过的用上一份正文当底稿,已经填对的自评维度会带回去。次数用尽后仍标成失败待补,等下一句继续。 +

+ +

0 = 只填一次。最大 12。例如设为 7,就是第一次填写之外最多再试 7 次。

+
+ +
+
diff --git a/web/settings.js b/web/settings.js index aa80d07..3df9271 100644 --- a/web/settings.js +++ b/web/settings.js @@ -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);