返回 oh-my-ppt
generation.ts
根目录 / src / shared / generation.ts
1 import type { LayoutIntent } from './layout-intent'
2
3 /** A generated slide's semantic plan, shared by planning, generation, and page tools. */
4 export interface OutlineItem {
5 title: string
6 contentOutline: string
7 layoutIntent?: LayoutIntent
8 /** M3a resolves a session layout master into a flexible generation constraint. */
9 layoutId?: string
10 layoutPrompt?: string
11 }
12
13 /** Deck-level visual rules persisted with a session and applied to every generated page. */
14 export interface DesignContract {
15 theme: string
16 background: string
17 palette: string[]
18 titleStyle: string
19 layoutMotif: string
20 chartStyle: string
21 shapeLanguage: string
22 titleFont: string
23 bodyFont: string
24 }
25
26 export type DeckEditScope = 'page' | 'deck' | 'presentation-container'
27
28 export interface UploadedAsset {
29 id: string
30 fileName: string
31 originalName: string
32 relativePath: string
33 absolutePath?: string
34 mimeType: string
35 size: number
36 createdAt: number
37 }
38
39 export interface ParseDocumentPlanPayload {
40 files: Array<{
41 path: string
42 name?: string
43 }>
44 modelConfigId?: string
45 topic?: string
46 existingBrief?: string
47 }
48
49 export interface SourceDocumentPlan {
50 version: 1
51 confidence: 'high' | 'medium' | 'low'
52 sourceDocumentPath?: string
53 sourceDocumentName?: string
54 pageSkeleton: DocumentPlanPageSkeletonItem[]
55 }
56
57 export interface PrepareReferenceDocumentPayload {
58 files: Array<{
59 path: string
60 name?: string
61 }>
62 }
63
64 export interface ParseImageReferencePayload {
65 file: {
66 path: string
67 name?: string
68 }
69 modelConfigId?: string
70 }
71
72 export interface ParsedDocumentPlanResult {
73 topic: string
74 pageCount: number
75 briefText: string
76 pageSkeleton?: DocumentPlanPageSkeletonItem[]
77 sourcePlan?: SourceDocumentPlan
78 files: Array<{
79 name: string
80 type: 'markdown' | 'text' | 'csv' | 'docx' | 'image'
81 characterCount: number
82 path: string
83 }>
84 }
85
86 export interface DocumentPlanPageSkeletonItem {
87 id?: string
88 pageNumber: number
89 title: string
90 role: 'chapter-divider' | 'content'
91 sourceHeading: string
92 headingLevel: number
93 lineStart: number
94 lineEnd: number
95 reason: string
96 }
97
98 export const SECTION_AGENDA_OUTLINE_MARKER = 'Page role: section-agenda'
99 export const SECTION_AGENDA_REASON_PREFIX_ZH = '章节目录页'
100 export const SECTION_AGENDA_REASON_PREFIX_EN = 'Section agenda page'
101
102 export const isSectionAgendaReason = (reason: string): boolean =>
103 new RegExp(
104 `^(?:${SECTION_AGENDA_REASON_PREFIX_ZH}|${SECTION_AGENDA_REASON_PREFIX_EN})\\s*[::]`,
105 'i'
106 ).test(reason.trim())
107
108 export const isSectionAgendaOutline = (outline: string): boolean =>
109 /Page role:\s*section-agenda/i.test(outline)
110
111 export const isInternalDocumentPlanPageReason = (reason: string): boolean => {
112 const normalized = reason.toLowerCase()
113 return (
114 normalized.includes('major # heading') ||
115 normalized.includes('leaf ## section') ||
116 normalized.includes('top-level ## section') ||
117 normalized.includes('standalone level-') ||
118 normalized.includes('section has substantial own body')
119 )
120 }
121
122 export interface PreparedReferenceDocumentResult {
123 files: ParsedDocumentPlanResult['files']
124 }
125
126 export interface PptxImportPayload {
127 filePath: string
128 title?: string
129 styleId?: string | null
130 modelConfigId?: string
131 }
132
133 export interface PptxImportProgressPayload {
134 sessionId?: string
135 stage: 'reading' | 'parsing' | 'media' | 'pages' | 'index' | 'database' | 'completed'
136 progress: number
137 label: string
138 pageNumber?: number
139 totalPages?: number
140 }
141
142 export interface PptxImportResult {
143 sessionId: string
144 pageCount: number
145 warnings: string[]
146 }
147
148 export interface FontRef {
149 source: 'google' | 'uploaded'
150 family: string
151 id?: string
152 }
153
154 export type FontSelection =
155 | { mode: 'auto' }
156 | {
157 mode: 'pair'
158 title: FontRef
159 body: FontRef
160 }
161
162 export const normalizeFontSelection = (value: unknown): FontSelection => {
163 const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
164 if (record.mode !== 'pair') return { mode: 'auto' }
165 const title =
166 record.title && typeof record.title === 'object'
167 ? (record.title as Record<string, unknown>)
168 : {}
169 const body =
170 record.body && typeof record.body === 'object' ? (record.body as Record<string, unknown>) : {}
171 const titleFamily = typeof title.family === 'string' ? title.family.trim() : ''
172 const bodyFamily = typeof body.family === 'string' ? body.family.trim() : ''
173 if (!titleFamily || !bodyFamily) return { mode: 'auto' }
174 const titleSource = title.source === 'uploaded' ? 'uploaded' : 'google'
175 const bodySource = body.source === 'uploaded' ? 'uploaded' : 'google'
176 return {
177 mode: 'pair',
178 title: {
179 source: titleSource,
180 family: titleFamily,
181 id: typeof title.id === 'string' ? title.id : undefined
182 },
183 body: {
184 source: bodySource,
185 family: bodyFamily,
186 id: typeof body.id === 'string' ? body.id : undefined
187 }
188 }
189 }
190
191 /**
192 * A bounded, read-only snapshot of the element that initiated a selector-scoped AI edit.
193 *
194 * The renderer reads this from the live preview; the main process normalizes it again before
195 * it reaches an agent prompt. It deliberately excludes HTML, event handlers, and editor-only
196 * markers so it remains context rather than an alternate document-editing channel.
197 */
198 export interface SelectedElementRuntimeContext {
199 classList?: string[]
200 attributes?: Record<string, string>
201 inlineStyle?: Record<string, { value: string; priority?: '' | 'important' }>
202 computedStyle?: Record<string, string>
203 bounds?: { x: number; y: number; width: number; height: number }
204 }
205
206 /**
207 * The renderer samples only these computed properties and the main process accepts only these
208 * keys from IPC. Keep the list shared so the two trust-boundary checks cannot drift.
209 */
210 export const SELECTED_ELEMENT_CONTEXT_COMPUTED_STYLE_PROPERTIES = [
211 'display',
212 'position',
213 'box-sizing',
214 'left',
215 'top',
216 'right',
217 'bottom',
218 'width',
219 'height',
220 'min-width',
221 'min-height',
222 'max-width',
223 'max-height',
224 'margin',
225 'padding',
226 'gap',
227 'z-index',
228 'opacity',
229 'visibility',
230 'overflow',
231 'transform',
232 'transform-origin',
233 'color',
234 'background-color',
235 'background-image',
236 'border',
237 'border-radius',
238 'box-shadow',
239 'font-family',
240 'font-size',
241 'font-weight',
242 'line-height',
243 'letter-spacing',
244 'text-align',
245 'white-space',
246 'flex',
247 'flex-direction',
248 'align-items',
249 'justify-content',
250 'grid-template-columns',
251 'grid-template-rows',
252 'object-fit',
253 'object-position'
254 ] as const
255
256 export interface GenerateStartPayload {
257 sessionId: string
258 modelConfigId?: string
259 userMessage: string
260 type?: 'deck' | 'page'
261 chatType?: 'main' | 'page'
262 resetVisualStyle?: boolean
263 persistUserMessage?: boolean
264 /** Renderer-generated ID so an optimistic chat message is reconciled with its DB record. */
265 clientMessageId?: string
266 chatPageId?: string
267 selectPageIds?: string[]
268 selectedPageId?: string
269 htmlPath?: string
270 selector?: string
271 elementTag?: string
272 elementText?: string
273 selectedElementContext?: SelectedElementRuntimeContext
274 imagePaths?: string[]
275 videoPaths?: string[]
276 docPaths?: string[]
277 animationPreferences?: AnimationPreferencesPayload
278 autoApply?: boolean
279 approvedPlan?: SessionPageEditPlan
280 }
281
282 export const SESSION_PAGE_EDIT_INTENTS = [
283 'content',
284 'style',
285 'layout',
286 'redesign',
287 'other'
288 ] as const
289
290 export type SessionPageEditIntent = (typeof SESSION_PAGE_EDIT_INTENTS)[number]
291
292 export interface SessionPageEditPlan {
293 intent: SessionPageEditIntent
294 target: string
295 summary: string
296 changes: string[]
297 confirmationQuestion: string
298 }
299
300 export interface SessionPageEditAssessment {
301 plan: SessionPageEditPlan
302 requiresConfirmation: boolean
303 }
304
305 export const normalizeSessionPageEditPlan = (value: unknown): SessionPageEditPlan | undefined => {
306 if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
307 const record = value as Record<string, unknown>
308 const intent = SESSION_PAGE_EDIT_INTENTS.includes(record.intent as SessionPageEditIntent)
309 ? (record.intent as SessionPageEditIntent)
310 : undefined
311 const target = typeof record.target === 'string' ? record.target.trim().slice(0, 500) : ''
312 const summary = typeof record.summary === 'string' ? record.summary.trim().slice(0, 1500) : ''
313 const changes = Array.isArray(record.changes)
314 ? record.changes
315 .filter((item): item is string => typeof item === 'string')
316 .map((item) => item.trim())
317 .filter(Boolean)
318 .slice(0, 8)
319 : []
320 const confirmationQuestion =
321 typeof record.confirmationQuestion === 'string'
322 ? record.confirmationQuestion.trim().slice(0, 300)
323 : ''
324 if (!intent || !target || !summary || changes.length === 0 || !confirmationQuestion)
325 return undefined
326 return { intent, target, summary, changes, confirmationQuestion }
327 }
328
329 export interface SwitchSessionStylePayload {
330 sessionId: string
331 styleId: string
332 modelConfigId?: string
333 }
334
335 export type RetrySessionStylePayload = SwitchSessionStylePayload & {
336 failedRunId?: string
337 }
338
339 export type RetryDeckEditPayload = GenerateStartPayload & {
340 failedRunId?: string
341 }
342
343 export const MAX_SELECTED_PAGES = 50
344 export const MAX_STYLE_SWITCH_PAGES = 500
345
346 export const normalizeSelectPageIds = (value: unknown, limit = MAX_SELECTED_PAGES): string[] => {
347 if (!Array.isArray(value)) return []
348 const normalized = Array.from(
349 new Set(
350 value.map((item) => String(item || '').trim()).filter((item) => /^[a-z0-9_-]+$/i.test(item))
351 )
352 )
353 if (normalized.length > limit) {
354 throw new Error(`一次最多选择 ${limit} 页`)
355 }
356 return normalized
357 }
358
359 export interface GenerateRetryFailedPayload {
360 sessionId: string
361 modelConfigId?: string
362 userMessage?: string
363 failedRunId?: string
364 }
365
366 export type AnimationPreferenceId =
367 | 'fade'
368 | 'fade-up'
369 | 'fade-down'
370 | 'fade-left'
371 | 'fade-right'
372 | 'scale-in'
373 | 'slide-up'
374 | 'slide-down'
375 | 'slide-left'
376 | 'slide-right'
377 | 'fly-in'
378 | 'wipe'
379 | 'zoom-in'
380 | 'spin-in'
381 | 'pulse-soft'
382 | 'pulse'
383 | 'pulse-strong'
384 | 'grow-shrink-soft'
385 | 'grow-shrink'
386 | 'grow-shrink-strong'
387
388 export interface AnimationPreferencesPayload {
389 ids: AnimationPreferenceId[]
390 }
391
392 const ANIMATION_PREFERENCE_IDS = new Set<AnimationPreferenceId>([
393 'fade',
394 'fade-up',
395 'fade-down',
396 'fade-left',
397 'fade-right',
398 'scale-in',
399 'slide-up',
400 'slide-down',
401 'slide-left',
402 'slide-right',
403 'fly-in',
404 'wipe',
405 'zoom-in',
406 'spin-in',
407 'pulse-soft',
408 'pulse',
409 'pulse-strong',
410 'grow-shrink-soft',
411 'grow-shrink',
412 'grow-shrink-strong'
413 ])
414
415 export const normalizeAnimationPreferences = (
416 value: unknown
417 ): AnimationPreferencesPayload | null => {
418 const rawIds = Array.isArray((value as AnimationPreferencesPayload | null)?.ids)
419 ? (value as AnimationPreferencesPayload).ids
420 : Array.isArray(value)
421 ? value
422 : []
423 const ids = Array.from(
424 new Set(
425 rawIds
426 .map((item) => String(item || '').trim())
427 .filter((item): item is AnimationPreferenceId =>
428 ANIMATION_PREFERENCE_IDS.has(item as AnimationPreferenceId)
429 )
430 )
431 )
432 const selected = ids.slice(0, 3)
433 return selected.length > 0 ? { ids: selected } : null
434 }
435
436 export type AnimationPreferenceSourceRun = {
437 session_id: string
438 animation_preferences: string | null
439 }
440
441 /**
442 * Inherit animation preferences from a prior generation run — but only when an
443 * explicit source run is supplied AND it belongs to the same session. A missing
444 * or stale source run must never block retry; degrade to no preferences.
445 * See docs/design/session-create-animation-preferences-design.md (run 字段持久化).
446 */
447 export const resolveInheritedAnimationPreferences = (
448 sourceRun: AnimationPreferenceSourceRun | null | undefined,
449 sessionId: string
450 ): AnimationPreferencesPayload | null => {
451 if (!sourceRun || sourceRun.session_id !== sessionId) return null
452 try {
453 return normalizeAnimationPreferences(
454 sourceRun.animation_preferences ? JSON.parse(sourceRun.animation_preferences) : null
455 )
456 } catch {
457 return null
458 }
459 }
460
461 export interface GenerateAddPagePayload {
462 sessionId: string
463 modelConfigId?: string
464 userMessage: string
465 insertAfterPageNumber: number
466 targetPageId?: string
467 }
468
469 export interface GenerateRetrySinglePagePayload {
470 sessionId: string
471 modelConfigId?: string
472 pageId: string
473 }
474
475 export interface GeneratedPagePayload {
476 id?: string
477 focusPage?: boolean
478 pageNumber: number
479 title: string
480 contentOutline?: string | null
481 html: string
482 htmlPath?: string
483 pageId?: string
484 sourceUrl?: string
485 pageCommitReady?: boolean
486 }
487
488 export interface PageStatusPayload {
489 id?: string
490 pageNumber: number
491 title: string
492 pageId?: string
493 htmlPath?: string
494 error?: string
495 }
496
497 export interface GenerateStagePayload {
498 runId: string
499 sessionId?: string
500 stage: string
501 label: string
502 progress?: number
503 currentPage?: number
504 totalPages?: number
505 completedPageCount?: number
506 failedPageCount?: number
507 timestamp?: string
508 activityKind?:
509 | 'page-edit'
510 | 'deck-edit'
511 | 'edit'
512 | 'style-switch'
513 | 'page-beautify'
514 | 'single-page-retry'
515 | 'addPage'
516 }
517
518 export type GenerateChunkEvent =
519 | {
520 type: 'stage_started' | 'stage_progress'
521 payload: GenerateStagePayload
522 }
523 | {
524 type: 'llm_status'
525 payload: GenerateStagePayload & {
526 provider?: string
527 model?: string
528 detail?: string
529 }
530 }
531 | {
532 type: 'assistant_message'
533 payload: {
534 id?: string
535 runId: string
536 sessionId?: string
537 content: string
538 chatType?: 'main' | 'page'
539 pageId?: string
540 timestamp?: string
541 activityKind?:
542 | 'page-edit'
543 | 'deck-edit'
544 | 'edit'
545 | 'style-switch'
546 | 'page-beautify'
547 | 'single-page-retry'
548 | 'addPage'
549 }
550 }
551 | {
552 type: 'page_generated'
553 payload: GenerateStagePayload & GeneratedPagePayload
554 }
555 | {
556 type: 'page_updated'
557 payload: GenerateStagePayload & GeneratedPagePayload
558 }
559 | {
560 type: 'page_planned'
561 payload: GenerateStagePayload & PageStatusPayload
562 }
563 | {
564 type: 'page_started' | 'page_failed'
565 payload: GenerateStagePayload & PageStatusPayload
566 }
567 | {
568 type: 'run_completed'
569 payload: {
570 runId: string
571 sessionId?: string
572 totalPages: number
573 completedPageCount?: number
574 failedPageCount?: number
575 timestamp?: string
576 outcome?: 'changed' | 'unchanged'
577 activityKind?:
578 | 'page-edit'
579 | 'deck-edit'
580 | 'edit'
581 | 'style-switch'
582 | 'page-beautify'
583 | 'single-page-retry'
584 | 'addPage'
585 }
586 }
587 | {
588 type: 'run_error'
589 payload: {
590 runId: string
591 sessionId?: string
592 message: string
593 cancelled?: boolean
594 completedPageCount?: number
595 failedPageCount?: number
596 timestamp?: string
597 activityKind?:
598 | 'page-edit'
599 | 'deck-edit'
600 | 'edit'
601 | 'style-switch'
602 | 'page-beautify'
603 | 'single-page-retry'
604 | 'addPage'
605 }
606 }
607
607 lines TYPESCRIPT