引入游玩呈现壳与旁观维护槽,完善结算合并、十分制自评与技能产物 UI。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 02:03:29 +08:00
parent 94f67fa744
commit 120d67ee82
46 changed files with 2513 additions and 236 deletions

View File

@@ -57,6 +57,14 @@ ${workerLines}
- **禁止**替用户猜测或改选配方
- **禁止**一次编排排死全程固定 DAG
## play 优先顺序lifecycleStage=play
- 严格按声明管线顺序调度play_slots 展开序):**auditor旁观维护→ perspective? → gm主世界层→ narrator转述**
- auditor / gm / perspective 的 acceptance 多为 continue跑完立刻调度下一槽不要中途 ask_user
- 仅 narrator或规格标明 review 的终稿槽)停下来等人看
- chance 仅在需要真随机时按需 run_worker不插入每轮固定序
- slots.pendingSideEffectWorkers 非空时优先调度其中 workerId
- **禁止**发明未声明的 play ref**禁止**把旁观维护当成正文作者
输出必须是 JSON 对象,字段:
{
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
@@ -102,15 +110,19 @@ export class MainAgent {
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
const { session, blackboardIndex, availableWorkers } = context;
const stage = String(session.slots?.uiLifecycleStage ?? "design");
const pendingSide = session.slots?.pendingSideEffectWorkers;
return JSON.stringify(
{
runtimePhase: session.phase,
waitingReason: session.waitingReason,
lifecycleStage: stage,
flowId: session.flowId,
currentStepId: session.currentStepId,
currentWorkerId: session.currentWorkerId,
acceptanceMode: session.acceptanceMode,
slots: session.slots,
pendingSideEffectWorkers: pendingSide ?? null,
pendingDecision: session.pendingDecision
? {
id: session.pendingDecision.id,
@@ -129,7 +141,9 @@ export function buildMainAgentUserPrompt(context: MainAgentContext): string {
blackboardIndex,
availableWorkers,
instruction:
"根据当前状态决定下一步。尚无设计.创作流程 → design-flow有未完成步骤 → design-stepsteps 做完但 status=open → 再 design-flow禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。",
stage === "play"
? "play按声明序 auditor→perspective?→gm→narratorcontinue 槽连跑;仅终稿 review 停pendingSideEffectWorkers 优先;禁止未声明 ref。"
: "根据当前状态决定下一步。尚无设计.创作流程 → design-flow有未完成步骤 → design-stepsteps 做完但 status=open → 再 design-flow禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。",
},
null,
2,

View File

@@ -93,6 +93,14 @@ function buildToolLoopSystemPrompt(
- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。
- **禁止**替用户猜测或改选配方。
- **禁止**一次编排排死全程固定长链。
## play 阶段提示lifecycleStage=play 时优先)
- 管线顺序:**auditor旁观维护→ perspective? → gm主世界层→ narrator**。
- auditor/gm/perspective 多为 continue写完立刻 run 下一槽,不要中途 ask_user。
- 只在 narrator或 review 终稿槽)停给人看。
- chance 按需pendingSideEffectWorkers 非空则优先调度。
- 禁止未声明 play ref旁观维护默认空操作不是正文作者。
当前 skill 可用 worker
${workerLines}

View File

@@ -91,6 +91,16 @@ import {
parseSideEffectRules,
stringifyFiredRegistry,
} from "../blackboard/table-side-effects.js";
import {
SETTLEMENT_TAG,
parseSettlementPacket,
settlementChangesToTablePatch,
} from "../skills/settlement-packet.js";
import {
MAINTAIN_TAG,
maintainOpsToTablePatch,
parseMaintainPacket,
} from "../skills/maintain-packet.js";
import {
mountResidentContextForWorker as writeResidentContextTags,
parseResidentContext,
@@ -1058,9 +1068,61 @@ export class PhaseRuntime {
if (nextDoc) {
this.applyTableSideEffects(prevDoc, nextDoc, workerId);
}
// 裁决包 / 旁观维护包 → 合并进变量.当前(可触发 side_effects
if (tag === SETTLEMENT_TAG) {
this.applySettlementVariableChanges(toWrite, workerId);
} else if (tag === MAINTAIN_TAG) {
this.applyMaintainTableOps(toWrite, workerId);
}
return toWrite;
}
/** settlement.v1.variable_changes → 变量.当前 */
private applySettlementVariableChanges(
settlementRaw: string,
workerId: string,
): void {
const view = parseSettlementPacket(settlementRaw);
const changes = view.packet?.variable_changes;
if (!view.ok || !changes?.length) return;
const current = parseTableDoc(
this.blackboard.getContentByTag("变量.当前"),
);
const patch = settlementChangesToTablePatch(changes, current);
if (!patch) return;
this.writeWorkerTagContent(
"变量.当前",
stringifyTableDoc(patch),
workerId,
);
this.onMessage(
`[裁决合并] 变量.当前 ← ${changes.map((c) => c.key).join("、")}`,
);
}
/** maintain.v1.table_ops → 变量.当前(及其他已支持表) */
private applyMaintainTableOps(maintainRaw: string, workerId: string): void {
const view = parseMaintainPacket(maintainRaw);
const ops = view.packet?.table_ops;
if (!view.ok || !ops?.length) return;
const current = parseTableDoc(
this.blackboard.getContentByTag("变量.当前"),
);
const patch = maintainOpsToTablePatch(ops, current, "变量.当前");
if (!patch) return;
this.writeWorkerTagContent(
"变量.当前",
stringifyTableDoc(patch),
workerId,
);
const keys = ops
.filter((o) => (o.tag?.trim() || "变量.当前") === "变量.当前")
.map((o) => o.key);
if (keys.length) {
this.onMessage(`[旁观维护] 变量.当前 ← ${keys.join("、")}`);
}
}
/** 避免 syncSlots 重复把同一句用户输入追加进历史 */
private lastAppendedUserHistory = "";

View File

@@ -28,6 +28,7 @@ const WORKER_LABELS: Record<string, string> = {
narrator: "叙事转述",
"role-decide": "角色决策",
"world-simulator": "主世界层",
auditor: "旁观维护",
chance: "机遇裁定",
"round-present": "回合呈现",
outline: "大纲 / 细纲",

View File

@@ -62,6 +62,7 @@ import type { ActiveSkillSnapshot } from "../types/runtime.js";
import type { BlackboardItem } from "../types/blackboard.js";
import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import { parseAcceptedUnits, isDesignDiskWorker } from "../skills/creation-units.js";
import {
formatWorkerSetForUser,
@@ -185,6 +186,14 @@ export type SessionView = {
selectedRecipe?: { id: string; name: string; declaration: string } | null;
/** 黑板与定稿上下文(强可读) */
boardPanel?: BoardPanelView;
/** 游玩呈现壳微调(来自设计.正文组成) */
presentationTweaks?: {
shell_id?: string;
tone_chrome?: string;
show_suggested_actions?: boolean;
block_labels?: Record<string, string>;
empty_states?: Record<string, string>;
};
/** 总管正在输出的思维链(流式,轮询用) */
agentThinking?: string;
/** Agent / Worker 流式输出(轮询用) */
@@ -1691,6 +1700,10 @@ export class SessionManager {
lifecycleStage,
{ workerSetYaml },
);
const shellAdapt = parseShellAdaptationFromReplyFormat(
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
s.runtime.getBlackboard().getContentByTag("设计.回复格式"),
);
return {
id,
@@ -1735,6 +1748,12 @@ export class SessionManager {
s.recipeOptions,
),
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
presentationTweaks: shellAdapt
? {
shell_id: shellAdapt.shell_id,
...shellAdapt.tweaks,
}
: undefined,
liveStream: this.buildLiveStreamView(id),
agentThinking: this.agentThinkingLive.get(id),
resumed: resumed || undefined,

View File

@@ -40,6 +40,29 @@ function asStringList(v: unknown): string[] {
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
}
/** 自评分数展示010兼容旧百分数 */
function formatSelfScoreLabel(score: unknown): string {
if (typeof score === "number" && Number.isFinite(score)) {
if (score >= 0 && score <= 10) {
const shown = Number.isInteger(score) ? String(score) : String(Math.round(score * 10) / 10);
return `${shown}/10`;
}
if (score > 10 && score <= 100) {
const ten = Math.round((score / 10) * 10) / 10;
const shown = Number.isInteger(ten) ? String(ten) : String(ten);
return `${shown}/10`;
}
}
if (typeof score === "string" && score.trim()) {
const s = score.trim();
if (/\/\s*10$/i.test(s)) return s.replace(/\s+/g, "");
const n = Number(s.replace(/%$/, ""));
if (Number.isFinite(n)) return formatSelfScoreLabel(n);
return s;
}
return "?";
}
function normalizeStability(v: unknown): ContextFragmentStability | undefined {
if (v === "stable" || v === "semi" || v === "volatile") return v;
if (v === "稳" || v === "少变") return "stable";
@@ -164,7 +187,7 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView {
const lines = dims.map((d) => {
if (!d || typeof d !== "object") return String(d);
const r = d as Record<string, unknown>;
return `${r. ?? "?"}${r. ?? "?"}%${r. ? `${r.}` : ""}`;
return `${r. ?? "?"}${formatSelfScoreLabel(r.)}${r. ? `${r.}` : ""}`;
});
if (typeof row. === "string" && row..trim()) {
lines.push(`薄弱点:${row..trim()}`);
@@ -233,10 +256,7 @@ export function extractFragmentAskSidecar(raw: unknown): {
const name = asString(r.) ?? asString(r.) ?? "?";
const score = r.;
const note = asString(r.);
const scoreText =
typeof score === "number" || typeof score === "string"
? `${score}%`
: "?";
const scoreText = formatSelfScoreLabel(score);
assessmentParts.push(
note ? `${name} ${scoreText}${note}` : `${name} ${scoreText}`,
);

View File

@@ -5,7 +5,12 @@
*/
import type { ContextSegmentDef } from "./context-segments.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
import { parsePlaySlots, type PlaySlotsConfig } from "./play-slots.js";
import {
parsePlaySlots,
playSlotIdForRef,
PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY,
type PlaySlotsConfig,
} from "./play-slots.js";
import {
DIALOGUE_HISTORY_TAG,
isDialogueHistoryRef,
@@ -430,8 +435,22 @@ export function mergeContextOrderIntoWorkerSetJson(
/** 声明缺 context 时的兜底(与模板/检查器默认对齐的最小集) */
const SYNTH_FALLBACK_CONTEXT: Record<
string,
{ static: string[]; dynamic: string[] }
{ static: string[]; dynamic: string[]; /** 默认不注入对话.历史 */ skipHistory?: boolean }
> = {
auditor: {
static: [
"设计.变量设计与更新规则",
"设计.生成规则",
"设计.变量控制上下文",
],
dynamic: [
"用户.最新输入",
"变量.当前",
"上下文.旁观.状态摘要",
"运行.本轮.工单",
],
skipHistory: true,
},
"world-simulator": {
static: ["设计.变量设计与更新规则"],
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入", "上下文.角色态度"],
@@ -460,7 +479,12 @@ export function synthesizeContextOrderFromWorkers(
if (!ref) continue;
const fallback = SYNTH_FALLBACK_CONTEXT[ref];
const staticTags = w.context?.static ?? fallback?.static ?? [];
const dynamicTags = w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
const dynamicTags =
w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
const slotId = playSlotIdForRef(ref);
const skipHistory =
Boolean(fallback?.skipHistory) ||
(slotId != null && PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY.has(slotId));
const inserts: ContextOrderInsert[] = [
{
order: 0,
@@ -479,16 +503,17 @@ export function synthesizeContextOrderFromWorkers(
projection: tag.includes("事件流") ? "summary" : "full",
});
}
inserts.push({
order: o++,
ref: DIALOGUE_HISTORY_TAG,
projection: "summary",
note: "对话历史(按投影裁剪)",
});
if (!skipHistory) {
inserts.push({
order: o++,
ref: DIALOGUE_HISTORY_TAG,
projection: "summary",
note: "对话历史(按投影裁剪)",
});
}
for (const tag of dynamicTags) {
if (!tag?.trim()) continue;
if (isDialogueHistoryRef(tag)) continue;
// 事件流已可由历史兜底;仍可单独挂
inserts.push({
order: o++,
ref: tag.trim(),
@@ -504,8 +529,18 @@ export function synthesizeContextOrderFromWorkers(
if (!slots.length) return undefined;
return renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA,
brief: "扁平投影序(含对话.历史标签,可编排)",
brief: skipHistoryBrief(slots)
? "扁平投影序(旁观维护无对话.历史;主世界层等可含历史)"
: "扁平投影序(含对话.历史标签,可编排)",
play_slots: playSlots,
slots,
});
}
function skipHistoryBrief(slots: ContextOrderSlot[]): boolean {
return slots.some(
(s) =>
!s.inserts.some((i) => isDialogueHistoryRef(i.ref)) &&
playSlotIdForRef(s.ref) === "auditor",
);
}

View File

@@ -0,0 +1,220 @@
/**
* 旁观维护包(副 LLM表/规则补充工单。
* 存黑板 tag运行.本轮.旁观
* 多数轮为空操作;不写真相权威、不写用户正文。
*/
import {
createTableFromValues,
type TableDoc,
} from "../blackboard/table-cells.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
export const MAINTAIN_SCHEMA = "maintain.v1" as const;
export const MAINTAIN_TAG = "运行.本轮.旁观";
export type MaintainNeedGenerate =
| false
| {
rule_id: string;
reason: string;
};
export type MaintainTableOp = {
/** set写入绝对值delta在现有值上加减 */
op: "set" | "delta";
/** 目标表 tag默认 变量.当前 */
tag?: string;
key: string;
value?: unknown;
delta?: number;
note?: string;
};
export type MaintainPacket = {
schema: typeof MAINTAIN_SCHEMA;
/** 是否需要按生成规则补产物;默认 false */
need_generate?: MaintainNeedGenerate;
/** 表字段操作;空数组 = 本轮无需改表 */
table_ops?: MaintainTableOp[];
/** 只读意见(漂移/口径),不进用户正文 */
notes?: string[];
};
export type MaintainPacketView = {
ok: boolean;
parseError?: string;
packet?: MaintainPacket;
empty: boolean;
};
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function parseNeedGenerate(raw: unknown): MaintainNeedGenerate | undefined {
if (raw === false || raw === null || raw === undefined) return false;
if (raw === true) return false;
if (typeof raw === "object" && !Array.isArray(raw)) {
const row = raw as Record<string, unknown>;
const ruleId =
asString(row.rule_id) ?? asString(row.ruleId) ?? asString(row.);
const reason = asString(row.reason) ?? asString(row.) ?? "";
if (!ruleId) return false;
return { rule_id: ruleId, reason };
}
return undefined;
}
function parseTableOps(raw: unknown): MaintainTableOp[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: MaintainTableOp[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const key = asString(row.key) ?? asString(row.name) ?? asString(row.);
if (!key) continue;
const opRaw = asString(row.op) ?? "set";
const op: "set" | "delta" = opRaw === "delta" ? "delta" : "set";
const delta =
typeof row.delta === "number" && Number.isFinite(row.delta)
? row.delta
: undefined;
if (op === "delta" && delta == null) continue;
if (op === "set" && row.value === undefined && row.to === undefined) continue;
out.push({
op,
tag: asString(row.tag) ?? asString(row.),
key,
value: row.value ?? row.to,
delta,
note: asString(row.note) ?? asString(row.),
});
}
return out.length ? out : undefined;
}
export function normalizeMaintainPacket(
row: Record<string, unknown>,
): MaintainPacket {
const notesRaw = row.notes ?? row.;
const notes = Array.isArray(notesRaw)
? notesRaw
.map((x) => asString(x))
.filter((x): x is string => Boolean(x))
: undefined;
return {
schema: MAINTAIN_SCHEMA,
need_generate: parseNeedGenerate(row.need_generate ?? row.needGenerate),
table_ops: parseTableOps(row.table_ops ?? row.tableOps ?? row.),
notes: notes?.length ? notes : undefined,
};
}
export function emptyMaintainPacket(): MaintainPacket {
return {
schema: MAINTAIN_SCHEMA,
need_generate: false,
table_ops: [],
notes: [],
};
}
export function isMaintainPacketEmpty(packet: MaintainPacket): boolean {
const need =
packet.need_generate &&
typeof packet.need_generate === "object" &&
Boolean(packet.need_generate.rule_id);
const ops = packet.table_ops?.length ?? 0;
const notes = packet.notes?.length ?? 0;
return !need && ops === 0 && notes === 0;
}
export function parseMaintainPacket(
raw: string | undefined | null,
): MaintainPacketView {
const text = raw?.trim();
if (!text) {
return {
ok: true,
packet: emptyMaintainPacket(),
empty: true,
};
}
// 允许模型用极短词表示空操作
if (/^(无需|无|空|skip|none|noop)$/i.test(text)) {
return { ok: true, packet: emptyMaintainPacket(), empty: true };
}
const jsonText =
extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
if (!jsonText) {
return {
ok: false,
parseError: "旁观维护包不是 JSON",
empty: true,
};
}
try {
const doc = JSON.parse(jsonText) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return {
ok: false,
parseError: "旁观维护包不是 JSON 对象",
empty: true,
};
}
const packet = normalizeMaintainPacket(doc as Record<string, unknown>);
return {
ok: true,
packet,
empty: isMaintainPacketEmpty(packet),
};
} catch (err) {
return {
ok: false,
parseError:
err instanceof Error ? err.message : "旁观维护包 JSON 解析失败",
empty: true,
};
}
}
/**
* 将针对某一表 tag 的 ops 编成 TableDoc patch供 mergeTableCells
* 仅处理 op=set|delta其它 tag 由调用方过滤。
*/
export function maintainOpsToTablePatch(
ops: MaintainTableOp[],
current: TableDoc | null,
targetTag = "变量.当前",
): TableDoc | null {
const relevant = ops.filter(
(op) => (op.tag?.trim() || "变量.当前") === targetTag,
);
if (!relevant.length) return null;
const byKey = new Map((current?.rows ?? []).map((r) => [r.key, r]));
const values: Record<string, unknown> = {};
const meta: Record<string, { note?: string }> = {};
for (const op of relevant) {
const existing = byKey.get(op.key);
if (op.op === "delta") {
const cur = existing?.value;
const n = typeof cur === "number" ? cur : Number(cur);
values[op.key] = Number.isFinite(n)
? (n as number) + (op.delta ?? 0)
: (op.delta ?? 0);
} else {
values[op.key] = op.value;
}
if (op.note) meta[op.key] = { note: op.note };
}
if (!Object.keys(values).length) return null;
return createTableFromValues(values, "system", meta);
}

View File

@@ -1,15 +1,20 @@
/**
* 固定游玩槽位:世界模拟路径不再自由发明 workers[]
* 以勾选 gm / narrator / perspective 展开为声明条目。
* 以勾选 auditor / gm / narrator / perspective 展开为声明条目。
* `chance` 为按需程序槽:不进每轮管线,可被 run_worker / toolcall 调用。
*/
import type { WorkerAcceptance, WorkerSetEntry } from "./worker-set-parse.js";
export type PlaySlotId = "gm" | "narrator" | "perspective";
/** 按需槽(不进入 perspective→gm→narrator 管线) */
export type PlaySlotId = "auditor" | "gm" | "narrator" | "perspective";
/** 按需槽(不进入 auditor→perspective→gm→narrator 管线) */
export type OnDemandSlotId = "chance";
export type PlaySlotsConfig = {
/**
* 旁观维护(副 LLM表/规则检查,默认空操作);默认 true。
* 每轮可上场,但不等于每轮改表。缺省键视为开启。
*/
auditor?: boolean;
/** 主世界层(裁决);默认 true */
gm: boolean;
/** 叙事转述;默认 true */
@@ -26,6 +31,7 @@ export type PlaySlotsConfig = {
};
export const DEFAULT_PLAY_SLOT_REFS: Record<PlaySlotId, string> = {
auditor: "auditor",
gm: "world-simulator",
narrator: "narrator",
perspective: "role-decide",
@@ -39,6 +45,12 @@ export const PLAY_SLOT_META: Record<
PlaySlotId,
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
> = {
auditor: {
label: "旁观维护",
purpose:
"副 LLM检查表与生成规则是否需补充默认空操作不写正文、不写真相权威。",
defaultAcceptance: "continue",
},
gm: {
label: "主世界层",
purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。",
@@ -69,21 +81,47 @@ export const ON_DEMAND_SLOT_META: Record<
};
/** 推荐回合顺序(启用的槽按此排序;不含按需槽) */
export const PLAY_SLOT_ORDER: PlaySlotId[] = ["perspective", "gm", "narrator"];
export const PLAY_SLOT_ORDER: PlaySlotId[] = [
"auditor",
"perspective",
"gm",
"narrator",
];
/** 这些槽的默认上下文不注入「对话.历史」长聊 */
export const PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY: ReadonlySet<PlaySlotId> =
new Set(["auditor"]);
export function playSlotIdForRef(ref: string): PlaySlotId | undefined {
const trimmed = ref.trim();
for (const id of Object.keys(DEFAULT_PLAY_SLOT_REFS) as PlaySlotId[]) {
if (DEFAULT_PLAY_SLOT_REFS[id] === trimmed) return id;
}
return undefined;
}
export function defaultPlaySlots(): PlaySlotsConfig {
return { gm: true, narrator: true, perspective: false, chance: false };
return {
auditor: true,
gm: true,
narrator: true,
perspective: false,
chance: false,
};
}
export function parsePlaySlots(raw: unknown): PlaySlotsConfig | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const base = defaultPlaySlots();
if (typeof row.auditor === "boolean") base.auditor = row.auditor;
// 兼容旧键名 / 别名
if (typeof row.maintain === "boolean") base.auditor = row.maintain;
if (typeof row. === "boolean") base.auditor = row.;
if (typeof row.gm === "boolean") base.gm = row.gm;
if (typeof row.narrator === "boolean") base.narrator = row.narrator;
if (typeof row.perspective === "boolean") base.perspective = row.perspective;
if (typeof row.chance === "boolean") base.chance = row.chance;
// 兼容旧键名
if (typeof row.world === "boolean") base.gm = row.world;
if (typeof row.transcription === "boolean") base.narrator = row.transcription;
if (typeof row.oracle === "boolean") base.chance = row.oracle;
@@ -116,7 +154,10 @@ export function refForSlot(
}
export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] {
return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id]));
return PLAY_SLOT_ORDER.filter((id) => {
if (id === "auditor") return slots.auditor !== false;
return Boolean(slots[id]);
});
}
export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] {
@@ -153,17 +194,21 @@ export function workerEntryForSlot(
role: id,
duty: meta.purpose,
when:
id === "perspective"
? "强信息隔离且本轮需要该角色独立反应时"
: id === "gm"
? "每轮用户输入后"
: "主世界层裁决包就绪后",
id === "auditor"
? "每轮用户输入后、主世界层之前"
: id === "perspective"
? "强信息隔离且本轮需要该角色独立反应时"
: id === "gm"
? "每轮用户输入后(旁观维护与可选视角之后)"
: "主世界层裁决包就绪后",
rationale:
id === "perspective"
? "知密内容不能进主世界层上下文"
: id === "gm"
? "裁决与真值变更需要独立推理槽"
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
id === "auditor"
? "表与规则补充与主裁决分槽,避免挤占对话历史上下文"
: id === "perspective"
? "知密内容不能进主世界层上下文"
: id === "gm"
? "裁决与真值变更需要独立推理槽"
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
acceptance: meta.defaultAcceptance,
invocation: "turn",
};
@@ -251,12 +296,23 @@ export function inferPlaySlotsFromWorkers(
const refs = new Set(
workers.map((w) => w.ref?.trim()).filter((r): r is string => Boolean(r)),
);
const hasAuditor = refs.has(DEFAULT_PLAY_SLOT_REFS.auditor);
const hasGm = refs.has(DEFAULT_PLAY_SLOT_REFS.gm);
const hasNarrator = refs.has(DEFAULT_PLAY_SLOT_REFS.narrator);
const hasPerspective = refs.has(DEFAULT_PLAY_SLOT_REFS.perspective);
const hasChance = refs.has(DEFAULT_ON_DEMAND_REFS.chance);
if (!hasGm && !hasNarrator && !hasPerspective && !hasChance) return undefined;
if (
!hasAuditor &&
!hasGm &&
!hasNarrator &&
!hasPerspective &&
!hasChance
) {
return undefined;
}
return {
// 旧规格无 auditor ref → 视为关,避免强行插入
auditor: hasAuditor,
gm: hasGm,
narrator: hasNarrator,
perspective: hasPerspective,

View File

@@ -0,0 +1,345 @@
/**
* 游玩呈现包 present.v1 + 从「设计.正文组成」解析壳适配。
* 规范docs/play-presentation-shells.md
*/
import { extractJsonObjectText } from "./worker-set-parse.js";
export const PRESENT_SCHEMA = "present.v1" as const;
export const PRESENT_TAG = "输出.用户展示";
export const PRESENT_SHELL_IDS = [
"prose",
"chat_monitor",
"turn_panel",
"chapter_reader",
] as const;
export type PresentShellId = (typeof PRESENT_SHELL_IDS)[number];
export type PresentRegionId =
| "monitor"
| "header"
| "body"
| "footer"
| "aside"
| "hidden";
export type PresentShellTweaks = {
tone_chrome?: string;
show_suggested_actions?: boolean;
block_labels?: Record<string, string>;
empty_states?: Record<string, string>;
};
export type ShellAdaptation = {
shell_id: PresentShellId;
why?: string;
tweaks: PresentShellTweaks;
};
export type PresentBlocks = Partial<
Record<PresentRegionId, string | Record<string, unknown> | unknown[]>
>;
export type PresentPacket = {
schema: typeof PRESENT_SCHEMA;
shell: PresentShellId;
blocks: PresentBlocks;
meta?: {
suggested_actions?: string[];
hide?: string[];
};
};
export type PresentPacketView = {
ok: boolean;
parseError?: string;
packet: PresentPacket;
/** 原文不是 JSON 时回退为 prose body */
fallbackPlain: boolean;
};
const SHELL_SET = new Set<string>(PRESENT_SHELL_IDS);
export function isPresentShellId(v: unknown): v is PresentShellId {
return typeof v === "string" && SHELL_SET.has(v);
}
export function defaultShellTweaks(
shell: PresentShellId,
): PresentShellTweaks {
return {
tone_chrome: "default",
show_suggested_actions: shell === "turn_panel",
block_labels: {},
empty_states: {},
};
}
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function asStringList(v: unknown): string[] | undefined {
if (!Array.isArray(v)) return undefined;
const out = v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
return out.length ? out : undefined;
}
function asStringMap(v: unknown): Record<string, string> | undefined {
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
const key = k.trim();
const s = asString(val);
if (key && s) out[key] = s;
}
return Object.keys(out).length ? out : undefined;
}
export function normalizePresentShellId(
raw: unknown,
fallback: PresentShellId = "prose",
): PresentShellId {
if (isPresentShellId(raw)) return raw;
const s = asString(raw);
if (s && isPresentShellId(s)) return s;
return fallback;
}
function normalizeBlocks(raw: unknown): PresentBlocks {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const row = raw as Record<string, unknown>;
const out: PresentBlocks = {};
const keys: PresentRegionId[] = [
"monitor",
"header",
"body",
"footer",
"aside",
"hidden",
];
for (const k of keys) {
if (row[k] !== undefined) out[k] = row[k] as PresentBlocks[typeof k];
}
// 兼容中文键
if (out.body === undefined && row. !== undefined) out.body = row. as string;
if (out.monitor === undefined && row. !== undefined) {
out.monitor = row. as PresentBlocks["monitor"];
}
return out;
}
export function normalizePresentPacket(
row: Record<string, unknown>,
fallbackShell: PresentShellId = "prose",
): PresentPacket {
const shell = normalizePresentShellId(
row.shell ?? row.shell_id ?? row.,
fallbackShell,
);
return {
schema: PRESENT_SCHEMA,
shell,
blocks: normalizeBlocks(row.blocks ?? row.),
meta: {
suggested_actions: asStringList(
row.meta && typeof row.meta === "object"
? (row.meta as Record<string, unknown>).suggested_actions ??
(row.meta as Record<string, unknown>).建议行动
: row.suggested_actions,
),
hide: asStringList(
row.meta && typeof row.meta === "object"
? (row.meta as Record<string, unknown>).hide
: undefined,
),
},
};
}
export function presentFromPlainText(
text: string,
shell: PresentShellId = "prose",
): PresentPacket {
return {
schema: PRESENT_SCHEMA,
shell,
blocks: { body: text },
};
}
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
): PresentPacketView {
const text = raw?.trim() ?? "";
if (!text) {
return {
ok: true,
packet: presentFromPlainText("", fallbackShell),
fallbackPlain: true,
};
}
const jsonText =
extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
if (!jsonText) {
return {
ok: true,
packet: presentFromPlainText(text, fallbackShell),
fallbackPlain: true,
};
}
try {
const doc = JSON.parse(jsonText) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return {
ok: true,
packet: presentFromPlainText(text, fallbackShell),
fallbackPlain: true,
};
}
const row = doc as Record<string, unknown>;
// 明确 present 或带 blocks/shell 的展示包
const looksPresent =
row.schema === PRESENT_SCHEMA ||
row.blocks != null ||
isPresentShellId(row.shell) ||
isPresentShellId(row.shell_id);
if (!looksPresent && row.schema) {
// 其它 schema如 settlement不当作 present
return {
ok: true,
packet: presentFromPlainText(text, fallbackShell),
fallbackPlain: true,
};
}
const packet = normalizePresentPacket(row, fallbackShell);
if (
packet.blocks.body === undefined &&
!packet.blocks.monitor &&
!packet.blocks.header
) {
// JSON 但无区域 → 整段当 body 说明
if (!looksPresent) {
return {
ok: true,
packet: presentFromPlainText(text, fallbackShell),
fallbackPlain: true,
};
}
}
return { ok: true, packet, fallbackPlain: false };
} catch (err) {
return {
ok: false,
parseError: err instanceof Error ? err.message : "present JSON 解析失败",
packet: presentFromPlainText(text, fallbackShell),
fallbackPlain: true,
};
}
}
export function isPresentLikeObject(doc: unknown): boolean {
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
const row = doc as Record<string, unknown>;
if (row.schema === PRESENT_SCHEMA) return true;
if (isPresentShellId(row.shell) && row.blocks != null) return true;
return false;
}
/**
* 从「设计.正文组成」context-fragment 解析壳适配。
*/
export function parseShellAdaptationFromReplyFormat(
raw: string | undefined | null,
): ShellAdaptation | undefined {
if (!raw?.trim()) return undefined;
const jsonText =
extractJsonObjectText(raw) ?? (raw.trim().startsWith("{") ? raw.trim() : null);
if (!jsonText) return undefined;
try {
const doc = JSON.parse(jsonText) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return undefined;
const row = doc as Record<string, unknown>;
const body =
row. && typeof row. === "object" && !Array.isArray(row.)
? (row. as Record<string, unknown>)
: row;
const shellBox =
body. && typeof body. === "object" && !Array.isArray(body.)
? (body. as Record<string, unknown>)
: body.shell && typeof body.shell === "object"
? (body.shell as Record<string, unknown>)
: body;
const shellId = normalizePresentShellId(
shellBox.shell_id ?? shellBox.shellId ?? body.shell_id ?? body.shell,
"prose",
);
// 若正文组成完全没提壳,且也不是明确 prose 局,仍返回 prose 默认
const tweaksRaw =
shellBox. && typeof shellBox. === "object"
? (shellBox. as Record<string, unknown>)
: shellBox.tweaks && typeof shellBox.tweaks === "object"
? (shellBox.tweaks as Record<string, unknown>)
: {};
const base = defaultShellTweaks(shellId);
return {
shell_id: shellId,
why: asString(shellBox. ?? shellBox.why),
tweaks: {
tone_chrome:
asString(tweaksRaw.tone_chrome) ?? base.tone_chrome,
show_suggested_actions:
typeof tweaksRaw.show_suggested_actions === "boolean"
? tweaksRaw.show_suggested_actions
: base.show_suggested_actions,
block_labels:
asStringMap(tweaksRaw.block_labels) ?? base.block_labels,
empty_states:
asStringMap(tweaksRaw.empty_states) ?? base.empty_states,
},
};
} catch {
return undefined;
}
}
/** 区域是否应在该壳默认展示(无内容时可按 empty_states 显示) */
export function shellDefaultRegions(
shell: PresentShellId,
): PresentRegionId[] {
switch (shell) {
case "chat_monitor":
return ["monitor", "body", "footer"];
case "turn_panel":
return ["monitor", "header", "body", "footer", "aside"];
case "chapter_reader":
return ["header", "body", "aside", "footer"];
case "prose":
default:
return ["body"];
}
}
export function formatPresentBlockContent(
value: string | Record<string, unknown> | unknown[] | undefined,
): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value
.map((x) => (typeof x === "string" ? x : JSON.stringify(x)))
.filter(Boolean)
.join("\n");
}
// 监控键值 → 短行
return Object.entries(value)
.map(([k, v]) => `${k}${typeof v === "string" ? v : JSON.stringify(v)}`)
.join("\n");
}

View File

@@ -2,6 +2,10 @@
* 主世界层 → 叙事转述的裁决包settlement / direction packet
* 存黑板 tag运行.本轮.裁决
*/
import {
createTableFromValues,
type TableDoc,
} from "../blackboard/table-cells.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
export const SETTLEMENT_SCHEMA = "settlement.v1" as const;
@@ -229,3 +233,37 @@ export function isSettlementLikeObject(doc: unknown): boolean {
for (const k of keys) if (k in row) hits += 1;
return hits >= 2;
}
/**
* 将 variable_changes 编成 TableDoc patch供合并进「变量.当前」。
* - 有 `to` → 写绝对值
* - 仅有 `delta` → 在现有数值上加减(无现值则从 0
*/
export function settlementChangesToTablePatch(
changes: SettlementVariableChange[],
current: TableDoc | null,
): TableDoc | null {
if (!changes.length) return null;
const byKey = new Map((current?.rows ?? []).map((r) => [r.key, r]));
const values: Record<string, unknown> = {};
const meta: Record<string, { note?: string }> = {};
for (const c of changes) {
const key = c.key.trim();
if (!key) continue;
const existing = byKey.get(key);
if (c.to !== undefined) {
values[key] = c.to;
} else if (c.delta != null) {
const cur = existing?.value;
const n = typeof cur === "number" ? cur : Number(cur);
values[key] = Number.isFinite(n) ? (n as number) + c.delta : c.delta;
} else {
continue;
}
if (c.note) meta[key] = { note: c.note };
}
if (!Object.keys(values).length) return null;
return createTableFromValues(values, "system", meta);
}

View File

@@ -212,6 +212,23 @@ const DEFAULT_WORKER_CONTRACTS: Record<
string,
{ context: { static: string[]; dynamic: string[] }; outputs: string[] }
> = {
auditor: {
context: {
static: [
"设计.worker集",
"设计.变量设计与更新规则",
"设计.生成规则",
"设计.变量控制上下文",
],
dynamic: [
"用户.最新输入",
"变量.当前",
"上下文.旁观.状态摘要",
"运行.本轮.工单",
],
},
outputs: ["运行.本轮.旁观"],
},
"world-simulator": {
context: {
static: [
@@ -226,6 +243,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"用户.最新输入",
"上下文.角色态度",
"大纲.当前章",
"运行.本轮.旁观",
],
},
outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
@@ -243,8 +261,10 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"设计.worker集",
"设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.正文组成",
"设计.监控栏",
],
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
dynamic: ["运行.本轮.裁决", "用户.最新输入", "变量.当前"],
},
outputs: ["输出.用户展示"],
},
@@ -289,6 +309,7 @@ const WORKER_ROLE_LABELS: Record<string, string> = {
core: "核心",
auxiliary: "辅助",
transcription: "转述",
auditor: "旁观维护",
gm: "主世界层",
narrator: "叙事转述",
perspective: "角色视角",
@@ -746,7 +767,10 @@ export function formatWorkerSetForUser(
? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({
id,
label: PLAY_SLOT_META[id].label,
enabled: Boolean(parsed.play_slots![id]),
enabled:
id === "auditor"
? parsed.play_slots!.auditor !== false
: Boolean(parsed.play_slots![id]),
ref: refForSlot(parsed.play_slots!, id),
}))
: undefined,