返回 oh-my-ppt
retry-flow.ts
根目录 / src / main / generation / retry-flow.ts
1 import type { EmitAssistantFn, RetryContext } from './types'
2 import { resolvePageHtmlPath, uiText } from './generation-utils'
3 import { finalizeGenerationSuccess } from './finalization'
4 import { progressText } from '@shared/progress'
5 import path from 'path'
6 import fs from 'fs'
7 import { normalizeLayoutIntent, type LayoutIntent } from '@shared/layout-intent'
8 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
9 import { buildDesignContractWithLLM, runDeepAgentDeckGeneration } from './agent-runner'
10 import {
11 type DesignContract,
12 resolveInheritedAnimationPreferences,
13 type AnimationPreferencesPayload,
14 type GeneratedPagePayload
15 } from '@shared/generation'
16 import { nanoid } from 'nanoid'
17 import {
18 buildRetryUserMessage,
19 buildTotalPages,
20 type GenerationContext,
21 type RuntimeJobExecutionContext,
22 normalizeGeneratePayload,
23 resolveCommonContext,
24 resolveSourceDocuments
25 } from './context'
26
27 export async function resolveRetryContext(
28 ctx: GenerationContext,
29 _event: Electron.IpcMainInvokeEvent,
30 payload: unknown,
31 execution?: RuntimeJobExecutionContext
32 ): Promise<RetryContext> {
33 const input = normalizeGeneratePayload(payload)
34 if (!input.sessionId) throw new Error('sessionId 不能为空')
35
36 const common = await resolveCommonContext(ctx, input.sessionId, input.modelConfigId, execution)
37 const userMessage = buildRetryUserMessage(input.rawUserMessage)
38 let animationPreferences: AnimationPreferencesPayload | null = null
39 if (input.failedRunId) {
40 const sourceRun = await ctx.db.getGenerationRun(input.failedRunId)
41 animationPreferences = resolveInheritedAnimationPreferences(sourceRun, input.sessionId)
42 }
43 const sourceDocumentPaths = await resolveSourceDocuments(ctx, {
44 sessionId: input.sessionId,
45 projectDir: common.projectDir,
46 rawDocPaths: input.rawDocPaths,
47 mode: 'retry',
48 sessionRecord: common.sessionRecord
49 })
50
51 return {
52 sessionId: input.sessionId,
53 userMessage,
54 requestedType: 'deck',
55 effectiveMode: 'retry',
56 selectedPageId: undefined,
57 selectPageIds: [],
58 htmlPath: undefined,
59 selector: undefined,
60 elementTag: undefined,
61 elementText: undefined,
62 sourceRunId: input.failedRunId,
63 session: common.session,
64 sessionRecord: common.sessionRecord,
65 previousSessionStatus: common.previousSessionStatus,
66 projectDir: common.projectDir,
67 abortSignal: common.abortSignal,
68 runId: common.runId,
69 styleId: common.styleId,
70 styleSkill: common.styleSkill,
71 styleKey: common.styleKey,
72 styleName: common.styleName,
73 styleVersion: common.styleVersion,
74 slideSize: common.slideSize,
75 userProvidedOutlineTitles: [],
76 totalPages: buildTotalPages(common.sessionRecord),
77 provider: common.provider,
78 apiKey: common.apiKey,
79 model: common.model,
80 modelConfigId: common.modelConfigId,
81 modelConfigName: common.modelConfigName,
82 runModel: common.runModel,
83 modelTimeouts: common.modelTimeouts,
84 providerBaseUrl: common.providerBaseUrl,
85 maxTokens: common.maxTokens,
86 modelRuntime: common.modelRuntime,
87 projectId: common.projectId,
88 messageScope: 'main',
89 messagePageId: undefined,
90 imagePaths: [],
91 videoPaths: [],
92 sourceDocumentPaths,
93 sourcePlan: common.sourcePlan,
94 topic: common.topic,
95 deckTitle: common.deckTitle,
96 appLocale: common.appLocale,
97 fontSelection: common.fontSelection,
98 animationPreferences
99 }
100 }
101
102 export async function executeRetryFailedPages(
103 ctx: GenerationContext,
104 emitAssistant: EmitAssistantFn,
105 context: RetryContext
106 ): Promise<void> {
107 const {
108 db,
109 agentManager,
110 runtimeEmitters: { createDeckProgressEmitter },
111 sessionProject: { getPageSourceUrl },
112 tuning: {
113 designContractTemperature: DESIGN_CONTRACT_TEMPERATURE,
114 pageGenerationTemperature: PAGE_GENERATION_TEMPERATURE
115 }
116 } = ctx
117
118 if (!context.apiKey) {
119 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
120 }
121
122 const indexPath = path.join(context.projectDir, 'index.html')
123 const emitRetryChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
124 let savedDesignContract: DesignContract | undefined
125 const sessionRecord = (context.session || {}) as Record<string, unknown>
126 const sessionPages = await db.listSessionPages(context.sessionId)
127 if (sessionPages.length === 0) {
128 throw new Error('session_pages is empty after migration; cannot retry this session')
129 }
130 await ctx.history.ensureBaseline(context.sessionId, context.projectDir)
131 const latestPageSnapshot = await db.listLatestGenerationPageSnapshot(context.sessionId)
132 const failedSessionPages = sessionPages.filter((page) => page.status !== 'completed')
133 const retryRecords = failedSessionPages.map((page) => {
134 const snapshot = latestPageSnapshot.find((item) => item.page_id === page.file_slug)
135 return {
136 page_number: page.page_number,
137 page_id: page.file_slug,
138 title: page.title || snapshot?.title || page.file_slug,
139 content_outline: snapshot?.content_outline || '',
140 layout_intent: snapshot?.layout_intent || null,
141 html_path: resolvePageHtmlPath({
142 projectDir: context.projectDir,
143 fileSlug: page.file_slug,
144 candidates: [page.html_path, snapshot?.html_path]
145 }),
146 retry_count: snapshot?.retry_count || 0,
147 status: page.status,
148 error: page.error
149 }
150 })
151 const completedSessionPageCount = sessionPages.filter(
152 (page) => page.status === 'completed'
153 ).length
154 if (retryRecords.length === 0) {
155 throw new Error('当前会话没有可继续生成的页面。')
156 }
157 if (completedSessionPageCount === 0) {
158 throw new Error('当前没有成功页面可保留,请使用完整重新生成。')
159 }
160 if (
161 typeof sessionRecord.designContract === 'string' &&
162 sessionRecord.designContract.trim().length > 0
163 ) {
164 try {
165 savedDesignContract = JSON.parse(sessionRecord.designContract) as DesignContract
166 } catch {
167 // ignore malformed design contract and rebuild below
168 }
169 }
170 const designContract =
171 savedDesignContract ||
172 (await buildDesignContractWithLLM({
173 provider: context.provider,
174 apiKey: context.apiKey,
175 model: context.model,
176 baseUrl: context.providerBaseUrl,
177 maxTokens: context.maxTokens,
178 modelRuntime: context.modelRuntime,
179 modelTimeoutMs: context.modelTimeouts.design,
180 temperature: DESIGN_CONTRACT_TEMPERATURE,
181 styleId: context.styleId,
182 styleSkillPrompt: context.styleSkill.prompt,
183 styleKey: context.styleKey,
184 styleName: context.styleName,
185 styleVersion: context.styleVersion,
186 appLocale: context.appLocale,
187 totalPages: sessionPages.length,
188 slideSize: context.slideSize,
189 topic: context.topic,
190 userMessage: context.userMessage,
191 fontSelection: context.fontSelection,
192 emit: (chunk) => emitRetryChunk(chunk),
193 runId: context.runId,
194 signal: context.abortSignal
195 }))
196
197 const retryPages = retryRecords.map((page) => ({
198 pageNumber: page.page_number,
199 pageId: page.page_id,
200 title: page.title || page.page_id,
201 contentOutline: page.content_outline || '',
202 layoutIntent: page.layout_intent ? normalizeLayoutIntent(page.layout_intent) : undefined,
203 htmlPath: resolvePageHtmlPath({
204 projectDir: context.projectDir,
205 fileSlug: page.page_id,
206 candidates: [page.html_path]
207 }),
208 retryCount: page.retry_count + 1
209 }))
210 const pageFileMap = Object.fromEntries(retryPages.map((page) => [page.pageId, page.htmlPath]))
211 const pageNumbers = Object.fromEntries(retryPages.map((page) => [page.pageId, page.pageNumber]))
212 const existingSessionPages = await db.listSessionPages(context.sessionId, {
213 includeDeleted: true
214 })
215 const existingSessionPageBySlug = new Map(
216 existingSessionPages.map((page) => [page.file_slug, page])
217 )
218 const upsertRetrySessionPage = async (
219 page: {
220 pageNumber: number
221 pageId: string
222 title: string
223 htmlPath: string
224 },
225 status: 'completed' | 'failed' | 'pending',
226 error: string | null
227 ): Promise<void> => {
228 const existing = existingSessionPageBySlug.get(page.pageId)
229 const id = existing?.id || nanoid()
230 await db.upsertSessionPage({
231 id,
232 sessionId: context.sessionId,
233 legacyPageId:
234 existing?.legacy_page_id || (page.pageId.match(/^page-\d+$/) ? page.pageId : null),
235 fileSlug: page.pageId,
236 pageNumber: page.pageNumber,
237 title: page.title,
238 htmlPath: page.htmlPath,
239 status,
240 error
241 })
242 existingSessionPageBySlug.set(page.pageId, {
243 id,
244 session_id: context.sessionId,
245 legacy_page_id:
246 existing?.legacy_page_id || (page.pageId.match(/^page-\d+$/) ? page.pageId : null),
247 file_slug: page.pageId,
248 page_number: page.pageNumber,
249 title: page.title,
250 html_path: page.htmlPath,
251 status,
252 error,
253 created_at: existing?.created_at || Math.floor(Date.now() / 1000),
254 updated_at: Math.floor(Date.now() / 1000),
255 deleted_at: null
256 })
257 }
258
259 await db.createGenerationRun({
260 id: context.runId,
261 sessionId: context.sessionId,
262 mode: 'retry',
263 totalPages: retryPages.length,
264 modelConfigId: context.modelConfigId,
265 animationPreferences: context.animationPreferences,
266 metadata: {
267 retryOnly: true,
268 source: 'session_pages',
269 pageIds: retryPages.map((page) => page.pageId),
270 inheritedAnimationPreferencesFromRunId: context.animationPreferences
271 ? context.sourceRunId || null
272 : null,
273 modelConfigId: context.modelConfigId,
274 modelConfigName: context.modelConfigName,
275 provider: context.provider,
276 model: context.model
277 }
278 })
279 for (const page of retryPages) {
280 await db.upsertGenerationPage({
281 runId: context.runId,
282 sessionId: context.sessionId,
283 pageId: page.pageId,
284 pageNumber: page.pageNumber,
285 title: page.title,
286 contentOutline: page.contentOutline,
287 layoutIntent: page.layoutIntent,
288 htmlPath: page.htmlPath,
289 status: 'pending',
290 retryCount: page.retryCount
291 })
292 }
293
294 emitRetryChunk({
295 type: 'stage_started',
296 payload: {
297 runId: context.runId,
298 stage: 'rendering',
299 label: uiText(
300 context.appLocale,
301 `正在重新生成 ${retryPages.length} 个失败页面`,
302 `Regenerating ${retryPages.length} failed pages`
303 ),
304 progress: 8,
305 totalPages: retryPages.length
306 }
307 })
308 const persistedRetryCompletedPageIds = new Set<string>()
309 const persistedRetryFailedPageIds = new Set<string>()
310
311 const persistCompletedRetryPage = async (page: {
312 pageNumber: number
313 pageId: string
314 title: string
315 contentOutline: string
316 layoutIntent?: LayoutIntent
317 htmlPath: string
318 }): Promise<void> => {
319 if (!fs.existsSync(page.htmlPath)) {
320 throw new Error(`${page.pageId}.html 缺失`)
321 }
322 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
323 const validation = validatePersistedPageHtml(html, page.pageId)
324 if (!validation.valid) {
325 throw new Error(`HTML 验证失败 (${page.pageId}): ${validation.errors.join('; ')}`)
326 }
327 const retryPage = retryPages.find((item) => item.pageId === page.pageId)
328 await db.upsertGenerationPage({
329 runId: context.runId,
330 sessionId: context.sessionId,
331 pageId: page.pageId,
332 pageNumber: page.pageNumber,
333 title: page.title,
334 contentOutline: page.contentOutline,
335 layoutIntent: page.layoutIntent,
336 htmlPath: page.htmlPath,
337 status: 'completed',
338 retryCount: retryPage?.retryCount || 0
339 })
340 await upsertRetrySessionPage(page, 'completed', null)
341 persistedRetryFailedPageIds.delete(page.pageId)
342 persistedRetryCompletedPageIds.add(page.pageId)
343 const existingSessionPage = existingSessionPageBySlug.get(page.pageId)
344 const payload: GeneratedPagePayload = {
345 id: existingSessionPage?.id,
346 pageNumber: page.pageNumber,
347 title: page.title,
348 html,
349 pageId: page.pageId,
350 htmlPath: page.htmlPath,
351 sourceUrl: getPageSourceUrl(page.htmlPath)
352 }
353 emitRetryChunk({
354 type: 'page_updated',
355 payload: {
356 runId: context.runId,
357 stage: 'rendering',
358 label: progressText(context.appLocale, 'completed'),
359 progress: 90,
360 currentPage: page.pageNumber,
361 totalPages: retryPages.length,
362 ...payload
363 }
364 })
365 }
366 const persistFailedRetryPage = async (page: {
367 pageNumber: number
368 pageId: string
369 title: string
370 contentOutline: string
371 layoutIntent?: LayoutIntent
372 htmlPath: string
373 reason: string
374 }): Promise<void> => {
375 const retryPage = retryPages.find((item) => item.pageId === page.pageId)
376 await db.upsertGenerationPage({
377 runId: context.runId,
378 sessionId: context.sessionId,
379 pageId: page.pageId,
380 pageNumber: page.pageNumber,
381 title: page.title,
382 contentOutline: page.contentOutline,
383 layoutIntent: page.layoutIntent,
384 htmlPath: page.htmlPath,
385 status: 'failed',
386 error: page.reason,
387 retryCount: retryPage?.retryCount || 0
388 })
389 await upsertRetrySessionPage(page, 'failed', page.reason)
390 persistedRetryCompletedPageIds.delete(page.pageId)
391 persistedRetryFailedPageIds.add(page.pageId)
392 }
393
394 const { summary: agentSummary, failedPages } = await runDeepAgentDeckGeneration({
395 renderingLabel: uiText(
396 context.appLocale,
397 `正在重新生成 ${retryPages.length} 个失败页面`,
398 `Regenerating ${retryPages.length} failed pages`
399 ),
400 sessionId: context.sessionId,
401 provider: context.provider,
402 apiKey: context.apiKey,
403 model: context.model,
404 baseUrl: context.providerBaseUrl,
405 maxTokens: context.maxTokens,
406 modelTimeoutMs: context.modelTimeouts.agent,
407 temperature: PAGE_GENERATION_TEMPERATURE,
408 styleId: context.styleId,
409 styleSkillPrompt: context.styleSkill.prompt,
410 styleKey: context.styleKey,
411 styleName: context.styleName,
412 styleVersion: context.styleVersion,
413 slideSize: context.slideSize,
414 appLocale: context.appLocale,
415 animationPreferences: context.animationPreferences,
416 topic: context.topic,
417 deckTitle: context.deckTitle,
418 userMessage:
419 context.userMessage ||
420 [
421 '继续生成本会话中未完成的页面。页面正文、标题、图表标签必须保持与现有页面相同语言。',
422 'Continue generating the unfinished slides in this session. Keep slide text, titles, and chart labels in the same language as existing slides.',
423 'Determine the content language from the existing topic, outline, source materials, and existing slides; do not infer it from this instruction.'
424 ].join('\n'),
425 outlineTitles: retryPages.map((page) => page.title),
426 outlineItems: retryPages.map((page) => ({
427 title: page.title,
428 contentOutline: page.contentOutline,
429 layoutIntent: page.layoutIntent
430 })),
431 sourceDocumentPaths: context.sourceDocumentPaths,
432 generationMode: 'retry',
433 pageTasks: retryPages.map((page) => ({
434 pageNumber: page.pageNumber,
435 pageId: page.pageId,
436 title: page.title,
437 contentOutline: page.contentOutline,
438 layoutIntent: page.layoutIntent
439 })),
440 designContract,
441 projectDir: context.projectDir,
442 indexPath,
443 pageFileMap,
444 pageNumbers,
445 agentManager,
446 emit: (chunk) => emitRetryChunk(chunk),
447 onPageCompleted: persistCompletedRetryPage,
448 onPageFailed: persistFailedRetryPage,
449 runId: context.runId,
450 signal: context.abortSignal
451 })
452
453 const failedPageIdSet = new Set(failedPages.map((page) => page.pageId))
454 const retrySuccessPages: Array<{
455 pageNumber: number
456 title: string
457 pageId: string
458 htmlPath: string
459 html: string
460 }> = []
461 const retryFailures = [...failedPages]
462 for (const page of retryPages) {
463 if (failedPageIdSet.has(page.pageId)) {
464 const failure = failedPages.find((item) => item.pageId === page.pageId)
465 if (!persistedRetryFailedPageIds.has(page.pageId)) {
466 await db.upsertGenerationPage({
467 runId: context.runId,
468 sessionId: context.sessionId,
469 pageId: page.pageId,
470 pageNumber: page.pageNumber,
471 title: page.title,
472 contentOutline: page.contentOutline,
473 layoutIntent: page.layoutIntent,
474 htmlPath: page.htmlPath,
475 status: 'failed',
476 error: failure?.reason || '页面重试失败',
477 retryCount: page.retryCount
478 })
479 await upsertRetrySessionPage(page, 'failed', failure?.reason || '页面重试失败')
480 persistedRetryFailedPageIds.add(page.pageId)
481 }
482 continue
483 }
484 if (!fs.existsSync(page.htmlPath)) {
485 const reason = `${page.pageId}.html 缺失`
486 retryFailures.push({ pageId: page.pageId, title: page.title, reason })
487 if (!persistedRetryFailedPageIds.has(page.pageId)) {
488 await db.upsertGenerationPage({
489 runId: context.runId,
490 sessionId: context.sessionId,
491 pageId: page.pageId,
492 pageNumber: page.pageNumber,
493 title: page.title,
494 contentOutline: page.contentOutline,
495 layoutIntent: page.layoutIntent,
496 htmlPath: page.htmlPath,
497 status: 'failed',
498 error: reason,
499 retryCount: page.retryCount
500 })
501 await upsertRetrySessionPage(page, 'failed', reason)
502 persistedRetryFailedPageIds.add(page.pageId)
503 }
504 continue
505 }
506 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
507 const validation = validatePersistedPageHtml(html, page.pageId)
508 if (!validation.valid) {
509 const reason = validation.errors.join('; ')
510 retryFailures.push({ pageId: page.pageId, title: page.title, reason })
511 if (!persistedRetryFailedPageIds.has(page.pageId)) {
512 await db.upsertGenerationPage({
513 runId: context.runId,
514 sessionId: context.sessionId,
515 pageId: page.pageId,
516 pageNumber: page.pageNumber,
517 title: page.title,
518 contentOutline: page.contentOutline,
519 layoutIntent: page.layoutIntent,
520 htmlPath: page.htmlPath,
521 status: 'failed',
522 error: reason,
523 retryCount: page.retryCount
524 })
525 await upsertRetrySessionPage(page, 'failed', reason)
526 persistedRetryFailedPageIds.add(page.pageId)
527 }
528 continue
529 }
530 retrySuccessPages.push({
531 pageNumber: page.pageNumber,
532 title: page.title,
533 pageId: page.pageId,
534 htmlPath: page.htmlPath,
535 html
536 })
537 if (!persistedRetryCompletedPageIds.has(page.pageId)) {
538 await db.upsertGenerationPage({
539 runId: context.runId,
540 sessionId: context.sessionId,
541 pageId: page.pageId,
542 pageNumber: page.pageNumber,
543 title: page.title,
544 contentOutline: page.contentOutline,
545 layoutIntent: page.layoutIntent,
546 htmlPath: page.htmlPath,
547 status: 'completed',
548 retryCount: page.retryCount
549 })
550 await upsertRetrySessionPage(page, 'completed', null)
551 persistedRetryCompletedPageIds.add(page.pageId)
552 }
553 }
554
555 const retryPageIdSet = new Set(retryPages.map((page) => page.pageId))
556 let previousGeneratedPages: Array<{
557 pageNumber: number
558 title: string
559 pageId: string
560 htmlPath: string
561 html: string
562 }> = []
563 const restoredPages = await Promise.all(
564 sessionPages
565 .filter((page) => page.status === 'completed' && !retryPageIdSet.has(page.file_slug))
566 .map(async (page) => {
567 const htmlPath = resolvePageHtmlPath({
568 projectDir: context.projectDir,
569 fileSlug: page.file_slug,
570 candidates: [page.html_path]
571 })
572 const html = fs.existsSync(htmlPath) ? await fs.promises.readFile(htmlPath, 'utf-8') : ''
573 if (!html.trim()) return null
574 return {
575 pageNumber: page.page_number,
576 title: page.title,
577 pageId: page.file_slug,
578 htmlPath,
579 html
580 }
581 })
582 )
583 previousGeneratedPages = restoredPages.filter(
584 (
585 page
586 ): page is {
587 pageNumber: number
588 title: string
589 pageId: string
590 htmlPath: string
591 html: string
592 } => Boolean(page)
593 )
594 const mergedGeneratedPages = [...previousGeneratedPages, ...retrySuccessPages].sort(
595 (a, b) => a.pageNumber - b.pageNumber
596 )
597
598 await db.updateSessionMetadata(context.sessionId, {
599 lastRunId: context.runId,
600 entryMode: 'multi_page',
601 indexPath,
602 projectId: context.projectId
603 })
604 await db.updateSessionDesignContract(context.sessionId, designContract)
605 await db.updateProjectStatus(context.projectId, 'draft')
606
607 if (retryFailures.length > 0) {
608 const failedDetails = retryFailures
609 .map((item) => `${item.pageId}(${item.title}):${item.reason}`)
610 .join(';')
611 await db.updateGenerationRunStatus(
612 context.runId,
613 retrySuccessPages.length > 0 ? 'partial' : 'failed',
614 failedDetails
615 )
616 emitRetryChunk({
617 type: 'llm_status',
618 payload: {
619 runId: context.runId,
620 stage: 'rendering',
621 label: progressText(context.appLocale, 'failed'),
622 progress: 90,
623 totalPages: retryPages.length,
624 detail: failedDetails
625 }
626 })
627 throw new Error(
628 `重试后仍有页面失败(${retryFailures.length}/${retryPages.length}):${retryFailures
629 .map((item) => `${item.pageId}(${item.title})`)
630 .join(', ')}`
631 )
632 }
633
634 if (mergedGeneratedPages.length < sessionPages.length) {
635 const message = uiText(
636 context.appLocale,
637 `重试页面已完成,但当前只恢复 ${mergedGeneratedPages.length}/${sessionPages.length} 页,请继续重试或重新生成。`,
638 `Retry completed, but only ${mergedGeneratedPages.length}/${sessionPages.length} pages were restored. Retry again or regenerate.`
639 )
640 await db.updateGenerationRunStatus(context.runId, 'partial', message)
641 emitRetryChunk({
642 type: 'llm_status',
643 payload: {
644 runId: context.runId,
645 stage: 'rendering',
646 label: progressText(context.appLocale, 'failed'),
647 progress: 90,
648 totalPages: retryPages.length,
649 detail: message
650 }
651 })
652 throw new Error(message)
653 }
654
655 const fallbackCompletionSummary = uiText(
656 context.appLocale,
657 `失败页面已经重试完成,本次修复 ${retrySuccessPages.length} 页。`,
658 `Failed pages were retried. ${retrySuccessPages.length} pages were fixed.`
659 )
660 await emitAssistant(context, agentSummary.trim() || fallbackCompletionSummary)
661 await db.updateGenerationRunStatus(context.runId, 'completed', null)
662 await finalizeGenerationSuccess(ctx, {
663 context,
664 indexPath,
665 totalPages: sessionPages.length,
666 generatedPages: mergedGeneratedPages,
667 designContract
668 })
669 }
670
670 lines TYPESCRIPT