/** * 游玩呈现包 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; empty_states?: Record; }; export type ShellAdaptation = { shell_id: PresentShellId; why?: string; tweaks: PresentShellTweaks; }; export type PresentBlocks = Partial< Record | 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(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 | undefined { if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) { 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; 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"]; } if (typeof out.body === "string") { out.body = stripPresentSourceFences(out.body); } return out; } export function normalizePresentPacket( row: Record, 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).suggested_actions ?? (row.meta as Record).建议行动 : row.suggested_actions, ), hide: asStringList( row.meta && typeof row.meta === "object" ? (row.meta as Record).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 = [ "用户可见正文(present.v1 的 blocks.body,或纯 Markdown 主读)默认 **500~2000 字**(按汉字计)。", "不要写成几句气泡短信就结束,除非用户明确要求极短。", "禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。", ].join("\n"); /** 游玩期把裁决写成用户可见终稿的执行单元 */ export function isPlayPresentWorker(workerId: string): boolean { const id = workerId.trim(); return id === "narrator" || id === "round-present"; } /** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */ export const PRESENT_JSON_SCHEMA: Record = { 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" }, }, }, }, }, }; /** 游玩转述:按探测到的 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; // 明确 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; 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; const body = row.正文 && typeof row.正文 === "object" && !Array.isArray(row.正文) ? (row.正文 as Record) : row; const shellBox = body.呈现壳 && typeof body.呈现壳 === "object" && !Array.isArray(body.呈现壳) ? (body.呈现壳 as Record) : body.shell && typeof body.shell === "object" ? (body.shell as Record) : 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) : shellBox.tweaks && typeof shellBox.tweaks === "object" ? (shellBox.tweaks as Record) : {}; 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 | 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"); }