| 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 |