From b871578d593b837b1563007f27fbbedc01702612 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 9 Sep 2026 03:06:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=8F=AA=E5=BC=80=E4=B8=BB?= =?UTF-8?q?=E4=B8=96=E7=95=8C=E5=B1=82=EF=BC=8C=E6=97=A0=E8=BD=AC=E8=BF=B0?= =?UTF-8?q?=E6=97=B6=E7=9B=B4=E6=8E=A5=E6=8A=8A=E5=8E=9F=E6=96=87=E5=BD=93?= =?UTF-8?q?=E7=BB=88=E7=A8=BF=E5=91=88=E7=8E=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免再经转述改写一轮,并让主世界层在关转述时走可见正文协议。 Co-authored-by: Cursor --- src/runtime/phase-runtime.ts | 13 ++++++++-- src/skills/context-order.ts | 2 ++ src/skills/declared-worker.ts | 5 +++- src/skills/play-slots.ts | 48 ++++++++++++++++++++++------------- src/skills/present-packet.ts | 11 +++++--- src/skills/worker-set-view.ts | 10 ++++---- src/worker/executor.ts | 16 +++++++----- tests/play-slots.test.ts | 19 +++++++++++++- tests/present-packet.test.ts | 8 +++++- web/agent-ui.js | 2 +- 10 files changed, 96 insertions(+), 38 deletions(-) diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 93c3611..99068b1 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -120,7 +120,7 @@ import { SLOT_ASKED_QUESTIONS, } from "../skills/question-protocol.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; -import { defaultPlaySlots } from "../skills/play-slots.js"; +import { defaultPlaySlots, isNarratorEnabled } from "../skills/play-slots.js"; import { mergeOpeningTablePatch, OPENING_CURRENT_VARS_TAG, @@ -1252,7 +1252,16 @@ export class PhaseRuntime { ): import("../worker/executor.js").WorkerRunResult { if (!isPlayLayerActive(this.session.slots)) return result; const outputs = { ...result.outputs }; - if (Object.keys(outputs).length === 0 && isPlayPresentWorker(workerId)) { + if ( + Object.keys(outputs).length === 0 && + isPlayPresentWorker(workerId, { + narratorEnabled: isNarratorEnabled( + parseWorkerSetYaml( + this.blackboard.getContentByTag("设计.worker集") ?? "", + )?.play_slots, + ), + }) + ) { const shell = parseShellAdaptationFromReplyFormat( this.blackboard.getContentByTag("设计.正文组成") ?? diff --git a/src/skills/context-order.ts b/src/skills/context-order.ts index cfd770a..5d18ed9 100644 --- a/src/skills/context-order.ts +++ b/src/skills/context-order.ts @@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record< "设计.变量设计与更新规则", "设计.生成规则", "设计.叙事指南与故事推进", + "设计.正文组成", + "设计.监控栏", ], dynamic: [ "变量.当前", diff --git a/src/skills/declared-worker.ts b/src/skills/declared-worker.ts index c550157..ab97ed5 100644 --- a/src/skills/declared-worker.ts +++ b/src/skills/declared-worker.ts @@ -32,6 +32,7 @@ import { } from "./context-order.js"; import type { ContextSegmentDef } from "./context-segments.js"; import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js"; +import { isNarratorEnabled } from "./play-slots.js"; type WorkerTemplateDoc = { id?: string; @@ -220,7 +221,9 @@ export function buildDeclaredWorkerSkill(params: { `执行 ${id}`; const excerpt = params.template?.prompt_excerpt?.trim() || ""; const visibleBodyRule = - id === "narrator" || id === "round-present" + id === "narrator" || + id === "round-present" || + (id === "world-simulator" && !isNarratorEnabled(params.workerSet?.play_slots)) ? PLAY_VISIBLE_BODY_INSTRUCTION : ""; const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n"); diff --git a/src/skills/play-slots.ts b/src/skills/play-slots.ts index 0845df2..f8610d6 100644 --- a/src/skills/play-slots.ts +++ b/src/skills/play-slots.ts @@ -11,13 +11,13 @@ export type OnDemandSlotId = "chance"; export type PlaySlotsConfig = { /** - * 旁观维护(副 LLM:表/规则检查,默认空操作);默认 true。 - * 每轮可上场,但不等于每轮改表。缺省键视为开启。 + * 旁观维护(副 LLM:表/规则检查,默认空操作);默认 false。 + * 每轮可上场,但不等于每轮改表。缺省键视为关闭。 */ auditor?: boolean; - /** 主世界层(裁决);默认 true */ + /** 主世界层(裁决 / 无转述时兼写可见正文);默认 true */ gm: boolean; - /** 叙事转述;默认 true */ + /** 叙事转述;默认 false(主世界层直接交用户可见原文) */ narrator: boolean; /** 单角色知密视角;默认 false */ perspective: boolean; @@ -53,12 +53,13 @@ export const PLAY_SLOT_META: Record< }, gm: { label: "主世界层", - purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。", - defaultAcceptance: "continue", + purpose: + "读真值与 Progressive 投影并推进世界;无独立转述时直接写用户可见正文,有转述时出裁决包。", + defaultAcceptance: "review", }, narrator: { label: "叙事转述", - purpose: "只读裁决包 + 文风常驻,输出用户可见正文。", + purpose: "只读裁决包 + 文风常驻,输出用户可见正文(可选;默认关)。", defaultAcceptance: "review", }, perspective: { @@ -102,14 +103,19 @@ export function playSlotIdForRef(ref: string): PlaySlotId | undefined { export function defaultPlaySlots(): PlaySlotsConfig { return { - auditor: true, + auditor: false, gm: true, - narrator: true, + narrator: false, perspective: false, chance: false, }; } +/** 是否启用独立转述层(未显式 true 视为关) */ +export function isNarratorEnabled(slots: PlaySlotsConfig | undefined | null): boolean { + return slots?.narrator === true; +} + export function parsePlaySlots(raw: unknown): PlaySlotsConfig | undefined { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const row = raw as Record; @@ -154,10 +160,7 @@ export function refForSlot( } export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] { - return PLAY_SLOT_ORDER.filter((id) => { - if (id === "auditor") return slots.auditor !== false; - return Boolean(slots[id]); - }); + return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id])); } export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] { @@ -188,14 +191,19 @@ export function workerEntryForSlot( ): WorkerSetEntry { const meta = PLAY_SLOT_META[id]; const ref = refForSlot(slots, id); + const soloGm = id === "gm" && !isNarratorEnabled(slots); return { ref, name: meta.label, role: id, - duty: meta.purpose, + duty: soloGm + ? "读真值与投影,推进世界,并直接写出用户可见正文(无独立转述层,原文即终稿)。" + : meta.purpose, when: id === "gm" - ? "每轮用户输入后首先上场(主 LLM / 裁决权威)" + ? soloGm + ? "每轮用户输入后上场;直接交用户可见正文" + : "每轮用户输入后首先上场(主 LLM / 裁决权威)" : id === "perspective" ? "强信息隔离且本轮需要该角色独立反应时(主世界层之后)" : id === "narrator" @@ -203,13 +211,17 @@ export function workerEntryForSlot( : "转述完成后;表维护合并进变量.当前,供下一轮主世界层读取", rationale: id === "gm" - ? "等同 SillyTavern 主 LLM:读设定与状态并产出裁决" + ? soloGm + ? "默认少槽:主世界层兼呈现,避免转述再改写一遍" + : "有独立转述时:主世界层出裁决,正文交给转述" : id === "perspective" ? "知密内容不能进主世界层上下文" : id === "narrator" ? "用户可见正文与裁决分离,避免文风与规则互相挤压" : "表与规则补充延后执行,改表结果作用于下一轮而非本轮重裁", - acceptance: meta.defaultAcceptance, + acceptance: + id === "gm" && isNarratorEnabled(slots) ? "continue" : meta.defaultAcceptance, + outputs: soloGm ? ["输出.用户展示"] : undefined, invocation: "turn", }; } @@ -356,7 +368,7 @@ export function formatPlayAgentRosterForPrompt(): string { ]; for (const id of PLAY_SLOT_ORDER) { const meta = PLAY_SLOT_META[id]; - const def = id === "perspective" ? "默认关" : "默认开"; + const def = id === "gm" ? "默认开" : "默认关"; lines.push( `- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`, ); diff --git a/src/skills/present-packet.ts b/src/skills/present-packet.ts index fd872bd..3bfb6f0 100644 --- a/src/skills/present-packet.ts +++ b/src/skills/present-packet.ts @@ -198,10 +198,15 @@ export const PLAY_VISIBLE_BODY_INSTRUCTION = [ "禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。", ].join("\n"); -/** 游玩期把裁决写成用户可见终稿的执行单元 */ -export function isPlayPresentWorker(workerId: string): boolean { +/** 游玩期把用户可见终稿写出的执行单元(有转述用转述;无则主世界层直接交原文) */ +export function isPlayPresentWorker( + workerId: string, + opts?: { narratorEnabled?: boolean }, +): boolean { const id = workerId.trim(); - return id === "narrator" || id === "round-present"; + if (id === "narrator" || id === "round-present") return true; + if (id === "world-simulator" && opts?.narratorEnabled === false) return true; + return false; } /** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */ diff --git a/src/skills/worker-set-view.ts b/src/skills/worker-set-view.ts index 046c80c..344efa8 100644 --- a/src/skills/worker-set-view.ts +++ b/src/skills/worker-set-view.ts @@ -236,6 +236,9 @@ const DEFAULT_WORKER_CONTRACTS: Record< "世界.蓝图.确认稿", "世界.拓扑.*", "设计.变量设计与更新规则", + "设计.叙事指南与故事推进", + "设计.正文组成", + "设计.监控栏", ], dynamic: [ "变量.当前", @@ -246,7 +249,7 @@ const DEFAULT_WORKER_CONTRACTS: Record< "运行.本轮.旁观", ], }, - outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"], + outputs: ["输出.用户展示", "运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"], }, "variable-update": { context: { @@ -767,10 +770,7 @@ export function formatWorkerSetForUser( ? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({ id, label: PLAY_SLOT_META[id].label, - enabled: - id === "auditor" - ? parsed.play_slots!.auditor !== false - : Boolean(parsed.play_slots![id]), + enabled: Boolean(parsed.play_slots![id]), ref: refForSlot(parsed.play_slots!, id), })) : undefined, diff --git a/src/worker/executor.ts b/src/worker/executor.ts index fb0e4ee..3fc5de9 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -46,6 +46,7 @@ import { GM_CHANCE_HARNESS_INSTRUCTION, } from "../skills/gm-tools.js"; import { runGmHarness } from "./gm-harness.js"; +import { isNarratorEnabled } from "../skills/play-slots.js"; import { mergeQuestionsPreferFragment, normalizeQuestions, @@ -772,17 +773,20 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { - it("expands default auditor+gm+narrator", () => { + it("defaults to gm only", () => { + const slots = parsePlaySlots({})!; + expect(slots).toEqual({ + auditor: false, + gm: true, + narrator: false, + perspective: false, + chance: false, + }); + expect(refsFromPlaySlots(slots)).toEqual(["world-simulator"]); + const workers = expandPlaySlotsToWorkers(slots); + expect(workers.map((w) => w.ref)).toEqual(["world-simulator"]); + expect(workers[0].acceptance).toBe("review"); + expect(workers[0].outputs).toEqual(["输出.用户展示"]); + }); + + it("expands auditor+gm+narrator when all enabled", () => { const slots = parsePlaySlots({ + auditor: true, gm: true, narrator: true, perspective: false, diff --git a/tests/present-packet.test.ts b/tests/present-packet.test.ts index 0591b61..d1fa117 100644 --- a/tests/present-packet.test.ts +++ b/tests/present-packet.test.ts @@ -118,10 +118,16 @@ describe("shell adaptation from reply-format", () => { }); describe("play present worker", () => { - it("recognizes narrator and round-present", () => { + it("recognizes narrator, round-present, and solo gm", () => { expect(isPlayPresentWorker("narrator")).toBe(true); expect(isPlayPresentWorker("round-present")).toBe(true); expect(isPlayPresentWorker("world-simulator")).toBe(false); + expect( + isPlayPresentWorker("world-simulator", { narratorEnabled: false }), + ).toBe(true); + expect( + isPlayPresentWorker("world-simulator", { narratorEnabled: true }), + ).toBe(false); }); it("fallback packet uses the given shell", () => { diff --git a/web/agent-ui.js b/web/agent-ui.js index 022b7eb..2c63dcb 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -629,7 +629,7 @@ function shouldRenderPlayPresent(msg, view) { if (isPlayHousekeepingMessage(msg)) return false; const actor = String(msg.actor ?? ""); const title = String(msg.title ?? ""); - return actor === "narrator" || actor === "round-present" || /用户展示|开场白/.test(title); + return actor === "narrator" || actor === "round-present" || actor === "world-simulator" || /用户展示|开场白/.test(title); } function isPlayFinalReply(msg, view) {