返回 oh-my-ppt
handlers.ts
根目录 / src / main / generation / handlers.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import log from 'electron-log/main.js'
4 import { getSessionRunPageCounts } from '../ipc/runtime/session-run-state'
5 import { createEmitAssistantMessage } from './generation-utils'
6 import { executeDeckGeneration, resolveDeckContext } from './deck-flow'
7 import {
8 executeTemplateDeckGeneration,
9 resolveTemplateDeckContext
10 } from './template-deck-flow'
11 import { executeRetryFailedPages, resolveRetryContext } from './retry-flow'
12 import type { DeckContext, RetryContext } from './types'
13 import {
14 resolveAddPageContext,
15 executeAddPageGeneration,
16 type AddPageContext
17 } from './add-page-flow'
18 import {
19 resolveRetrySinglePageContext,
20 executeRetrySinglePageGeneration,
21 type RetrySinglePageContext
22 } from './retry-single-page-flow'
23 import { finalizeGenerationFailure } from './finalization'
24 import { GenerateJobManager } from './job-manager'
25 import { JobCoordinator } from '../agent-runtime'
26 import type { GenerationContext } from './context'
27 import type { DeckEditJobService } from '../edit-jobs/deck-edit-job-service'
28 import type { PageEditJobService } from '../edit-jobs/page-edit-job-service'
29 import type { StyleSwitchJobService } from '../edit-jobs/style-switch-job-service'
30
31 export function registerGenerationHandlers(
32 ctx: GenerationContext,
33 coordinator: JobCoordinator,
34 styleSwitchJobs: StyleSwitchJobService,
35 pageEditJobs?: PageEditJobService,
36 deckEditJobs?: DeckEditJobService
37 ): void {
38 const {
39 db,
40 agentManager,
41 sessionRuns,
42 runtimeEmitters
43 } = ctx
44 const { sessionRunStates, pruneFinishedSessionRunStates } = sessionRuns
45 const { emitGenerateChunk } = runtimeEmitters
46 const emitAssistant = createEmitAssistantMessage(db, emitGenerateChunk)
47 const jobManager = new GenerateJobManager(ctx, coordinator)
48 const interruptedJobsReady = jobManager
49 .abortInterruptedJobs('应用退出导致生成中断,可继续生成')
50 .catch((error) => {
51 log.warn('[generate:job] failed to abort interrupted jobs', {
52 message: error instanceof Error ? error.message : String(error)
53 })
54 })
55
56 const logPreContextFailure = (operation: string, sessionId: string, error: unknown): void => {
57 log.error(`[${operation}] failed before context`, {
58 sessionId,
59 message: error instanceof Error ? error.message : String(error)
60 })
61 }
62
63 const getSessionPageStatusSnapshot = async (
64 sessionId: string
65 ): Promise<{ completed: number; failedKeys: string[] }> => {
66 const pages = await db.listSessionPages(sessionId)
67 return {
68 completed: pages.filter((page) => page.status === 'completed').length,
69 failedKeys: pages
70 .filter((page) => page.status === 'failed')
71 .map((page) => page.file_slug || page.legacy_page_id || page.id)
72 .filter((pageKey) => pageKey.length > 0)
73 }
74 }
75
76 ipcMain.handle('generate:state', async (_event, rawSessionId: unknown) => {
77 await interruptedJobsReady
78 pruneFinishedSessionRunStates()
79 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
80 if (!sessionId) {
81 throw new Error('sessionId 不能为空')
82 }
83
84 const activeState = sessionRunStates.get(sessionId)
85 if (activeState) {
86 const pageCounts = getSessionRunPageCounts(activeState)
87 return {
88 sessionId,
89 runId: activeState.runId,
90 status: activeState.status,
91 hasActiveRun: activeState.status === 'queued' || activeState.status === 'running',
92 progress: activeState.progress,
93 totalPages: activeState.totalPages,
94 completedPageCount: pageCounts.completedPageCount,
95 failedPageCount: pageCounts.failedPageCount,
96 events: activeState.events,
97 error: activeState.error,
98 startedAt: activeState.startedAt,
99 updatedAt: activeState.updatedAt,
100 kind: activeState.kind,
101 activityKind: activeState.activityKind,
102 targetPageId: activeState.targetPageId,
103 targetPageNumber: activeState.targetPageNumber
104 }
105 }
106
107 const latestJob = await db.getLatestSessionJob(sessionId, [
108 'standard',
109 'template',
110 'retry',
111 'add-page',
112 'single-page-retry'
113 ])
114 if (latestJob) {
115 const generationRun = await db.getGenerationRun(latestJob.id)
116 const session = await db.getSession(sessionId)
117 const sessionRecord = (session || {}) as Record<string, unknown>
118 const pageCount = Number(sessionRecord.page_count ?? sessionRecord.pageCount ?? 1) || 1
119 const status =
120 latestJob.status === 'pending'
121 ? 'queued'
122 : latestJob.status === 'active'
123 ? 'running'
124 : latestJob.status === 'aborted'
125 ? generationRun?.error && /取消|cancel/i.test(generationRun.error)
126 ? 'cancelled'
127 : 'failed'
128 : generationRun?.status === 'completed'
129 ? 'completed'
130 : generationRun?.status === 'failed' || generationRun?.status === 'partial'
131 ? 'failed'
132 : 'idle'
133 return {
134 sessionId,
135 runId: latestJob.id,
136 status,
137 hasActiveRun: latestJob.status === 'pending' || latestJob.status === 'active',
138 progress: status === 'completed' ? 100 : 0,
139 totalPages: Math.max(1, Math.floor(generationRun?.total_pages || pageCount)),
140 completedPageCount: 0,
141 failedPageCount: 0,
142 events: [],
143 error: generationRun?.error || latestJob.abort_reason || null,
144 startedAt: (latestJob.activated_at || latestJob.created_at) * 1000,
145 updatedAt: latestJob.updated_at * 1000,
146 kind: latestJob.kind,
147 activityKind:
148 generationRun?.mode === 'addPage'
149 ? 'addPage'
150 : generationRun?.mode === 'retrySinglePage'
151 ? 'single-page-retry'
152 : undefined,
153 targetPageId: latestJob.target_page_id || undefined,
154 targetPageNumber: latestJob.target_page_number || undefined
155 }
156 }
157
158 const session = await db.getSession(sessionId)
159 const sessionRecord = (session || {}) as Record<string, unknown>
160 const sessionStatus = String(sessionRecord.status || 'active')
161 const normalizedStatus =
162 sessionStatus === 'completed' ? 'completed' : sessionStatus === 'failed' ? 'failed' : 'idle'
163 const pageCount = Number(sessionRecord.page_count ?? sessionRecord.pageCount ?? 1) || 1
164 return {
165 sessionId,
166 runId: null,
167 status: normalizedStatus,
168 hasActiveRun: false,
169 progress: normalizedStatus === 'completed' ? 100 : 0,
170 totalPages: Math.max(1, Math.floor(pageCount)),
171 completedPageCount: 0,
172 failedPageCount: 0,
173 events: [],
174 error: null,
175 startedAt: null,
176 updatedAt: null
177 }
178 })
179
180 ipcMain.handle('generate:listActive', async () => {
181 await interruptedJobsReady
182 pruneFinishedSessionRunStates()
183 const jobs = await db.listActiveSessionJobs([
184 'standard',
185 'template',
186 'retry',
187 'add-page',
188 'single-page-retry'
189 ])
190 return jobs.flatMap((job) => {
191 const state = sessionRunStates.get(job.session_id)
192 if (state?.runId === job.id && state.status !== 'queued' && state.status !== 'running') {
193 return []
194 }
195 return [
196 {
197 sessionId: job.session_id,
198 runId: job.id,
199 status: job.status === 'pending' ? 'queued' : 'running',
200 hasActiveRun: true,
201 progress: state?.progress ?? 0,
202 totalPages: state?.totalPages ?? 1,
203 ...(state
204 ? getSessionRunPageCounts(state)
205 : { completedPageCount: 0, failedPageCount: 0 }),
206 events: state?.events ?? [],
207 error: state?.error ?? null,
208 startedAt: state?.startedAt ?? (job.activated_at || job.created_at) * 1000,
209 updatedAt: state?.updatedAt ?? job.updated_at * 1000,
210 kind: job.kind,
211 activityKind:
212 state?.activityKind ||
213 (job.kind === 'add-page'
214 ? 'addPage'
215 : job.kind === 'single-page-retry'
216 ? 'single-page-retry'
217 : undefined),
218 targetPageId: state?.targetPageId || job.target_page_id || undefined,
219 targetPageNumber: state?.targetPageNumber || job.target_page_number || undefined
220 }
221 ]
222 })
223 })
224
225 ipcMain.handle('generate:start', async (event, payload) => {
226 await interruptedJobsReady
227 pruneFinishedSessionRunStates()
228 const requestedSessionId =
229 payload &&
230 typeof payload === 'object' &&
231 typeof (payload as { sessionId?: unknown }).sessionId === 'string'
232 ? String((payload as { sessionId?: string }).sessionId).trim()
233 : ''
234 const reservation = requestedSessionId
235 ? await jobManager.reserve('generate:start', requestedSessionId, crypto.randomUUID())
236 : null
237 if (reservation?.alreadyRunning) {
238 return { success: true, runId: reservation.runId, alreadyRunning: true }
239 }
240 const reserved = reservation?.alreadyRunning === false ? reservation.reservation : null
241
242 let context: DeckContext | null = null
243 let handedToBackground = false
244 try {
245 const requestedType =
246 payload && typeof payload === 'object' && (payload as { type?: unknown }).type === 'page'
247 ? 'page'
248 : 'deck'
249 const requestedChatType =
250 payload &&
251 typeof payload === 'object' &&
252 (payload as { chatType?: unknown }).chatType === 'main'
253 ? 'main'
254 : 'page'
255 if (requestedType === 'page' && requestedChatType === 'page') {
256 throw new Error('单页编辑请使用 page-edit:start')
257 }
258 if (requestedType === 'page' && requestedChatType === 'main') {
259 throw new Error('主会话编辑请使用 deck-edit:start')
260 }
261 if (!reserved) throw new Error('生成任务 reservation 缺失')
262 context = await resolveDeckContext(ctx, event, payload, {
263 runId: reserved.jobId,
264 abortSignal: reserved.signal
265 })
266 jobManager.assertNotCancelled(reserved)
267 if (context.effectiveMode !== 'generate') {
268 throw new Error('非主会话编辑不能进入通用生成队列')
269 }
270 const deckContext = context
271 const result = await jobManager.enqueue({
272 reservation: reserved,
273 kind: 'standard',
274 context: deckContext,
275 totalPages: deckContext.totalPages,
276 execute: (deckContext) => executeDeckGeneration(ctx, emitAssistant, deckContext)
277 })
278 handedToBackground = true
279 return { success: true, runId: result.runId, queued: result.queued }
280 } catch (error) {
281 if (context && !handedToBackground) {
282 await finalizeGenerationFailure(ctx, context, error)
283 } else {
284 logPreContextFailure('generate:start', requestedSessionId, error)
285 }
286 throw error
287 } finally {
288 if (!handedToBackground) {
289 jobManager.release(reserved)
290 }
291 if (context && !handedToBackground) {
292 agentManager.removeSession(context.sessionId)
293 }
294 }
295 })
296
297 ipcMain.handle('generate:switchStyle', async (event, payload) => {
298 return styleSwitchJobs.start(event, payload)
299 })
300
301 ipcMain.handle('generate:retryStyleSwitch', async (event, payload) => {
302 return styleSwitchJobs.retryFailed(event, payload)
303 })
304
305 ipcMain.handle('generate:retryDeckEdit', async (event, payload) => {
306 await interruptedJobsReady
307 pruneFinishedSessionRunStates()
308 if (!deckEditJobs) throw new Error('deck-edit job service is unavailable')
309 return deckEditJobs.retry(event, payload)
310 })
311
312 ipcMain.handle('generate:startTemplate', async (event, payload) => {
313 await interruptedJobsReady
314 pruneFinishedSessionRunStates()
315 const requestedSessionId =
316 payload &&
317 typeof payload === 'object' &&
318 typeof (payload as { sessionId?: unknown }).sessionId === 'string'
319 ? String((payload as { sessionId?: string }).sessionId).trim()
320 : ''
321 const reservation = requestedSessionId
322 ? await jobManager.reserve('generate:startTemplate', requestedSessionId, crypto.randomUUID())
323 : null
324 if (reservation?.alreadyRunning) {
325 return { success: true, runId: reservation.runId, alreadyRunning: true }
326 }
327 const reserved = reservation?.alreadyRunning === false ? reservation.reservation : null
328
329 let context: Awaited<ReturnType<typeof resolveTemplateDeckContext>> | null = null
330 let handedToBackground = false
331 try {
332 if (!reserved) throw new Error('生成任务 reservation 缺失')
333 context = await resolveTemplateDeckContext(ctx, event, payload, {
334 runId: reserved.jobId,
335 abortSignal: reserved.signal
336 })
337 jobManager.assertNotCancelled(reserved)
338 const templateBaseSnapshot = context.templateRetry
339 ? await getSessionPageStatusSnapshot(context.sessionId)
340 : { completed: 0, failedKeys: [] }
341 const result = await jobManager.enqueue({
342 reservation: reserved,
343 kind: 'template',
344 context,
345 totalPages: context.totalPages,
346 completedPageBaseCount: templateBaseSnapshot.completed,
347 failedPageBaseKeys: templateBaseSnapshot.failedKeys,
348 execute: (templateContext) =>
349 executeTemplateDeckGeneration(ctx, emitAssistant, templateContext)
350 })
351 handedToBackground = true
352 return { success: true, runId: result.runId, queued: result.queued }
353 } catch (error) {
354 if (context && !handedToBackground) {
355 await finalizeGenerationFailure(ctx, context, error)
356 } else {
357 logPreContextFailure('generate:startTemplate', requestedSessionId, error)
358 }
359 throw error
360 } finally {
361 if (!handedToBackground) {
362 jobManager.release(reserved)
363 }
364 if (context && !handedToBackground) {
365 agentManager.removeSession(context.sessionId)
366 }
367 }
368 })
369
370 ipcMain.handle('generate:retryFailedPages', async (event, payload) => {
371 await interruptedJobsReady
372 pruneFinishedSessionRunStates()
373 const requestedSessionId =
374 payload &&
375 typeof payload === 'object' &&
376 typeof (payload as { sessionId?: unknown }).sessionId === 'string'
377 ? String((payload as { sessionId?: string }).sessionId).trim()
378 : ''
379 const reservation = requestedSessionId
380 ? await jobManager.reserve('generate:retryFailedPages', requestedSessionId, crypto.randomUUID())
381 : null
382 if (reservation?.alreadyRunning) {
383 return { success: true, runId: reservation.runId, alreadyRunning: true }
384 }
385
386 const reserved = reservation?.alreadyRunning === false ? reservation.reservation : null
387 let context: RetryContext | null = null
388 let handedToBackground = false
389 try {
390 if (!reserved) throw new Error('生成任务 reservation 缺失')
391 context = await resolveRetryContext(ctx, event, payload, {
392 runId: reserved.jobId,
393 abortSignal: reserved.signal
394 })
395 jobManager.assertNotCancelled(reserved)
396 const retryTotalPages = Math.max(
397 1,
398 (await db.listLatestGenerationPageSnapshot(context.sessionId)).filter(
399 (page) => page.status !== 'completed'
400 ).length || context.totalPages
401 )
402 const retryBaseSnapshot = await getSessionPageStatusSnapshot(context.sessionId)
403 jobManager.assertNotCancelled(reserved)
404 const result = await jobManager.enqueue({
405 reservation: reserved,
406 kind: 'retry',
407 context,
408 totalPages: retryTotalPages,
409 completedPageBaseCount: retryBaseSnapshot.completed,
410 failedPageBaseKeys: retryBaseSnapshot.failedKeys,
411 execute: (retryContext) => executeRetryFailedPages(ctx, emitAssistant, retryContext)
412 })
413 handedToBackground = true
414 return { success: true, runId: result.runId, queued: result.queued }
415 } catch (error) {
416 if (context && !handedToBackground) {
417 await finalizeGenerationFailure(ctx, context, error)
418 } else {
419 logPreContextFailure('generate:retryFailedPages', requestedSessionId, error)
420 }
421 throw error
422 } finally {
423 if (!handedToBackground) {
424 jobManager.release(reserved)
425 }
426 if (context && !handedToBackground) {
427 agentManager.removeSession(context.sessionId)
428 }
429 }
430 })
431
432 ipcMain.handle('generate:addPage', async (_event, payload) => {
433 await interruptedJobsReady
434 pruneFinishedSessionRunStates()
435 const addPagePayload =
436 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
437 const requestedSessionId =
438 typeof addPagePayload.sessionId === 'string' ? addPagePayload.sessionId.trim() : ''
439 if (!requestedSessionId) {
440 throw new Error('sessionId 不能为空')
441 }
442 const userMsg =
443 typeof addPagePayload.userMessage === 'string' ? addPagePayload.userMessage.trim() : ''
444 const targetPageId =
445 typeof addPagePayload.targetPageId === 'string' ? addPagePayload.targetPageId.trim() : ''
446 if (!userMsg) {
447 throw new Error('userMessage is required for addPage')
448 }
449
450 const reservation = await jobManager.reserve(
451 'generate:addPage',
452 requestedSessionId,
453 crypto.randomUUID()
454 )
455 if (reservation.alreadyRunning) {
456 return { success: true, runId: reservation.runId, alreadyRunning: true }
457 }
458
459 const reserved = reservation.reservation
460 let addPageCtx: AddPageContext | null = null
461 let handedToBackground = false
462 try {
463 const insertAfter = Number(addPagePayload.insertAfterPageNumber) || 0
464
465 // Resolve context independently — no shared resolveGenerationContext
466 const modelConfigId =
467 typeof addPagePayload.modelConfigId === 'string'
468 ? addPagePayload.modelConfigId.trim()
469 : undefined
470 addPageCtx = await resolveAddPageContext(
471 ctx,
472 requestedSessionId,
473 userMsg,
474 insertAfter,
475 modelConfigId,
476 targetPageId || undefined,
477 { runId: reserved.jobId, abortSignal: reserved.signal }
478 )
479 jobManager.assertNotCancelled(reserved)
480 const addPageContext = addPageCtx
481 if (!addPageContext) throw new Error('新增页面生成上下文缺失')
482
483 // Persist user message
484 await db.addMessage(addPageContext.sessionId, {
485 role: 'user',
486 content: userMsg,
487 type: 'text',
488 chat_scope: 'main' as const,
489 run_model: addPageContext.runModel
490 })
491 jobManager.assertNotCancelled(reserved)
492 const targetPage = addPageContext.targetPageId
493 ? (await db.listSessionPages(addPageContext.sessionId)).find(
494 (page) =>
495 page.id === addPageContext.targetPageId || page.file_slug === addPageContext.targetPageId
496 )
497 : undefined
498 jobManager.assertNotCancelled(reserved)
499 if (targetPage) {
500 await db.upsertSessionPage({
501 id: targetPage.id,
502 sessionId: targetPage.session_id,
503 legacyPageId: targetPage.legacy_page_id,
504 fileSlug: targetPage.file_slug,
505 pageNumber: targetPage.page_number,
506 title: targetPage.title,
507 htmlPath: targetPage.html_path,
508 status: 'pending',
509 error: null
510 })
511 }
512 jobManager.assertNotCancelled(reserved)
513 const result = await jobManager.enqueue({
514 reservation: reserved,
515 kind: 'add-page',
516 context: addPageContext,
517 totalPages: 1,
518 activityKind: 'addPage',
519 targetPageId: targetPage?.id || addPageContext.targetPageId,
520 targetPageNumber: targetPage?.page_number,
521 execute: (context) => executeAddPageGeneration(ctx, context)
522 })
523 handedToBackground = true
524 return { success: true, runId: result.runId, queued: result.queued }
525 } catch (error) {
526 if (addPageCtx && !handedToBackground) {
527 await finalizeGenerationFailure(ctx, addPageCtx, error)
528 } else {
529 logPreContextFailure('generate:addPage', requestedSessionId, error)
530 }
531 throw error
532 } finally {
533 if (!handedToBackground) {
534 jobManager.release(reserved)
535 }
536 if (addPageCtx && !handedToBackground) {
537 agentManager.removeSession(addPageCtx.sessionId)
538 }
539 }
540 })
541
542 ipcMain.handle('generate:retrySinglePage', async (_event, payload) => {
543 await interruptedJobsReady
544 pruneFinishedSessionRunStates()
545 const addPagePayload =
546 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
547 const requestedSessionId =
548 typeof addPagePayload.sessionId === 'string' ? addPagePayload.sessionId.trim() : ''
549 const requestedPageId =
550 typeof addPagePayload.pageId === 'string' ? addPagePayload.pageId.trim() : ''
551 if (!requestedSessionId) {
552 throw new Error('sessionId 不能为空')
553 }
554 if (!requestedPageId) {
555 throw new Error('pageId 不能为空')
556 }
557
558 const reservation = await jobManager.reserve(
559 'generate:retrySinglePage',
560 requestedSessionId,
561 crypto.randomUUID()
562 )
563 if (reservation.alreadyRunning) {
564 return { success: true, runId: reservation.runId, alreadyRunning: true }
565 }
566
567 const reserved = reservation.reservation
568 let retryCtx: RetrySinglePageContext | null = null
569 let handedToBackground = false
570 try {
571 const modelConfigId =
572 typeof addPagePayload.modelConfigId === 'string'
573 ? addPagePayload.modelConfigId.trim()
574 : undefined
575 retryCtx = await resolveRetrySinglePageContext(
576 ctx,
577 requestedSessionId,
578 requestedPageId,
579 modelConfigId,
580 { runId: reserved.jobId, abortSignal: reserved.signal }
581 )
582 jobManager.assertNotCancelled(reserved)
583 const result = await jobManager.enqueue({
584 reservation: reserved,
585 kind: 'single-page-retry',
586 context: retryCtx,
587 totalPages: 1,
588 activityKind: 'single-page-retry',
589 targetPageId: retryCtx.pageId,
590 targetPageNumber: retryCtx.pageNumber,
591 execute: (context) => executeRetrySinglePageGeneration(ctx, context)
592 })
593 handedToBackground = true
594 return { success: true, runId: result.runId, queued: result.queued }
595 } catch (error) {
596 if (retryCtx && !handedToBackground) {
597 await finalizeGenerationFailure(ctx, retryCtx, error)
598 } else {
599 logPreContextFailure('generate:retrySinglePage', requestedSessionId, error)
600 }
601 throw error
602 } finally {
603 if (!handedToBackground) {
604 jobManager.release(reserved)
605 }
606 if (retryCtx && !handedToBackground) {
607 agentManager.removeSession(retryCtx.sessionId)
608 }
609 }
610 })
611
612 ipcMain.handle('generate:cancel', async (_event, sessionId) => {
613 await interruptedJobsReady
614 const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : ''
615 const cancelSessionId = normalizedSessionId || String(sessionId || '')
616 if (!cancelSessionId) return { success: true }
617 if (await pageEditJobs?.cancel(cancelSessionId)) return { success: true }
618 if (await deckEditJobs?.cancel(cancelSessionId)) return { success: true }
619 const handledByJobManager = await jobManager.cancel(cancelSessionId)
620 if (handledByJobManager) return { success: true }
621 const activeState = sessionRunStates.get(cancelSessionId)
622 if (activeState?.status === 'queued' || activeState?.status === 'running') {
623 emitGenerateChunk(cancelSessionId, {
624 type: 'run_error',
625 payload: {
626 runId: activeState.runId,
627 message: '生成已取消'
628 }
629 })
630 }
631 return { success: true }
632 })
633 }
634
634 lines TYPESCRIPT