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