返回 oh-my-ppt
page-image-finalizer.ts
根目录 / src / main / generation / page-image-finalizer.ts
1 import fs from 'fs'
2 import log from 'electron-log/main.js'
3 import { nanoid } from 'nanoid'
4 import type { OutlineItem } from '@shared/generation'
5 import { getLayoutMasterTemplate } from '@shared/layout-master'
6 import {
7 isValidImagePrompt,
8 parseVisualIntents,
9 type VisualIntentParseResult
10 } from '../image-generation/visual-intent'
11 import {
12 finalizeAutomaticImageIntents,
13 type ImageLayoutRefinement
14 } from '../image-generation/fulfillment-service'
15 import {
16 createImagePromptDirector,
17 type ImagePromptDirectorConfig
18 } from '../image-generation/prompt-director'
19 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
20 import type { GenerationContext } from './context'
21 import { validateLayoutSlots } from './layout-slot-validator'
22
23 type PageFinalizerContext = {
24 sessionId: string
25 runId: string
26 visualEnabled: boolean
27 imageModelConfigId?: string
28 imageGenerationPrompt?: string
29 imagePromptDirector?: ImagePromptDirectorConfig
30 abortSignal?: AbortSignal
31 }
32
33 type CompletedPage = {
34 pageNumber: number
35 pageId: string
36 title: string
37 contentOutline: string
38 layoutIntent?: OutlineItem['layoutIntent']
39 layoutId: string
40 layoutContractVersion: number
41 htmlPath: string
42 }
43
44 const assertNotCancelled = (signal?: AbortSignal): void => {
45 if (signal?.aborted) throw new Error('生成已取消')
46 }
47
48 const imageDirectorFailure = (
49 parsed: VisualIntentParseResult,
50 error: unknown
51 ): VisualIntentParseResult => {
52 const message = error instanceof Error && error.message ? error.message : String(error)
53 const failure = `Image director failed: ${message}`
54 return {
55 status: 'invalid',
56 intents: [],
57 invalidIntents: parsed.intents.map((intent) => ({
58 slotId: intent.slotId,
59 layoutSlotId: intent.layoutSlotId,
60 role: intent.role,
61 requestJson: intent.requestJson,
62 errors: [failure]
63 })),
64 errors: [failure]
65 }
66 }
67
68 const ensureSessionPageForFinalization = async (
69 ctx: GenerationContext,
70 context: PageFinalizerContext,
71 page: CompletedPage
72 ): Promise<string> => {
73 const pages = await ctx.db.listSessionPages(context.sessionId, { includeDeleted: true })
74 const existing = pages.find(
75 (item) =>
76 item.file_slug === page.pageId ||
77 item.id === page.pageId ||
78 item.legacy_page_id === page.pageId
79 )
80 if (existing) return existing.id
81 const id = nanoid()
82 await ctx.db.upsertSessionPage({
83 id,
84 sessionId: context.sessionId,
85 legacyPageId: page.pageId.match(/^page-\d+$/) ? page.pageId : null,
86 fileSlug: page.pageId,
87 pageNumber: page.pageNumber,
88 title: page.title,
89 htmlPath: page.htmlPath,
90 layoutIntent: page.layoutIntent || null,
91 layoutId: page.layoutId,
92 layoutContractVersion: page.layoutContractVersion,
93 status: 'pending',
94 error: null
95 })
96 return id
97 }
98
99 /** Shared generation-stage gate. A page cannot be persisted or emitted until this resolves. */
100 export const createPageImageFinalizer =
101 (ctx: GenerationContext, context: PageFinalizerContext) =>
102 async (page: CompletedPage, refineImageLayout?: ImageLayoutRefinement): Promise<void> => {
103 assertNotCancelled(context.abortSignal)
104 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
105 const imageRequestsEnabled =
106 context.visualEnabled && Boolean(context.imageGenerationPrompt?.trim())
107 const imageSlots = (getLayoutMasterTemplate(page.layoutId)?.slots || []).filter(
108 (slot) => slot.role === 'visual' && slot.image?.policy !== 'forbidden'
109 )
110 const imageCapableSlots = imageSlots.map((slot) => slot.id)
111 let parsed = parseVisualIntents({
112 html,
113 visualEnabled: imageRequestsEnabled,
114 layoutIntent: page.layoutIntent || null,
115 layoutId: page.layoutId,
116 layoutContractVersion: page.layoutContractVersion
117 })
118 if (parsed.status === 'valid' && parsed.intents.length > 0) {
119 if (!context.imagePromptDirector) {
120 parsed = imageDirectorFailure(parsed, new Error('Image director is not configured.'))
121 } else {
122 try {
123 const directImage = createImagePromptDirector(context.imagePromptDirector)
124 const intents = await Promise.all(
125 parsed.intents.map(async (intent) => {
126 const subject = await directImage({
127 sessionId: context.sessionId,
128 pageId: page.pageId,
129 pageTitle: page.title,
130 pageOutline: page.contentOutline,
131 pageHtml: html,
132 layoutSlotId: intent.layoutSlotId,
133 role: intent.role,
134 imageGenerationPrompt: context.imageGenerationPrompt || '',
135 signal: context.abortSignal
136 })
137 if (!isValidImagePrompt(subject)) throw new Error('Image director returned an empty prompt.')
138 return { ...intent, subject }
139 })
140 )
141 parsed = { ...parsed, intents }
142 } catch (error) {
143 parsed = imageDirectorFailure(parsed, error)
144 }
145 }
146 }
147 log.info('[images:fulfillment] page intent scan', {
148 sessionId: context.sessionId,
149 runId: context.runId,
150 pageId: page.pageId,
151 layoutId: page.layoutId,
152 layoutIntent: page.layoutIntent || null,
153 visualEnabled: context.visualEnabled,
154 hasStyleImageDirection: Boolean(context.imageGenerationPrompt?.trim()),
155 imageModelConfigured: Boolean(context.imageModelConfigId?.trim()),
156 imageCapableSlots,
157 intentStatus: parsed.status,
158 intentCount: parsed.intents.length,
159 errors: parsed.errors
160 })
161 if (parsed.status === 'forbidden') {
162 const error = parsed.errors.join(' ') || 'Image intent drafts are forbidden for this session.'
163 log.warn('[images:fulfillment] forbidden image intent drafts', {
164 sessionId: context.sessionId,
165 runId: context.runId,
166 pageId: page.pageId,
167 error
168 })
169 throw new Error(error)
170 }
171 if (parsed.status === 'none') {
172 log.info('[images:fulfillment] page skipped', {
173 sessionId: context.sessionId,
174 runId: context.runId,
175 pageId: page.pageId,
176 reason: !imageRequestsEnabled
177 ? 'automatic image generation is disabled or the style has no image direction'
178 : imageCapableSlots.length === 0
179 ? 'layout has no image-capable visual slot'
180 : 'page agent determined that no generated image improves this page'
181 })
182 assertNotCancelled(context.abortSignal)
183 return
184 }
185 const sessionPageId = await ensureSessionPageForFinalization(ctx, context, page)
186 const result = await finalizeAutomaticImageIntents({
187 db: ctx.db,
188 coordinator: ctx.imageCoordinator,
189 decryptApiKey: ctx.credentials.decryptApiKey,
190 resolveSessionProjectDir: ctx.sessionProject.resolveSessionProjectDir,
191 sessionId: context.sessionId,
192 sessionPageId,
193 runId: context.runId,
194 pageId: page.pageId,
195 pageHtmlPath: page.htmlPath,
196 layoutId: page.layoutId,
197 layoutContractVersion: page.layoutContractVersion,
198 imageModelConfigId: context.imageModelConfigId || '',
199 parseResult: parsed,
200 validateCandidateHtml: (candidateHtml) => [
201 ...validatePersistedPageHtml(candidateHtml, page.pageId).errors,
202 ...validateLayoutSlots({
203 html: candidateHtml,
204 layoutIntent: page.layoutIntent || null,
205 layoutId: page.layoutId,
206 layoutContractVersion: page.layoutContractVersion
207 }).errors
208 ],
209 signal: context.abortSignal,
210 refineImageLayout
211 })
212 log.info('[images:fulfillment] page finalization finished', {
213 sessionId: context.sessionId,
214 runId: context.runId,
215 pageId: page.pageId,
216 jobId: result.jobId || null,
217 status: result.status,
218 reused: Boolean(result.reused),
219 error: result.error || null
220 })
221 if (result.status === 'cancelled') throw new Error('生成已取消')
222 assertNotCancelled(context.abortSignal)
223 }
224
224 lines TYPESCRIPT