/** * 游玩呈现壳渲染(P2 最小适配) * 契约:docs/play-presentation-shells.md · present.v1 */ export const PRESENT_SHELL_IDS = [ "prose", "chat_monitor", "turn_panel", "chapter_reader", ]; const SHELL_LABELS = { prose: "纯散文", chat_monitor: "对话+监控", turn_panel: "回合面板", chapter_reader: "章节阅读", }; const REGION_LABELS = { monitor: "监控", header: "抬头", body: "正文", footer: "文末", aside: "侧栏", hidden: "隐藏", }; function isPresentShellId(v) { return typeof v === "string" && PRESENT_SHELL_IDS.includes(v); } function shellDefaultRegions(shell) { 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"]; default: return ["body"]; } } function formatBlockContent(value) { 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"); } if (typeof value === "object") { return Object.entries(value) .map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`) .join("\n"); } return String(value); } function normalizeBlocks(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const out = {}; for (const k of ["monitor", "header", "body", "footer", "aside", "hidden"]) { if (raw[k] !== undefined) out[k] = raw[k]; } if (out.body === undefined && raw.正文 !== undefined) out.body = raw.正文; if (out.monitor === undefined && raw.监控 !== undefined) out.monitor = raw.监控; return out; } /** * @returns {{ ok: boolean, packet: object, fallbackPlain: boolean } | null} * null = 不是 present 包(交给其它渲染器) */ export function parsePresentDoc(doc, fallbackShell = "prose") { if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; const looks = doc.schema === "present.v1" || (isPresentShellId(doc.shell) && doc.blocks != null) || (isPresentShellId(doc.shell_id) && doc.blocks != null); if (!looks) return null; const shell = isPresentShellId(doc.shell) ? doc.shell : isPresentShellId(doc.shell_id) ? doc.shell_id : fallbackShell; const meta = doc.meta && typeof doc.meta === "object" && !Array.isArray(doc.meta) ? doc.meta : {}; return { ok: true, fallbackPlain: false, packet: { schema: "present.v1", shell, blocks: normalizeBlocks(doc.blocks ?? doc.区域), meta: { suggested_actions: Array.isArray(meta.suggested_actions) ? meta.suggested_actions.filter((x) => typeof x === "string") : Array.isArray(doc.suggested_actions) ? doc.suggested_actions.filter((x) => typeof x === "string") : [], }, }, }; } /** 纯文本 → prose 包(供调用方在非 JSON 时使用) */ export function presentFromPlain(text, shell = "prose") { return { schema: "present.v1", shell: isPresentShellId(shell) ? shell : "prose", blocks: { body: text ?? "" }, meta: { suggested_actions: [] }, }; } /** * @param {object} packet present.v1 * @param {(s: string) => string} esc * @param {{ tweaks?: object }} [opts] */ export function renderPresentShellHtml(packet, esc, opts = {}) { if (!packet || typeof packet !== "object") return ""; const shell = isPresentShellId(packet.shell) ? packet.shell : "prose"; const tweaks = opts.tweaks ?? {}; const labels = { ...(tweaks.block_labels && typeof tweaks.block_labels === "object" ? tweaks.block_labels : {}), }; const emptyStates = tweaks.empty_states && typeof tweaks.empty_states === "object" ? tweaks.empty_states : {}; const showActions = typeof tweaks.show_suggested_actions === "boolean" ? tweaks.show_suggested_actions : shell === "turn_panel"; const tone = typeof tweaks.tone_chrome === "string" && tweaks.tone_chrome.trim() ? tweaks.tone_chrome.trim() : "default"; const blocks = packet.blocks ?? {}; const regions = shellDefaultRegions(shell); const parts = []; for (const region of regions) { if (region === "hidden") continue; let text = formatBlockContent(blocks[region]).trim(); if (!text && emptyStates[region]) text = String(emptyStates[region]); if (!text && region !== "body") continue; if (!text && region === "body") text = "(暂无正文)"; const title = labels[region] || labels[REGION_LABELS[region]] || REGION_LABELS[region] || region; const isBody = region === "body"; parts.push(`
${isBody && shell === "prose" ? "" : `

${esc(title)}

`}
${esc(text).replace(/\n/g, "
")}
`); } const actions = showActions ? packet.meta?.suggested_actions ?? [] : []; if (actions.length) { parts.push(`

建议行动

`); } const shellLabel = SHELL_LABELS[shell] || shell; return `
${esc(shellLabel)}
${parts.join("")}
`; }