返回 oh-my-ppt
shared.ts
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
244 lines TYPESCRIPT