返回 oh-my-ppt
handlers.ts
根目录 / src / main / image-generation / handlers.ts
1 import { ipcMain } from 'electron'
2 import fs from 'fs'
3 import path from 'path'
4 import { nanoid } from 'nanoid'
5 import log from 'electron-log/main.js'
6 import type {
7 GeneratedImageAsset,
8 ImageGenerationHistoryRecord,
9 ImageModelProvider
10 } from '@shared/image-generation'
11 import { resolveModelTimeoutMs } from '@shared/model-timeout'
12 import type { IpcContext } from '../ipc/context'
13 import { readAppLocale, uiText, type AppLocale } from '../config/locale-utils'
14 import {
15 resolveGlobalModelTimeouts,
16 resolveModelConfigForTask,
17 type ActiveModelConfig
18 } from '../config/model-config-utils'
19 import { allowLocalAssetRoot } from '../io/local-asset-roots'
20 import { extractModelText, resolveModel } from '../agent-runtime/model'
21 import { buildImagePromptGenerationMessages } from '../agent-runtime/prompt'
22 import { resolveImageGenerationProvider } from '../agent-runtime/provider/image'
23 import {
24 getImageModelDisplayName,
25 resolveActiveOrSelectedImageModel
26 } from './model-config'
27 import {
28 compactPageHtmlForImagePrompt,
29 normalizeGeneratedImagePrompt
30 } from './prompt-director'
31 import {
32 imageHistoryLockKey,
33 JobCoordinator,
34 type TypedEventBus
35 } from '../agent-runtime'
36 import {
37 getImageRunState,
38 setImageRunState,
39 type ImageRunState,
40 type ImageRunStatus
41 } from './run-state'
42
43 const resolvePageContext = async (
44 ctx: IpcContext,
45 sessionId: string,
46 pageId: string
47 ): Promise<{ pageId: string; title: string; contentOutline: string; htmlPath: string }> => {
48 const pages = await ctx.db.listSessionPages(sessionId)
49 const page = pages.find(
50 (item) => item.id === pageId || item.file_slug === pageId || item.legacy_page_id === pageId
51 )
52 if (!page) throw new Error('请先选择一个可用页面。')
53 const snapshots = await ctx.db.listLatestGenerationPageSnapshot(sessionId)
54 const snapshot = snapshots.find(
55 (item) => item.page_id === page.id || item.page_id === page.file_slug || item.page_id === page.legacy_page_id
56 )
57 return {
58 pageId: page.id,
59 title: page.title || snapshot?.title || `Page ${page.page_number}`,
60 contentOutline: snapshot?.content_outline || '',
61 htmlPath: page.html_path
62 }
63 }
64
65 const sanitizeExt = (extension: string): string =>
66 /^\.[a-z0-9]{2,5}$/i.test(extension) ? extension.toLowerCase() : '.png'
67
68 const resolvePromptModelConfig = async (
69 ctx: IpcContext,
70 modelConfigId?: string
71 ): Promise<ActiveModelConfig> => {
72 return resolveModelConfigForTask(ctx, {
73 modelConfigId,
74 purpose: 'images:generatePrompt'
75 })
76 }
77
78 export function registerImageGenerationHandlers(
79 ctx: IpcContext,
80 coordinator: JobCoordinator,
81 runtimeEvents: TypedEventBus
82 ): void {
83 const emitImageProgress = (state: ImageRunState): void => {
84 runtimeEvents.emit({
85 type: 'image.progress',
86 payload: {
87 runId: state.runId,
88 sessionId: state.sessionId,
89 pageId: state.pageId,
90 progress: state.progress,
91 label: state.label,
92 status: state.status
93 },
94 jobId: state.runId,
95 domain: 'image',
96 owner: { sessionId: state.sessionId, imageHistoryOwner: state.sessionId },
97 audience: { kind: 'broadcast' },
98 occurredAt: state.updatedAt
99 })
100 }
101
102 const updateImageRunState = (state: ImageRunState): void => {
103 setImageRunState(state)
104 emitImageProgress(state)
105 }
106
107 const emitImageJobTerminalEvent = (args: {
108 runId: string
109 sessionId: string
110 status: Exclude<ImageRunStatus, 'running'>
111 errorMessage?: string
112 }): void => {
113 runtimeEvents.emit({
114 type:
115 args.status === 'completed'
116 ? 'job.completed'
117 : args.status === 'cancelled'
118 ? 'job.cancelled'
119 : 'job.failed',
120 payload:
121 args.status === 'completed'
122 ? {}
123 : args.status === 'cancelled'
124 ? { reason: 'user' }
125 : {
126 errorCode: 'image_generation_failed',
127 errorMessage: args.errorMessage || 'Image generation failed'
128 },
129 jobId: args.runId,
130 domain: 'image',
131 owner: { sessionId: args.sessionId, imageHistoryOwner: args.sessionId },
132 audience: { kind: 'broadcast' },
133 occurredAt: Date.now()
134 })
135 }
136
137 const throwIfCancelled = (signal: AbortSignal, locale: AppLocale): void => {
138 if (signal.aborted) {
139 throw new Error(uiText(locale, '已取消生图', 'Image generation cancelled'))
140 }
141 }
142
143 ipcMain.handle('images:generatePrompt', async (_event, payload) => {
144 const locale = await readAppLocale(ctx)
145 const record =
146 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
147 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
148 const htmlPath = typeof record.htmlPath === 'string' ? record.htmlPath.trim() : ''
149 if (!sessionId) throw new Error(uiText(locale, '会话 ID 不能为空。', 'Session ID is required.'))
150 if (!htmlPath) {
151 throw new Error(
152 uiText(locale, '当前页文件地址不能为空。', 'Current page file path is required.')
153 )
154 }
155
156 const safeHtmlPath = await ctx.assertPathInAllowedRoots({
157 filePath: htmlPath,
158 mode: 'read',
159 sessionId,
160 htmlOnly: true
161 })
162 const pageHtml = compactPageHtmlForImagePrompt(await fs.promises.readFile(safeHtmlPath, 'utf-8'))
163 if (!pageHtml) {
164 throw new Error(uiText(locale, '当前页内容为空。', 'Current page content is empty.'))
165 }
166
167 const activeModel = await resolvePromptModelConfig(
168 ctx,
169 typeof record.modelConfigId === 'string' ? record.modelConfigId : undefined
170 )
171 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
172 const timeoutMs = resolveModelTimeoutMs(modelTimeouts.agent, 'agent')
173 const model = resolveModel(
174 activeModel.provider,
175 activeModel.apiKey,
176 activeModel.model,
177 activeModel.baseUrl,
178 0.45,
179 activeModel.maxTokens,
180 ctx.modelRuntime
181 )
182 const userPrompt = typeof record.userPrompt === 'string' ? record.userPrompt.trim() : ''
183 const pageTitle = typeof record.pageTitle === 'string' ? record.pageTitle.trim() : ''
184 const pageOutline = typeof record.pageOutline === 'string' ? record.pageOutline.trim() : ''
185 log.info('[images:generatePrompt] start', {
186 sessionId,
187 htmlPath: safeHtmlPath,
188 modelConfigId: activeModel.id,
189 model: activeModel.model,
190 htmlLength: pageHtml.length,
191 userPromptLength: userPrompt.length,
192 pageTitleLength: pageTitle.length,
193 pageOutlineLength: pageOutline.length
194 })
195
196 const response = await model.invoke(
197 buildImagePromptGenerationMessages({
198 locale,
199 userPrompt,
200 pageTitle,
201 pageOutline,
202 pageHtml
203 }),
204 { signal: AbortSignal.timeout(timeoutMs) }
205 )
206 const prompt = normalizeGeneratedImagePrompt(extractModelText(response))
207 if (!prompt) {
208 throw new Error(uiText(locale, '模型未返回提示词。', 'The model returned an empty prompt.'))
209 }
210 log.info('[images:generatePrompt] completed', {
211 sessionId,
212 promptLength: prompt.length
213 })
214 return { prompt }
215 })
216
217 ipcMain.handle('images:generate', async (_event, payload) => {
218 const locale = await readAppLocale(ctx)
219 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
220 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
221 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
222 const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : ''
223 if (!sessionId) throw new Error(uiText(locale, '会话 ID 不能为空。', 'Session ID is required.'))
224 if (!pageId) throw new Error(uiText(locale, '请先选择页面。', 'Select a page first.'))
225 if (!prompt) throw new Error(uiText(locale, '请先填写图片描述。', 'Enter an image prompt first.'))
226
227 const count =
228 typeof record.count === 'number' && record.count > 0
229 ? Math.min(Math.floor(record.count), 4)
230 : 1
231 const size = typeof record.size === 'string' && record.size.trim() ? record.size.trim() : '16:9'
232 const runId = nanoid(12)
233 const reservation = await coordinator.reserve({
234 jobId: runId,
235 domain: 'image',
236 owner: { kind: 'image-history', id: sessionId },
237 claims: { write: [imageHistoryLockKey(sessionId)] },
238 wait: 'fail'
239 })
240 if (reservation.status === 'busy') {
241 throw new Error(
242 uiText(
243 locale,
244 '当前会话已有图片生成任务正在进行。',
245 'An image generation task is already running for this session.'
246 )
247 )
248 }
249
250 const lease = reservation.lease
251 const startedAt = Date.now()
252 let resolvedPageId = pageId
253 let resolvedProvider: ImageModelProvider | undefined
254 const uncommittedImagePaths: string[] = []
255 let historyCommitted = false
256 const initialState: ImageRunState = {
257 runId,
258 sessionId,
259 pageId,
260 progress: 5,
261 label: uiText(locale, '准备生图', 'Preparing image generation'),
262 status: 'running',
263 updatedAt: Date.now()
264 }
265 setImageRunState(initialState)
266 runtimeEvents.emit({
267 type: 'job.started',
268 payload: {},
269 jobId: runId,
270 domain: 'image',
271 owner: { sessionId, imageHistoryOwner: sessionId },
272 audience: { kind: 'broadcast' },
273 occurredAt: startedAt
274 })
275 emitImageProgress(initialState)
276
277 try {
278 const modelConfig = await resolveActiveOrSelectedImageModel(
279 ctx,
280 typeof record.imageModelConfigId === 'string'
281 ? record.imageModelConfigId
282 : typeof record.modelConfigId === 'string'
283 ? record.modelConfigId
284 : undefined
285 )
286 throwIfCancelled(lease.signal, locale)
287 const pageContext = await resolvePageContext(ctx, sessionId, pageId)
288 throwIfCancelled(lease.signal, locale)
289 resolvedPageId = pageContext.pageId
290 resolvedProvider = modelConfig.provider
291 const displayModel = getImageModelDisplayName(modelConfig)
292 log.info('[images:generate] start', {
293 runId,
294 sessionId,
295 pageId: pageContext.pageId,
296 requestedPageId: pageId,
297 pageTitle: pageContext.title,
298 modelConfigId: modelConfig.id,
299 modelConfigName: modelConfig.name,
300 provider: modelConfig.provider,
301 model: displayModel,
302 count,
303 size,
304 promptLength: prompt.length,
305 negativePromptLength:
306 typeof record.negativePrompt === 'string' ? record.negativePrompt.length : 0,
307 hasSeed: typeof record.seed === 'number'
308 })
309 const adapter = resolveImageGenerationProvider(modelConfig.provider)
310 const results = await adapter.generate(modelConfig, {
311 prompt,
312 count,
313 size,
314 negativePrompt: typeof record.negativePrompt === 'string' ? record.negativePrompt : undefined,
315 seed: typeof record.seed === 'number' ? record.seed : undefined,
316 signal: lease.signal
317 })
318 throwIfCancelled(lease.signal, locale)
319 log.info('[images:generate] provider returned', {
320 runId,
321 provider: modelConfig.provider,
322 resultCount: results.length,
323 elapsedMs: Date.now() - startedAt
324 })
325 updateImageRunState({
326 runId,
327 sessionId,
328 pageId: pageContext.pageId,
329 progress: 80,
330 label: uiText(locale, '正在保存图片', 'Saving images'),
331 status: 'running',
332 updatedAt: Date.now()
333 })
334 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
335 throwIfCancelled(lease.signal, locale)
336 const imagesDir = path.join(projectDir, 'images')
337 log.info('[images:generate] save start', {
338 runId,
339 imagesDir,
340 resultCount: results.length
341 })
342 await fs.promises.mkdir(imagesDir, { recursive: true })
343 allowLocalAssetRoot(imagesDir)
344 const createdAt = Math.floor(Date.now() / 1000)
345 const assets: GeneratedImageAsset[] = []
346 for (const result of results) {
347 throwIfCancelled(lease.signal, locale)
348 const id = nanoid(10)
349 const extension = sanitizeExt(result.extension)
350 const fileName = `${ctx.toSafeAssetBaseName(`generated-${pageContext.title}`)}-${id}${extension}`
351 const absolutePath = path.join(imagesDir, fileName)
352 await fs.promises.writeFile(absolutePath, result.bytes)
353 uncommittedImagePaths.push(absolutePath)
354 const stat = await fs.promises.stat(absolutePath)
355 log.info('[images:generate] asset saved', {
356 runId,
357 assetId: id,
358 fileName,
359 mimeType: result.mimeType,
360 size: stat.size
361 })
362 assets.push({
363 id,
364 fileName,
365 originalName: fileName,
366 relativePath: `./images/${fileName}`,
367 absolutePath,
368 mimeType: result.mimeType,
369 size: stat.size,
370 prompt,
371 modelConfigId: modelConfig.id,
372 provider: modelConfig.provider,
373 model: displayModel,
374 pageId: pageContext.pageId,
375 createdAt
376 })
377 }
378 throwIfCancelled(lease.signal, locale)
379 const historyId = await ctx.db.insertImageGenerationHistory({
380 sessionId,
381 pageId: pageContext.pageId,
382 prompt,
383 imagePaths: assets.map((asset) => asset.relativePath),
384 modelConfigId: modelConfig.id,
385 provider: modelConfig.provider,
386 model: displayModel,
387 createdAt
388 })
389 historyCommitted = true
390 const history: ImageGenerationHistoryRecord = {
391 id: historyId,
392 sessionId,
393 pageId: pageContext.pageId,
394 prompt,
395 imagePaths: assets.map((asset) => asset.relativePath),
396 assets,
397 modelConfigId: modelConfig.id,
398 provider: modelConfig.provider,
399 model: displayModel,
400 createdAt
401 }
402 log.info('[images:generate] history saved', {
403 runId,
404 historyId,
405 imagePathCount: history.imagePaths.length
406 })
407 updateImageRunState({
408 runId,
409 sessionId,
410 pageId: pageContext.pageId,
411 progress: 100,
412 label: uiText(locale, '生图完成', 'Image generation completed'),
413 status: 'completed',
414 updatedAt: Date.now()
415 })
416 emitImageJobTerminalEvent({ runId, sessionId, status: 'completed' })
417 log.info('[images:generate] completed', {
418 runId,
419 sessionId,
420 pageId: pageContext.pageId,
421 assetCount: assets.length,
422 elapsedMs: Date.now() - startedAt
423 })
424 return { history }
425 } catch (error) {
426 if (!historyCommitted && uncommittedImagePaths.length > 0) {
427 const cleanupResults = await Promise.allSettled(
428 uncommittedImagePaths.map((filePath) => fs.promises.rm(filePath, { force: true }))
429 )
430 const cleanupFailures = cleanupResults.filter((result) => result.status === 'rejected')
431 if (cleanupFailures.length > 0) {
432 log.warn('[images:generate] failed to clean up uncommitted assets', {
433 runId,
434 failureCount: cleanupFailures.length
435 })
436 }
437 }
438 const message = error instanceof Error ? error.message : String(error)
439 const wasCancelled = lease.signal.aborted
440 const logPayload = {
441 runId,
442 sessionId,
443 pageId: resolvedPageId,
444 provider: resolvedProvider,
445 message,
446 elapsedMs: Date.now() - startedAt
447 }
448 if (wasCancelled) {
449 log.warn('[images:generate] cancelled', logPayload)
450 } else {
451 log.error('[images:generate] failed', logPayload)
452 }
453 updateImageRunState({
454 runId,
455 sessionId,
456 pageId: resolvedPageId,
457 progress: 100,
458 label: wasCancelled ? uiText(locale, '已取消生图', 'Image generation cancelled') : message,
459 status: wasCancelled ? 'cancelled' : 'failed',
460 error: message,
461 updatedAt: Date.now()
462 })
463 emitImageJobTerminalEvent({
464 runId,
465 sessionId,
466 status: wasCancelled ? 'cancelled' : 'failed',
467 errorMessage: message
468 })
469 throw error
470 } finally {
471 lease.release()
472 }
473 })
474
475 ipcMain.handle('images:cancel', async (_event, sessionId) => {
476 if (typeof sessionId !== 'string' || !sessionId.trim()) return { success: false }
477 const state = getImageRunState(sessionId.trim())
478 if (!state || state.status !== 'running') return { success: false }
479 log.warn('[images:cancel] abort requested', {
480 runId: state.runId,
481 sessionId: state.sessionId,
482 pageId: state.pageId,
483 progress: state.progress
484 })
485 return { success: coordinator.cancel(state.runId) }
486 })
487
488 ipcMain.handle('images:getState', async (_event, sessionId) => {
489 if (typeof sessionId !== 'string' || !sessionId.trim()) return null
490 const state = getImageRunState(sessionId.trim())
491 if (!state) return null
492 return {
493 runId: state.runId,
494 sessionId: state.sessionId,
495 pageId: state.pageId,
496 progress: state.progress,
497 label: state.label,
498 status: state.status,
499 error: state.error || null,
500 updatedAt: state.updatedAt
501 }
502 })
503 }
504
504 lines TYPESCRIPT