返回 oh-my-ppt
fulfillment-handlers.ts
根目录 / src / main / image-generation / fulfillment-handlers.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import fs from 'fs'
4 import path from 'path'
5 import * as cheerio from 'cheerio'
6 import type { IpcContext } from '../ipc/context'
7 import { JobCoordinator } from '../agent-runtime'
8 import { formatLayoutMasterPrompt, getLayoutMasterTemplate } from '@shared/layout-master'
9 import { createGenerationContext, resolveCommonContext } from '../generation/context'
10 import { createImageLayoutRefinement } from '../generation/agent-runner'
11 import { resolvePageHtmlPath } from '../generation/generation-utils'
12 import { validateLayoutSlots } from '../generation/layout-slot-validator'
13 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
14 import { finalizeAutomaticImageIntents } from './fulfillment-service'
15 import { withImageFulfillmentRetryLock } from './fulfillment-retry-lock'
16 import type { ParsedVisualIntent, VisualIntentParseResult } from './visual-intent'
17 import {
18 ensureHistoryBaselineSafe,
19 recordHistoryOperationStrict
20 } from '../history/git-history-service'
21
22 const readRecord = (value: unknown): Record<string, unknown> =>
23 value && typeof value === 'object' && !Array.isArray(value)
24 ? (value as Record<string, unknown>)
25 : {}
26
27 const RETRYABLE_INTENT_STATUSES = new Set(['failed', 'fallback', 'layout_failed', 'cancelled'])
28 const IMAGE_ROLES = new Set(['hero-image', 'product-visual', 'spot-illustration', 'data-visual'])
29
30 const parseStringArray = (value: string | null): string[] => {
31 if (!value) return []
32 try {
33 const parsed = JSON.parse(value)
34 return Array.isArray(parsed)
35 ? parsed.filter((item): item is string => typeof item === 'string')
36 : []
37 } catch {
38 return []
39 }
40 }
41
42 const toRetryIntent = (intent: {
43 slot_id: string
44 layout_slot_id: string
45 role: string
46 layer: string
47 subject: string
48 text_zone: string | null
49 subject_zone: string | null
50 negative_space: string | null
51 avoid_json: string | null
52 request_json: string
53 }): ParsedVisualIntent | null => {
54 if (!IMAGE_ROLES.has(intent.role)) return null
55 if (!intent.slot_id || !intent.layout_slot_id || !intent.subject.trim()) return null
56 if (intent.layer !== 'background' && intent.layer !== 'visual') return null
57 return {
58 slotId: intent.slot_id,
59 layoutSlotId: intent.layout_slot_id,
60 role: intent.role as ParsedVisualIntent['role'],
61 layer: intent.layer,
62 subject: intent.subject,
63 textZone: intent.text_zone || undefined,
64 subjectZone: intent.subject_zone || undefined,
65 negativeSpace: intent.negative_space || undefined,
66 avoid: parseStringArray(intent.avoid_json),
67 requestJson: intent.request_json
68 }
69 }
70
71 const hasLayoutSlot = ($: cheerio.CheerioAPI, slotId: string): boolean =>
72 $('[data-ppt-slot]')
73 .toArray()
74 .some((node) => ($(node).attr('data-ppt-slot') || '').trim() === slotId)
75
76 const executeAutomaticImageFulfillmentRetry = async (
77 ctx: IpcContext,
78 coordinator: JobCoordinator,
79 args: { sessionId: string; sourceJobId: string }
80 ) => {
81 const sourceJob = await ctx.db.getImageFulfillmentJob(args.sourceJobId)
82 if (!sourceJob || sourceJob.session_id !== args.sessionId)
83 throw new Error('Image fulfillment job not found.')
84 if (!['completed', 'degraded', 'failed', 'cancelled'].includes(sourceJob.status)) {
85 throw new Error('Image fulfillment job is not ready to retry.')
86 }
87 if (!sourceJob.layout_id || !sourceJob.layout_contract_version) {
88 throw new Error('The original image job has no compatible layout source.')
89 }
90
91 const sessionPage = (
92 await ctx.db.listSessionPages(args.sessionId, { includeDeleted: true })
93 ).find((page) => page.id === sourceJob.session_page_id)
94 if (!sessionPage || sessionPage.deleted_at) throw new Error('The original page no longer exists.')
95 if (
96 sessionPage.layout_id !== sourceJob.layout_id ||
97 sessionPage.layout_contract_version !== sourceJob.layout_contract_version ||
98 !sessionPage.layout_intent
99 ) {
100 throw new Error(
101 'The page layout source changed; regenerate the page before retrying its illustration.'
102 )
103 }
104
105 const execution = { runId: sourceJob.run_id, abortSignal: new AbortController().signal }
106 const generationContext = createGenerationContext({ ...ctx, imageCoordinator: coordinator })
107 const common = await resolveCommonContext(generationContext, args.sessionId, undefined, execution)
108 if (!common.visualEnabled || !common.imageModelConfigId) {
109 throw new Error('Automatic image generation is disabled for this session.')
110 }
111
112 const pageHtmlPath = resolvePageHtmlPath({
113 projectDir: common.projectDir,
114 fileSlug: sessionPage.file_slug,
115 candidates: [sessionPage.html_path]
116 })
117 if (!fs.existsSync(pageHtmlPath)) throw new Error('The page file no longer exists.')
118 const currentHtml = await fs.promises.readFile(pageHtmlPath, 'utf-8')
119 const layoutIntent = sessionPage.layout_intent as Parameters<
120 typeof validateLayoutSlots
121 >[0]['layoutIntent']
122 const slotValidation = validateLayoutSlots({
123 html: currentHtml,
124 layoutIntent,
125 layoutId: sessionPage.layout_id,
126 layoutContractVersion: sessionPage.layout_contract_version
127 })
128 if (!slotValidation.valid || slotValidation.skipped) {
129 throw new Error('The current page no longer satisfies the original layout slot contract.')
130 }
131
132 const template = getLayoutMasterTemplate(sessionPage.layout_id)
133 if (!template || template.intent !== layoutIntent) {
134 throw new Error('The current layout catalog entry is unavailable for image retry.')
135 }
136
137 const sourceIntents = await ctx.db.listImageFulfillmentIntents(sourceJob.id)
138 const retryable = sourceIntents
139 .filter((intent) => RETRYABLE_INTENT_STATUSES.has(intent.status))
140 .map((intent) => ({ source: intent, request: toRetryIntent(intent) }))
141 .filter(
142 (item): item is { source: (typeof sourceIntents)[number]; request: ParsedVisualIntent } =>
143 Boolean(item.request)
144 )
145 .filter((item) =>
146 hasLayoutSlot(
147 cheerio.load(currentHtml, { scriptingEnabled: false }),
148 item.request.layoutSlotId
149 )
150 )
151 .filter((item) => {
152 const layoutSlot = template.slots.find((slot) => slot.id === item.request.layoutSlotId)
153 return Boolean(
154 layoutSlot?.image &&
155 layoutSlot.image.policy !== 'forbidden' &&
156 layoutSlot.image.role === item.request.role
157 )
158 })
159 if (retryable.length === 0) {
160 throw new Error('No failed illustration remains compatible with the current page layout.')
161 }
162
163 const parseResult: VisualIntentParseResult = {
164 status: 'valid',
165 intents: retryable.map((item) => item.request),
166 invalidIntents: [],
167 errors: []
168 }
169 const retryOfIntentIdBySlot = Object.fromEntries(
170 retryable.map((item) => [item.request.slotId, item.source.id])
171 )
172 const retryKey = crypto
173 .createHash('sha256')
174 .update(`${sourceJob.id}:${currentHtml}`)
175 .digest('hex')
176 .slice(0, 24)
177 await ensureHistoryBaselineSafe(ctx.db, args.sessionId, common.projectDir)
178 const result = await finalizeAutomaticImageIntents({
179 db: ctx.db,
180 coordinator,
181 decryptApiKey: ctx.credentials.decryptApiKey,
182 resolveSessionProjectDir: ctx.resolveSessionProjectDir,
183 sessionId: args.sessionId,
184 sessionPageId: sessionPage.id,
185 runId: sourceJob.run_id,
186 pageId: sessionPage.file_slug,
187 pageHtmlPath,
188 layoutId: sessionPage.layout_id,
189 layoutContractVersion: sessionPage.layout_contract_version,
190 imageModelConfigId: common.imageModelConfigId,
191 parseResult,
192 retryOfJobId: sourceJob.id,
193 retryOfIntentIdBySlot,
194 idempotencyKey: `retry:${retryKey}`,
195 validateCandidateHtml: (candidateHtml) => [
196 ...validatePersistedPageHtml(candidateHtml, sessionPage.file_slug).errors,
197 ...validateLayoutSlots({
198 html: candidateHtml,
199 layoutIntent,
200 layoutId: sessionPage.layout_id,
201 layoutContractVersion: sessionPage.layout_contract_version
202 }).errors
203 ],
204 refineImageLayout: createImageLayoutRefinement({
205 provider: common.provider,
206 apiKey: common.apiKey,
207 model: common.model,
208 baseUrl: common.providerBaseUrl,
209 maxTokens: common.maxTokens,
210 modelRuntime: common.modelRuntime,
211 styleId: common.styleId,
212 context: {
213 mode: 'edit',
214 editScope: 'page',
215 sessionId: args.sessionId,
216 projectDir: common.projectDir,
217 indexPath: path.join(common.projectDir, 'index.html'),
218 pageFileMap: { [sessionPage.file_slug]: pageHtmlPath },
219 pageNumbers: { [sessionPage.file_slug]: sessionPage.page_number },
220 selectPageIds: [sessionPage.file_slug],
221 allowedPageIds: [sessionPage.file_slug],
222 topic: common.topic,
223 deckTitle: common.deckTitle,
224 styleId: common.styleId,
225 styleSkillPrompt: common.styleSkillPrompt,
226 hasStyleImageDirection: Boolean(common.imageGenerationPrompt.trim()),
227 styleKey: common.styleKey,
228 styleName: common.styleName,
229 styleVersion: common.styleVersion,
230 slideSize: common.slideSize,
231 appLocale: common.appLocale,
232 userMessage: 'Refine this page after automatic image placement.',
233 outlineTitles: [sessionPage.title || sessionPage.file_slug],
234 outlineItems: [
235 {
236 title: sessionPage.title || sessionPage.file_slug,
237 contentOutline: '',
238 layoutIntent,
239 layoutId: sessionPage.layout_id,
240 layoutPrompt: formatLayoutMasterPrompt(template)
241 }
242 ],
243 selectedPageId: sessionPage.file_slug,
244 selectedPageNumber: sessionPage.page_number,
245 selectedSelector: 'main[data-role="content"]',
246 elementTag: 'main',
247 elementText: 'Complete slide content after automatic image placement',
248 existingPageIds: [sessionPage.file_slug]
249 },
250 agentManager: generationContext.agentManager,
251 emit: (chunk) => ctx.emitGenerateChunk(args.sessionId, chunk),
252 runId: sourceJob.run_id,
253 stage: 'editing',
254 totalPages: 1,
255 timeoutMs: common.modelTimeouts.agent,
256 signal: execution.abortSignal,
257 workerLabel: sessionPage.file_slug
258 }),
259 signal: execution.abortSignal
260 })
261 if (result.status === 'completed' && result.jobId && !result.reused) {
262 const retryIntents = await ctx.db.listImageFulfillmentIntents(result.jobId)
263 const imagePaths = retryIntents
264 .filter((intent) => intent.status === 'used' && intent.asset_path)
265 .map((intent) => intent.asset_path!.replace(/^\.\//, ''))
266 await recordHistoryOperationStrict(ctx.db, {
267 sessionId: args.sessionId,
268 projectDir: common.projectDir,
269 type: 'edit',
270 scope: 'page',
271 prompt: 'Retry failed automatic illustration',
272 allowedPaths: [
273 path.relative(common.projectDir, pageHtmlPath).split(path.sep).join('/'),
274 ...imagePaths
275 ]
276 })
277 const html = await fs.promises.readFile(pageHtmlPath, 'utf-8')
278 ctx.emitGenerateChunk(args.sessionId, {
279 type: 'page_updated',
280 payload: {
281 runId: sourceJob.run_id,
282 stage: 'finalizing',
283 label: common.appLocale === 'en' ? 'Illustration retry saved' : '配图重试已保存',
284 progress: 100,
285 currentPage: sessionPage.page_number,
286 totalPages: 1,
287 id: sessionPage.id,
288 pageNumber: sessionPage.page_number,
289 title: sessionPage.title || `第${sessionPage.page_number}页`,
290 html,
291 pageId: sessionPage.file_slug,
292 htmlPath: pageHtmlPath,
293 sourceUrl: ctx.getPageSourceUrl(pageHtmlPath)
294 }
295 })
296 }
297 return result
298 }
299
300 export const retryAutomaticImageFulfillment = async (
301 ctx: IpcContext,
302 coordinator: JobCoordinator,
303 args: { sessionId: string; sourceJobId: string }
304 ) => {
305 return withImageFulfillmentRetryLock(coordinator, args.sessionId, args.sourceJobId, () =>
306 executeAutomaticImageFulfillmentRetry(ctx, coordinator, args)
307 )
308 }
309
310 /** IPC is deliberately limited to state and cooperative cancellation. Generation owns job creation. */
311 export const registerImageFulfillmentHandlers = (
312 ctx: IpcContext,
313 coordinator: JobCoordinator
314 ): void => {
315 ipcMain.handle('images:listFulfillmentJobs', async (_event, payload: unknown) => {
316 const input = readRecord(payload)
317 const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : ''
318 const pageId = typeof input.pageId === 'string' ? input.pageId.trim() : ''
319 if (!sessionId) throw new Error('Session ID is required.')
320 let sessionPageId: string | undefined
321 if (pageId) {
322 const page = (await ctx.db.listSessionPages(sessionId, { includeDeleted: true })).find(
323 (item) => item.id === pageId || item.file_slug === pageId || item.legacy_page_id === pageId
324 )
325 if (!page) return []
326 sessionPageId = page.id
327 }
328 const jobs = await ctx.db.listImageFulfillmentJobs(sessionId, sessionPageId)
329 return Promise.all(
330 jobs.map(async (job) => {
331 const intents = await ctx.db.listImageFulfillmentIntents(job.id)
332 return {
333 ...job,
334 layout_failed_count: intents.filter((intent) => intent.status === 'layout_failed').length,
335 retryable_intent_count: intents.filter(
336 (intent) =>
337 RETRYABLE_INTENT_STATUSES.has(intent.status) && Boolean(toRetryIntent(intent))
338 ).length
339 }
340 })
341 )
342 })
343
344 ipcMain.handle('images:cancelFulfillment', async (_event, payload: unknown) => {
345 const input = readRecord(payload)
346 const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : ''
347 const jobId = typeof input.jobId === 'string' ? input.jobId.trim() : ''
348 if (!sessionId || !jobId) return { success: false }
349 const job = await ctx.db.getImageFulfillmentJob(jobId)
350 if (!job || job.session_id !== sessionId) return { success: false }
351 const requested = await ctx.db.requestImageFulfillmentCancellation(jobId)
352 const cancelled = coordinator.cancel(jobId) || coordinator.cancel(`${jobId}:commit`)
353 return { success: requested || cancelled }
354 })
355
356 ipcMain.handle('images:retryFulfillment', async (_event, payload: unknown) => {
357 const input = readRecord(payload)
358 const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : ''
359 const jobId = typeof input.jobId === 'string' ? input.jobId.trim() : ''
360 if (!sessionId || !jobId) throw new Error('Session ID and fulfillment job ID are required.')
361 return retryAutomaticImageFulfillment(ctx, coordinator, { sessionId, sourceJobId: jobId })
362 })
363 }
364
364 lines TYPESCRIPT