| 1 | import { formatLayoutIntentPrompt } from '@shared/layout-intent' |
| 2 | import type { DesignContract } from '@shared/generation' |
| 3 | import type { SessionDeckGenerationContext } from '../../agent/types' |
| 4 | import { requireSlideSize, type SlideSizePreset } from '@shared/slide-size' |
| 5 | import { |
| 6 | buildCanvasScenarioContentRules, |
| 7 | buildCanvasScenarioDeliveryGuard, |
| 8 | buildCanvasScenarioExpansionRules |
| 9 | } from './canvas-scenario' |
| 10 | import { |
| 11 | CHART_SKILL_NAME, |
| 12 | DATA_ANIM_SKILL_NAME, |
| 13 | LAYOUT_SKILL_NAME, |
| 14 | RED_LAYOUT_SKILL_NAME, |
| 15 | SOURCE_READING_SKILL_NAME, |
| 16 | SQUARE_1_1_LAYOUT_SKILL_NAME, |
| 17 | STANDARD_4_3_LAYOUT_SKILL_NAME, |
| 18 | VERTICAL_3_4_LAYOUT_SKILL_NAME, |
| 19 | VERTICAL_9_16_LAYOUT_SKILL_NAME, |
| 20 | formatSkillUsageRequirement, |
| 21 | resolveLayoutSkillName, |
| 22 | type RequiredProductSkillName, |
| 23 | } from '../../../product-skills/contract' |
| 24 | |
| 25 | function describeLayoutSkill(skillName: RequiredProductSkillName): string { |
| 26 | if (skillName === LAYOUT_SKILL_NAME) return '16:9 PPT layout' |
| 27 | if (skillName === VERTICAL_9_16_LAYOUT_SKILL_NAME) return '9:16 vertical layout' |
| 28 | if (skillName === STANDARD_4_3_LAYOUT_SKILL_NAME) return '4:3 standard layout' |
| 29 | if (skillName === SQUARE_1_1_LAYOUT_SKILL_NAME) return '1:1 square card layout' |
| 30 | if (skillName === VERTICAL_3_4_LAYOUT_SKILL_NAME) return '3:4 vertical poster layout' |
| 31 | if (skillName === RED_LAYOUT_SKILL_NAME) return '小红书图文笔记 layout' |
| 32 | return '非 16:9 画布 layout' |
| 33 | } |
| 34 | |
| 35 | export function buildPageSemanticStructure(input: SlideSizePreset): string { |
| 36 | const layoutSkillName = resolveLayoutSkillName(input) |
| 37 | return [ |
| 38 | '## 页面语义结构', |
| 39 | `- The layout source of truth for this canvas is the ${describeLayoutSkill(layoutSkillName)} skill ${layoutSkillName}. Before creating a slide, choosing a composition, or repairing overflow/collision: ${formatSkillUsageRequirement(layoutSkillName)}`, |
| 40 | '- 写每页 HTML 前,先像设计师想三件事:① 这页的**焦点**是什么(观众先看哪)?② 其余元素怎么摆才**平衡**(视觉重量不偏一边、不堆一角)?③ 每处留白是**刻意的 framing 还是不小心的空缺**——不小心的空缺就重排。想清楚再写。', |
| 41 | '- If the task is a tiny text/style edit that does not affect layout, do not read the full layout reference.', |
| 42 | '- 直接输出完整创意页面片段;系统会自动包裹 section[data-page-scaffold]、main[data-role="content"] 和标准 page frame。', |
| 43 | '- 如果页面有明确标题,可以给第一个标题元素添加 data-role="title";没有传统标题时不要为了校验硬造标题。', |
| 44 | '- 主动添加 data-block-id 时保持页面内唯一(kebab-case:metric-1、summary、chart-main);未添加时系统会自动补齐。' |
| 45 | ].join('\n') |
| 46 | } |
| 47 | |
| 48 | export const CONTENT_LANGUAGE_RULES = [ |
| 49 | '## Content language', |
| 50 | '- The language of these instructions is not the output language. Do not imitate the prompt language.', |
| 51 | '- If the user explicitly requests a language, use that language.', |
| 52 | "- Otherwise, use the dominant language of the user's latest request and provided source materials.", |
| 53 | '- If source materials are primarily English, write slide titles, body text, outlines, and user-facing summaries in English. Do not translate them into Chinese.', |
| 54 | '- If source materials are primarily Chinese, write slide titles, body text, outlines, and user-facing summaries in Chinese.', |
| 55 | '- For mixed-language materials, prefer the latest user instruction language.', |
| 56 | '- Preserve proper nouns, brand names, technical terms, quoted source text, and metrics when appropriate.' |
| 57 | ].join('\n') |
| 58 | |
| 59 | export const SOURCE_UNSUPPORTED_CLAIMS = |
| 60 | 'exact facts, metrics, dates, system names, status claims, examples, risks, decisions, or conclusions' |
| 61 | |
| 62 | export const SOURCE_MATERIAL_PLANNING_RULES = [ |
| 63 | '## Source-grounded planning rules', |
| 64 | '- Apply these rules only when source documents, parsed reference-document outlines, or source-material briefs are present.', |
| 65 | '- Treat source materials as the primary content authority. Stay source-grounded and avoid creative drift.', |
| 66 | `- Every source-backed slide title and key point must be traceable to the user requirements or source materials. Do not invent ${SOURCE_UNSUPPORTED_CLAIMS} not present in the source.`, |
| 67 | '- Preserve source order, hierarchy, terminology, and stated conclusions unless the user explicitly asks for a different structure.', |
| 68 | '- Dense source tables/lists are evidence, not a slide checklist. Plan them as focused PPT pages: one main message per page, grouped support, and a clear reading path; split into multiple slides when one page would become a data dump.', |
| 69 | '- If the source material does not naturally fill the target slide count, split source-backed sections into finer-grained slides and deepen each slide from the available material: background/context already implied by the source, comparison dimensions, cause/effect, mechanism, implications, "so what", evidence groupings, or visual explanation modules.', |
| 70 | '- Do not add generic agenda, data overview, synthesis, next steps, outlook, background, summary, or transition slides unless the user request or source material explicitly contains them.' |
| 71 | ].join('\n') |
| 72 | |
| 73 | export const SOURCE_DOCUMENT_LOCATE_THEN_READ_RULE = [ |
| 74 | `- Before using source documents: ${formatSkillUsageRequirement(SOURCE_READING_SKILL_NAME)}`, |
| 75 | '- No retrieved snippets matched. Locate relevant source passages before writing; do not write the slide from the outline alone. Then expand thin pages with analysis derived from the source — grounding forbids invented facts, not analytical structure.' |
| 76 | ].join('\n') |
| 77 | |
| 78 | export const SOURCE_DOCUMENT_READ_STRATEGY = [ |
| 79 | `- Before using source documents: ${formatSkillUsageRequirement(SOURCE_READING_SKILL_NAME)}`, |
| 80 | '- Treat retrieved snippets as an index into the source, not as final evidence. Grounding forbids inventing facts the source lacks — not the analytical expansion (comparison, implications, so-what) that fills a thin page from inspected material.' |
| 81 | ].join('\n') |
| 82 | |
| 83 | export const SOURCE_DOCUMENT_FACT_RULE = [ |
| 84 | `- Do not invent ${SOURCE_UNSUPPORTED_CLAIMS} not present in the source document.` |
| 85 | ].join('\n') |
| 86 | |
| 87 | export const SOURCE_GROUNDED_EXPANSION_RULES = [ |
| 88 | '- When source documents are present, expansion must be source-grounded: use the inspected material as the authority for enrichment and summarization.', |
| 89 | '- First judge whether the inspected reference material is already enough for a readable slide. If it is enough, do not enrich or add support modules; edit, group, and choose the clearest PPT expression.', |
| 90 | '- If the reference material for a slide is truly thin, you should actively enrich the slide from the material instead of leaving it sparse.', |
| 91 | '- Expand by adding source-grounded analysis structure: context implied by the source, comparison dimensions, cause/effect, mechanism, implications, "so what", evidence grouping, annotations, or concise explanatory modules.', |
| 92 | '- If the inspected source material is already dense, source-grounded does not mean exhaustive: summarize, group, and choose the clearest PPT expression instead of reproducing every row, metric, or bullet as visible modules.', |
| 93 | '- This is expansion of reasoning and presentation structure, not invention of new evidence: do not fabricate unsupported exact facts, metrics, dates, cases, quotes, source names, risks, decisions, or conclusions.' |
| 94 | ].join('\n') |
| 95 | |
| 96 | export { buildCanvasScenarioContentRules, buildCanvasScenarioDeliveryGuard, buildCanvasScenarioExpansionRules } |
| 97 | |
| 98 | export const STABLE_HTML_FRAGMENT_PROTOCOL = [ |
| 99 | '## HTML 片段协议', |
| 100 | '- 只输出正文片段(一个 `<div>` 根节点);section[data-page-scaffold]、main[data-role="content"]、data-block-id、page frame 由工具自动补,不要手写。', |
| 101 | '- 片段里不要出现 `<!doctype>/<html>/<head>/<body>`、`<script src=>`、CDN/远程资源,以及系统骨架类 .ppt-page-root/.ppt-page-content/.ppt-page-fit-scope/data-ppt-guard-root(class、CSS、注释里都算)。', |
| 102 | '- 结构扁平:用 Tailwind 类替代多层 wrapper,目标 3 层、不超 4 层。', |
| 103 | '- 标签全部成对闭合、末尾完整——这是最常见的失败,写完自检每个 <div>/<section>/<ul>/<li>/<table>。' |
| 104 | ].join('\n') |
| 105 | |
| 106 | export function buildCanvasConstraints(input: SlideSizePreset): string { |
| 107 | const slideSize = requireSlideSize(input) |
| 108 | const layoutSkillName = resolveLayoutSkillName(slideSize) |
| 109 | const isPortrait = slideSize.height > slideSize.width |
| 110 | const ratioGuidance = |
| 111 | slideSize.id === 'xiaohongshu-note' |
| 112 | ? `- 小红书画布按图文笔记组织:强化标题、视觉锚点与信息层级,优先上下模块栈和分段叙事;不要套用 16:9 PPT 骨架,必须使用 ${RED_LAYOUT_SKILL_NAME}。` |
| 113 | : isPortrait |
| 114 | ? `- 这是非 PPT 竖版画布:优先顶部标题 + 中部主体 + 底部结论的纵向叙事或上下模块栈,不要照搬横向三列;必须使用 ${layoutSkillName}。` |
| 115 | : slideSize.id === 'square-1-1' |
| 116 | ? `- 这是 1:1 方形画布:围绕中心焦点、四象限/上下两段/中心主体 + 周边支撑组织,避免套用宽屏 PPT 骨架;必须使用 ${layoutSkillName}。` |
| 117 | : slideSize.id === 'standard-4-3' |
| 118 | ? `- 这是非 16:9 的 4:3 画布:减少横向密集信息,图表和卡片按更方正的区域组织;不要套用 16:9 PPT skeleton,必须使用 ${layoutSkillName}。` |
| 119 | : '- 这是横版画布:可以使用左右分栏、横向时间线和宽表格,但仍需围绕单一视觉焦点。' |
| 120 | |
| 121 | return [ |
| 122 | `## 画布与技法(${slideSize.label} / ${slideSize.width}×${slideSize.height})`, |
| 123 | `- 版式细节(密度、pattern、高度预算、防重叠)在 ${describeLayoutSkill(layoutSkillName)} skill ${layoutSkillName},写前先读:${formatSkillUsageRequirement(layoutSkillName)}`, |
| 124 | `- 根容器不带默认 padding,用 Tailwind grid/flex;背景可铺满 ${slideSize.width}×${slideSize.height},正文四边留 24-40px。`, |
| 125 | `- 已有内容在画布上占稳、对齐、按构图需要合理伸展,让版面协调——目标是平衡,不是把每寸塞满。对应逻辑画布宽 ${slideSize.width}px、高 ${slideSize.height}px;不为填满而新增卡片/注释/第二行模块,也不能溢出画布。`, |
| 126 | ratioGuidance, |
| 127 | '- 密度由内容决定:氛围/叙事页低密度,多数页中密度,表格/多指标对比才高密度;内容够了就不扩展,只压缩、归并、换表达。', |
| 128 | `- 内容过多先总结再布局:如果标题 + 图表/表格/列表/卡片会超出 ${slideSize.height}px 或显得过密,必须先重写信息架构(主旨、分组、优先级、紧凑表达)再写 HTML;不要靠缩小字号、增加卡片、堆更多行或把所有事实等权上屏来硬塞。`, |
| 129 | '- 图表高度:注释里写 `@ppt-chart-height=N`,且 N 与 class 的 `h-[Npx]` 一致(写 560 就配 h-[560px])。', |
| 130 | '- 字号下限:正文、普通标签和卡片说明不小于 `text-lg`(18px);任何标题不小于 `text-2xl`(24px),标题仍可按层级放大,最大 `text-5xl`(48px)。注释、页脚、页码、来源/出处等辅助信息可以小于 18px,但不得小于 12px;使用 `<footer>` / `<small>` / `<figcaption>`,或显式标记 `data-ppt-text-role="auxiliary"`。空间紧时调密度与层级,不靠缩小正文或标题硬塞;用 grid/flex 解决,不用 100vw/100vh/w-screen/h-screen/iframe。' |
| 131 | ].join('\n') |
| 132 | } |
| 133 | |
| 134 | export function buildLayoutCollisionRules(input: SlideSizePreset): string { |
| 135 | const layoutSkillName = resolveLayoutSkillName(input) |
| 136 | return [ |
| 137 | '## 布局防重叠', |
| 138 | `- Full collision guide for this canvas is in the ${describeLayoutSkill(layoutSkillName)} skill ${layoutSkillName}. ${formatSkillUsageRequirement(layoutSkillName)}`, |
| 139 | '- 正文内容用 grid/flex 正常文档流。absolute/fixed 仅用于背景装饰、连接线。正文卡片不得用 absolute/fixed。' |
| 140 | ].join('\n') |
| 141 | } |
| 142 | |
| 143 | export const FRONTEND_CAPABILITIES = [ |
| 144 | '## Runtime capability contract', |
| 145 | 'Available in every /<pageId>.html:', |
| 146 | '- Tailwind CSS, anime.js, Chart.js, ppt-runtime.js, and KaTeX are already loaded from local assets.', |
| 147 | '- Do not add CDN links, remote scripts, duplicate runtime tags, or iframe content.', |
| 148 | '', |
| 149 | 'Fonts:', |
| 150 | '- Use var(--ppt-title-font) for titles and var(--ppt-body-font) for body text.', |
| 151 | '- Do not declare @font-face or import external font/icon libraries.', |
| 152 | '', |
| 153 | 'Charts:', |
| 154 | `- Chart details are in the skill ${CHART_SKILL_NAME}. ${formatSkillUsageRequirement(CHART_SKILL_NAME)}`, |
| 155 | '- Wrap in document.addEventListener("DOMContentLoaded", function() { PPT.createChart(...) }). Do not use ppt-ready/ppt-rendered or other custom events.', |
| 156 | '', |
| 157 | 'Animations:', |
| 158 | `- Animation rules are in the skill ${DATA_ANIM_SKILL_NAME}. ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`, |
| 159 | '- Prefer `data-anim-stagger="N"` over embedding `stagger(N)` in delay strings for new content.', |
| 160 | '- Prefer `data-anim-sequence="with|after"` over overloading `data-anim-trigger` when you only need load-order composition.', |
| 161 | '- Use `data-anim-click-group="name"` only for contiguous click-triggered elements that should reveal on the same click step.', |
| 162 | '- Prefer bounded emphasis labels such as `pulse-soft|pulse|pulse-strong` and `grow-shrink-soft|grow-shrink|grow-shrink-strong` over ad hoc scale choreography.', |
| 163 | '- Use `data-anim="path"` only with an inline linear path string such as `M 0 0 L 120 30`; do not use selector-based SVG path choreography in normal generated pages.', |
| 164 | '- Do not use `data-anim-easing`, `data-anim-repeat`, or `data-anim-direction` in normal generated pages; those are runtime-only compatibility knobs and are not preserved by the editable PPTX lane.', |
| 165 | '- Keep the editable lane focused on whole-element motion. Do not use split-text/per-letter effects, SVG morph/draw helpers, or arbitrary path choreography in normal generated pages.', |
| 166 | '- Treat those richer anime capabilities as preview-only concepts until a dedicated non-editable lane exists.', |
| 167 | '', |
| 168 | 'Validation:', |
| 169 | '- Use \\( \\) or $$ $$ for math; do not use single-dollar inline math.' |
| 170 | ].join('\n') |
| 171 | |
| 172 | export const CONTENT_WRITING_RULES = [ |
| 173 | '## 内容与视觉', |
| 174 | '- 用真实文案与数据填模块;少用 emoji/贴纸装饰。', |
| 175 | '- 布局靠 grid/flex 文档流:items-center/justify-* 的父节点配 flex 或 grid,正文卡片留在文档流里,absolute/fixed 只给背景装饰与连接线。', |
| 176 | '- 装饰块保持扁平(单层绝对定位 div / 几个并列 div / 一个 SVG)。', |
| 177 | '- 模块占稳各自位置、彼此对齐,形成均衡版面与干净间距——不堆在顶部,也不塞到溢出。', |
| 178 | '- 内容超载时按这个优先级解决:(1) 总结精简——用更少的字表达同等信息量(长描述压成短句、词组、单一数据点),不丢信息只去水分 → (2) 合并/归并相关点为一个带共享标签的块 → (3) 把长清单重写成一个 hero 指标 + 一句解释 → (4) 换更紧凑的 pattern(如对比矩阵/ranking/2x2)。绝不靠缩字号到下限以下、也不靠超出画布高度来解决——竖版/小红书/方图本来就是低密度载体,内容多时模型必须更狠地总结精简,不是把 16:9 的信息量硬塞进来。' |
| 179 | ].join('\n') |
| 180 | |
| 181 | export const STYLE_FIDELITY_RULES = [ |
| 182 | '## 尺寸布局与风格合成闸门', |
| 183 | '- 当前画布尺寸与已注入的 layout skill/catalog 是页面结构的唯一来源:由它们决定阅读路径、分区、列数、密度和空间预算。', |
| 184 | '- 当前风格规则是视觉语言的唯一来源:颜色、字体气质、圆角/线条/阴影、背景、装饰符号、图表质感都必须从当前 style 与 design contract 派生。', |
| 185 | '- 先依据 layout skill/catalog 选择适合当前尺寸的页面结构,再把 style 的视觉语言应用到这些结构区域;layout 不提供新的审美,style 不替代尺寸结构。', |
| 186 | '- style 中出现的左右分栏、固定列数、横向色带或固定位置只表达视觉构图倾向;必须在当前尺寸与 layout pattern 中重新表达,不能直接作为页面骨架。', |
| 187 | '- size-aware layoutMotif 负责连接当前尺寸与 style 的构图气质,但不能覆盖当前画布尺寸或 layout skill/catalog。', |
| 188 | '- 单页生成也必须像整套 deck 一样遵守当前 style。可以变化构图和节奏,但不能自创无关配色、组件语言、插画/装饰风格或字体气质。', |
| 189 | '- 写入前做一次 style check:如果把当前 style 名字遮住,页面仍应能从配色、形状、字体和装饰语言上看出属于同一套演示。' |
| 190 | ].join('\n') |
| 191 | |
| 192 | export function resolveContextStylePrompt(context: SessionDeckGenerationContext): { |
| 193 | presetLabel: string |
| 194 | presetId: string |
| 195 | stylePrompt: string |
| 196 | } { |
| 197 | const presetLabel = context.styleName?.trim() || context.styleKey?.trim() || context.styleId || 'Session style' |
| 198 | const presetId = context.styleKey?.trim() || context.styleId || 'session-style' |
| 199 | const stylePrompt = context.styleSkillPrompt?.trim() |
| 200 | if (!stylePrompt) { |
| 201 | throw new Error('Session style snapshot is missing styleSkillPrompt.') |
| 202 | } |
| 203 | return { |
| 204 | presetLabel, |
| 205 | presetId, |
| 206 | stylePrompt |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | export function buildOutlinePageList(context: SessionDeckGenerationContext): string { |
| 211 | return context.outlineItems |
| 212 | .map((item, i) => { |
| 213 | const layoutIntent = item.layoutIntent |
| 214 | ? `\n ${formatLayoutIntentPrompt(item.layoutIntent).replace(/\n/g, '\n ')}` |
| 215 | : '' |
| 216 | const layoutMaster = |
| 217 | item.layoutId && item.layoutPrompt |
| 218 | ? `\n ${item.layoutPrompt.replace(/\n/g, '\n ')}` |
| 219 | : '' |
| 220 | return `${i + 1}. ${item.title}\n Content points: ${item.contentOutline}${layoutIntent}${layoutMaster}` |
| 221 | }) |
| 222 | .join('\n') |
| 223 | } |
| 224 | |
| 225 | export function formatDesignContract(contract?: DesignContract): string { |
| 226 | if (!contract) return 'Not provided. Keep pages visually consistent according to the style rules.' |
| 227 | const lines = [ |
| 228 | '- Treat this as a flexible visual contract, not a fixed template. Preserve coherence while varying composition, density, and emphasis per slide.', |
| 229 | `- Visual theme: ${contract.theme}`, |
| 230 | `- Canvas background: ${contract.background}`, |
| 231 | `- Palette: ${contract.palette.join(', ')}`, |
| 232 | `- Title style: ${contract.titleStyle}`, |
| 233 | `- Size-adapted composition motif: ${contract.layoutMotif}`, |
| 234 | '- Apply this motif within the current canvas layout rules. Keep pages varied within the motif instead of repeating one template.', |
| 235 | `- Chart style: ${contract.chartStyle}`, |
| 236 | `- Shape language: ${contract.shapeLanguage}` |
| 237 | ] |
| 238 | lines.push( |
| 239 | `- Title font: ${contract.titleFont} (use var(--ppt-title-font) for titles)`, |
| 240 | `- Body font: ${contract.bodyFont} (use var(--ppt-body-font) for body)` |
| 241 | ) |
| 242 | return lines.join('\n') |
| 243 | } |
| 244 |