Files
writing-agent/src/skills/present-packet.ts
T
chenmoranzhiandCursor d0a48f9431 把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:47:58 +08:00

510 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 游玩呈现包 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",
"spotlight",
"turn_panel",
"split_board",
"choice_dock",
"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 PresentBlockValue = string | Record<string, unknown> | unknown[];
/** 已知壳区域以外的键来自本局正文组成数据契约,必须原样保留。 */
export type PresentBlocks = Partial<Record<PresentRegionId, PresentBlockValue>> &
Record<string, PresentBlockValue | undefined>;
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" || shell === "choice_dock",
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 asPresentBlockValue(value: unknown): PresentBlockValue | undefined {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value;
if (value && typeof value === "object") return value as Record<string, unknown>;
return undefined;
}
function normalizeBlocks(raw: unknown): PresentBlocks {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const row = raw as Record<string, unknown>;
const out: PresentBlocks = {};
for (const [key, value] of Object.entries(row)) {
if (key === "正文" || key === "监控") continue;
const block = asPresentBlockValue(value);
if (block !== undefined) out[key] = block;
}
if (out.body === undefined && row.正文 !== undefined) {
const body = asPresentBlockValue(row.正文);
if (body !== undefined) out.body = body;
}
if (out.monitor === undefined && row.监控 !== undefined) {
const monitor = asPresentBlockValue(row.监控);
if (monitor !== undefined) out.monitor = monitor;
}
if (typeof out.body === "string") {
out.body = stripPresentSourceFences(out.body);
}
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: stripPresentSourceFences(text) },
};
}
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
export function stripPresentSourceFences(raw: string): string {
let s = raw.trim();
for (let i = 0; i < 3; i += 1) {
const next = s
.replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "")
.replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "")
.trim();
if (next === s) break;
s = next;
}
return s;
}
export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"用户可见正文(主世界层:纯 Markdown;转述:present.v1 的 blocks.body,或纯 Markdown 主读)默认 **1000~2000 字**(按汉字计);以「设计.监控栏/回复呈现」已钉字数为准。",
"采用完整长自然段叙述,不要单句成段。",
"不要写成几句气泡短信就结束,除非用户明确要求极短。",
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
/**
* 游玩期走 present.v1 结构化投递的执行单元(转述 / 回合陈述)。
* 主世界层不在此列:始终自由写 Markdown 正文。
*/
export function isPlayPresentWorker(workerId: string): boolean {
const id = workerId.trim();
return id === "narrator" || id === "round-present";
}
/**
* 本轮最终用户可见正文的执行单元(写入对话.历史 / 空产物兜底)。
* 无转述:主世界层;有转述:转述(主世界草稿可被覆盖,不进历史)。
*/
export function isPlayFinalVisibleWorker(
workerId: string,
opts?: { narratorEnabled?: boolean },
): boolean {
const id = workerId.trim();
if (id === "narrator" || id === "round-present") return true;
if (id === "world-simulator" && opts?.narratorEnabled !== true) return true;
return false;
}
/** 游玩主世界层:自由 Markdown 正文(程序可事后落入 present 壳,模型不交 JSON) */
export function isPlayGmBodyWorker(workerId: string): boolean {
return workerId.trim() === "world-simulator";
}
/** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */
export const PRESENT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["schema", "shell", "blocks", "meta"],
properties: {
schema: { type: "string" },
shell: { type: "string" },
blocks: {
type: "object",
additionalProperties: false,
required: ["body", "monitor", "header", "footer", "aside"],
properties: {
body: { type: "string" },
monitor: { type: "string" },
header: { type: "string" },
footer: { type: "string" },
aside: { type: "string" },
},
},
meta: {
type: "object",
additionalProperties: false,
required: ["suggested_actions"],
properties: {
suggested_actions: {
type: "array",
items: { type: "string" },
},
},
},
},
};
/** 游玩主世界层:直接写 Markdown,禁止 JSON 裁决包 */
export function playGmBodyOutputInstruction(): string {
return `
---
## 运行时输出协议(主世界正文)
直接输出本轮 **Markdown / 自然语言故事正文**(写入「输出.用户展示」)。
不要输出 JSON、不要交 settlement/裁决包、不要套 present.v1 外壳。
重心在场面推进与人物扮演;残稿也可以交,有转述时会再处理。
不要写追问、askUser、自评或「可验收产物」。`;
}
/** 游玩转述:按探测到的 structured 投递写出 present.v1,不要创作追问 */
export function playPresentOutputInstruction(shell: PresentShellId): string {
return `
---
## 运行时输出协议(游玩终稿)
本步读主世界层已写正文,产出给用户看的终稿。程序按已探测的模型能力用 schema / tool / json_object 投递,你输出 **一个 present.v1 对象**:
{"schema":"present.v1","shell":"${shell}","blocks":{"body":"用户可读正文","monitor":"","header":"","footer":"","aside":""},"meta":{"suggested_actions":[]}}
\`shell\` 必须是 \`${shell}\`。只填该壳已有区域,没有的键留空字符串。
prose 壳也可直接输出 Markdown,程序会落入 body。
可补全残缺叙述与版式,但不得改剧情事实。不要写追问、askUser、自评或「可验收产物」。`;
}
export function stringifyPresentPacket(packet: PresentPacket): string {
return JSON.stringify(packet);
}
export function presentOutputFromFallback(
text: string,
shell: PresentShellId = "prose",
): string {
const body = stripPresentSourceFences(text).trim() || "(本轮场面未写完)";
return stringifyPresentPacket(presentFromPlainText(body, shell));
}
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
): PresentPacketView {
const text = stripPresentSourceFences(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;
if (isPresentShellId(row.shell_id) && row.blocks != null) return true;
return false;
}
/** 开场/卡片预览:取出 present.v1 主读正文;散文原样返回。 */
export function presentReadablePlain(raw: string | null | undefined): string {
const text = (raw ?? "").trim();
if (!text) return "";
const view = parsePresentPacket(text);
const body = view.packet.blocks.body;
if (typeof body === "string" && body.trim()) return body.trim();
if (body && typeof body === "object") {
try {
return JSON.stringify(body);
} catch {
return text;
}
}
const header = view.packet.blocks.header;
if (typeof header === "string" && header.trim()) return header.trim();
return text;
}
/**
* 从「设计.正文组成」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 "spotlight":
return ["monitor", "body", "footer"];
case "turn_panel":
return ["monitor", "header", "body", "aside", "footer"];
case "split_board":
return ["header", "body", "aside", "footer"];
case "choice_dock":
return ["monitor", "body", "footer"];
case "chapter_reader":
return ["header", "body", "aside", "footer"];
case "prose":
default:
return ["body", "footer"];
}
}
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");
}