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