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