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