返回 oh-my-ppt
retry-single-page-flow.ts
根目录 / src / main / generation / retry-single-page-flow.ts
1 import log from 'electron-log/main.js'
2 import { progressText } from '@shared/progress'
3 import path from 'path'
4 import fs from 'fs'
5 import { nanoid } from 'nanoid'
6 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
7 import {
8 createGenerationPageCallbacks,
9 generatePagesWithRetry,
10 resolvePageHtmlPath,
11 uiText
12 } from './generation-utils'
13 import {
14 resolveCommonContext,
15 resolveSessionReferenceDocumentPath,
16 resolveSourceDocuments,
17 type GenerationContext,
18 type RuntimeJobExecutionContext
19 } from './context'
20 import type { DesignContract, SourceDocumentPlan } from '@shared/generation'
21 import type { ModelTimeoutProfile } from '@shared/model-timeout'
22 import type { ModelRuntimeConfig } from '../agent-runtime/model'
23 import { normalizeLayoutIntent, type LayoutIntent } from '@shared/layout-intent'
24 import { CHART_SKILL_NAME, formatSkillUsageRequirement } from '../product-skills/contract'
25 import { createPageImageFinalizer } from './page-image-finalizer'
26 import { capturePageHtmlSnapshot } from './page-html-snapshot'
27
28 // ── Independent RetrySinglePage context ──
29
30 export type RetrySinglePageContext = {
31 sessionId: string
32 runId: string
33 pageId: string
34 pageNumber: number
35 title: string
36 contentOutline: string
37 layoutIntent: LayoutIntent
38 layoutId?: string | null
39 layoutContractVersion?: number | null
40 htmlPath: string
41 provider: string
42 apiKey: string
43 model: string
44 modelConfigId?: string
45 modelConfigName?: string
46 runModel?: string
47 providerBaseUrl: string
48 maxTokens: number
49 modelRuntime: ModelRuntimeConfig
50 modelTimeouts: Record<ModelTimeoutProfile, number>
51 projectDir: string
52 abortSignal: AbortSignal
53 styleId: string
54 styleSkillPrompt: string
55 imageGenerationPrompt: string
56 styleKey: string
57 styleName: string
58 styleVersion: string
59 slideSize: import('@shared/slide-size').SlideSizePreset
60 topic: string
61 deckTitle: string
62 appLocale: 'zh' | 'en'
63 sessionRecord: Record<string, unknown>
64 previousSessionStatus: string
65 messageScope: 'main' | 'page'
66 messagePageId: string
67 projectId: string
68 effectiveMode: 'retrySinglePage'
69 sourceDocumentPaths: string[]
70 referenceDocumentPath?: string
71 sourcePlan: SourceDocumentPlan | null
72 visualEnabled: boolean
73 imageModelConfigId?: string
74 }
75
76 export async function resolveRetrySinglePageContext(
77 ctx: GenerationContext,
78 sessionId: string,
79 pageId: string,
80 modelConfigId?: string,
81 execution?: RuntimeJobExecutionContext
82 ): Promise<RetrySinglePageContext> {
83 const { db } = ctx
84
85 log.info('[generate:retrySinglePage] resolving context', { sessionId, pageId })
86 const common = await resolveCommonContext(ctx, sessionId, modelConfigId, execution)
87 const { sessionRecord } = common
88 const sourceDocumentPaths = await resolveSourceDocuments(ctx, {
89 sessionId,
90 projectDir: common.projectDir,
91 // Single-page retry should reproduce the saved deck context, not consume transient edit attachments.
92 rawDocPaths: [],
93 mode: 'retrySinglePage',
94 sessionRecord
95 })
96 const referenceDocumentPath =
97 resolveSessionReferenceDocumentPath(common.projectDir, sessionRecord) ?? undefined
98
99 const sessionPages = await db.listSessionPages(sessionId)
100 const sessionPage = sessionPages.find((page) => page.file_slug === pageId || page.id === pageId)
101 if (!sessionPage) {
102 throw new Error(`Page ${pageId} not found in session_pages`)
103 }
104 const fileSlug = sessionPage.file_slug
105
106 // Read failed page metadata from DB
107 const pageSnapshots = await db.listLatestGenerationPageSnapshot(sessionId)
108 const pageSnapshot = pageSnapshots.find((p) => p.page_id === fileSlug)
109
110 const pageNumber = sessionPage.page_number
111 const title = sessionPage.title || pageSnapshot?.title || `Page ${pageNumber}`
112 const contentOutline = pageSnapshot?.content_outline || title
113 const layoutIntent = normalizeLayoutIntent(
114 sessionPage.layout_intent || pageSnapshot?.layout_intent
115 )
116 const layoutId = sessionPage.layout_id || pageSnapshot?.layout_id || null
117 const layoutContractVersion =
118 sessionPage.layout_contract_version ?? pageSnapshot?.layout_contract_version ?? null
119 const htmlPath = resolvePageHtmlPath({
120 projectDir: common.projectDir,
121 fileSlug,
122 candidates: [sessionPage.html_path, pageSnapshot?.html_path]
123 })
124
125 log.info('[generate:retrySinglePage] context resolved', {
126 sessionId,
127 pageId: fileSlug,
128 pageNumber,
129 projectDir: common.projectDir,
130 sourceDocumentCount: sourceDocumentPaths.length
131 })
132
133 return {
134 ...common,
135 sessionId,
136 pageId: fileSlug,
137 pageNumber,
138 title,
139 contentOutline,
140 layoutIntent,
141 layoutId,
142 layoutContractVersion,
143 htmlPath,
144 sessionRecord,
145 messageScope: 'page' as const,
146 messagePageId: sessionPage.id,
147 effectiveMode: 'retrySinglePage' as const,
148 sourceDocumentPaths,
149 referenceDocumentPath,
150 sourcePlan: common.sourcePlan
151 }
152 }
153
154 // ── Execute single page retry ──
155
156 export async function executeRetrySinglePageGeneration(
157 ctx: GenerationContext,
158 context: RetrySinglePageContext
159 ): Promise<void> {
160 const {
161 db,
162 agentManager,
163 sessionProject: { getPageSourceUrl },
164 runtimeEmitters: { createDeckProgressEmitter },
165 tuning: { pageGenerationTemperature: PAGE_GENERATION_TEMPERATURE }
166 } = ctx
167
168 if (!context.apiKey) {
169 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
170 }
171
172 const emitChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
173 const indexPath = path.join(context.projectDir, 'index.html')
174 await ctx.history.ensureBaseline(context.sessionId, context.projectDir)
175
176 // Read designContract
177 const sessionRecord = context.sessionRecord
178 let designContract: DesignContract | undefined
179 if (
180 typeof sessionRecord.designContract === 'string' &&
181 sessionRecord.designContract.trim().length > 0
182 ) {
183 try {
184 designContract = JSON.parse(sessionRecord.designContract) as DesignContract
185 } catch {
186 // ignore
187 }
188 }
189 if (!designContract) {
190 throw new Error('当前会话缺少设计契约,无法重试。')
191 }
192
193 // Emit progress
194 emitChunk({
195 type: 'stage_started',
196 payload: {
197 runId: context.runId,
198 stage: 'rendering',
199 label: uiText(
200 context.appLocale,
201 `正在重新生成第 ${context.pageNumber} 页`,
202 `Regenerating page ${context.pageNumber}`
203 ),
204 progress: 10,
205 totalPages: 1
206 }
207 })
208
209 const pageHtmlSnapshot = await capturePageHtmlSnapshot(context.htmlPath)
210 let generationResult: Awaited<ReturnType<typeof generatePagesWithRetry>>
211 let newHtml: string
212
213 try {
214 // Write scaffold before generation
215 await fs.promises.writeFile(
216 context.htmlPath,
217 `<section data-page-scaffold="${context.pageId}" data-page-number="${context.pageNumber}">
218 <main data-role="content"><p>Regenerating...</p></main>
219 </section>`,
220 'utf-8'
221 )
222
223 // Create run + page records
224 await db.createGenerationRun({
225 id: context.runId,
226 sessionId: context.sessionId,
227 mode: 'retrySinglePage',
228 totalPages: 1,
229 modelConfigId: context.modelConfigId,
230 metadata: {
231 retrySinglePage: true,
232 pageId: context.pageId,
233 modelConfigId: context.modelConfigId,
234 modelConfigName: context.modelConfigName,
235 provider: context.provider,
236 model: context.model
237 }
238 })
239 await db.upsertGenerationPage({
240 runId: context.runId,
241 sessionId: context.sessionId,
242 pageId: context.pageId,
243 pageNumber: context.pageNumber,
244 title: context.title,
245 contentOutline: context.contentOutline,
246 layoutIntent: context.layoutIntent,
247 layoutId: context.layoutId,
248 layoutContractVersion: context.layoutContractVersion,
249 htmlPath: context.htmlPath,
250 status: 'pending'
251 })
252
253 const pageFileMap: Record<string, string> = { [context.pageId]: context.htmlPath }
254 const pageNumbers: Record<string, number> = { [context.pageId]: context.pageNumber }
255 const pageCallbacks = createGenerationPageCallbacks({
256 db,
257 runId: context.runId,
258 sessionId: context.sessionId
259 })
260 generationResult = await generatePagesWithRetry({
261 runArgs: {
262 sessionId: context.sessionId,
263 provider: context.provider,
264 apiKey: context.apiKey,
265 model: context.model,
266 baseUrl: context.providerBaseUrl,
267 modelTimeoutMs: context.modelTimeouts.agent,
268 temperature: PAGE_GENERATION_TEMPERATURE,
269 styleId: context.styleId,
270 styleSkillPrompt: context.styleSkillPrompt,
271 hasStyleImageDirection: Boolean(context.imageGenerationPrompt.trim()),
272 styleKey: context.styleKey,
273 styleName: context.styleName,
274 styleVersion: context.styleVersion,
275 slideSize: context.slideSize,
276 appLocale: context.appLocale,
277 topic: context.topic,
278 deckTitle: context.deckTitle,
279 userMessage: `重新生成第 ${context.pageNumber} 页「${context.title}」`,
280 outlineTitles: [context.title],
281 outlineItems: [
282 {
283 title: context.title,
284 contentOutline: context.contentOutline,
285 layoutIntent: context.layoutIntent
286 }
287 ],
288 sourceDocumentPaths: context.sourceDocumentPaths,
289 referenceDocumentPath: context.referenceDocumentPath,
290 sourcePlan: context.sourcePlan,
291 generationMode: 'generate',
292 visualEnabled: context.visualEnabled,
293 renderingLabel: uiText(
294 context.appLocale,
295 `正在重新生成第 ${context.pageNumber} 页`,
296 `Regenerating page ${context.pageNumber}`
297 ),
298 pageTasks: [
299 {
300 pageNumber: context.pageNumber,
301 pageId: context.pageId,
302 title: context.title,
303 contentOutline: context.contentOutline,
304 layoutIntent: context.layoutIntent,
305 layoutId: context.layoutId,
306 layoutContractVersion: context.layoutContractVersion
307 }
308 ],
309 designContract,
310 projectDir: context.projectDir,
311 indexPath,
312 pageFileMap,
313 pageNumbers,
314 agentManager,
315 emit: (chunk) => emitChunk(chunk),
316 finalizePage: createPageImageFinalizer(ctx, {
317 sessionId: context.sessionId,
318 runId: context.runId,
319 visualEnabled: context.visualEnabled,
320 imageModelConfigId: context.imageModelConfigId,
321 imageGenerationPrompt: context.imageGenerationPrompt,
322 imagePromptDirector: {
323 provider: context.provider,
324 apiKey: context.apiKey,
325 model: context.model,
326 baseUrl: context.providerBaseUrl,
327 maxTokens: context.maxTokens,
328 modelRuntime: context.modelRuntime,
329 modelTimeoutMs: context.modelTimeouts.agent,
330 locale: context.appLocale
331 },
332 abortSignal: context.abortSignal
333 }),
334 ...pageCallbacks,
335 runId: context.runId,
336 signal: context.abortSignal
337 },
338 emitChunk,
339 appLocale: context.appLocale,
340 runId: context.runId,
341 totalPages: 1,
342 beforeRetry: async () => {
343 await fs.promises.writeFile(
344 context.htmlPath,
345 `<section data-page-scaffold="${context.pageId}" data-page-number="${context.pageNumber}">
346 <main data-role="content"><p>Retrying...</p></main>
347 </section>`,
348 'utf-8'
349 )
350 },
351 buildRetryRunArgs: (runArgs) => ({
352 ...runArgs,
353 userMessage: `重新生成第 ${context.pageNumber} 页「${context.title}」。如果需要图表,先 ${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
354 })
355 })
356 if (context.abortSignal.aborted) throw new Error('生成已取消')
357
358 // Validate generated page
359 if (!fs.existsSync(context.htmlPath)) {
360 throw new Error(`${context.pageId}.html 缺失`)
361 }
362 newHtml = await fs.promises.readFile(context.htmlPath, 'utf-8')
363 const validation = validatePersistedPageHtml(newHtml, context.pageId)
364 if (!validation.valid) {
365 throw new Error(`重试页面 HTML 验证失败: ${validation.errors.join('; ')}`)
366 }
367 } catch (error) {
368 try {
369 await pageHtmlSnapshot.restore()
370 log.warn('[generate:retrySinglePage] failed; original page restored', {
371 sessionId: context.sessionId,
372 pageId: context.pageId,
373 reason: error instanceof Error ? error.message : String(error)
374 })
375 } catch (restoreError) {
376 log.error('[generate:retrySinglePage] could not restore original page', {
377 sessionId: context.sessionId,
378 pageId: context.pageId,
379 error: restoreError instanceof Error ? restoreError.message : String(restoreError)
380 })
381 }
382 throw error
383 }
384
385 // Read actual generated title from DB (LLM may change it during retry)
386 const runPages = await db.listGenerationPages(context.runId)
387 const latestPageRecord = runPages.find((p) => p.page_id === context.pageId)
388 const actualTitle = latestPageRecord?.title || context.title
389 const existingSessionPages = await db.listSessionPages(context.sessionId, {
390 includeDeleted: true
391 })
392 const existingBySlug = new Map(existingSessionPages.map((sp) => [sp.file_slug, sp]))
393 const currentSessionPage = existingBySlug.get(context.pageId)
394 await db.upsertSessionPage({
395 id: currentSessionPage?.id || nanoid(),
396 sessionId: context.sessionId,
397 legacyPageId:
398 currentSessionPage?.legacy_page_id ||
399 (context.pageId.match(/^page-\d+$/) ? context.pageId : null),
400 fileSlug: context.pageId,
401 pageNumber: context.pageNumber,
402 title: actualTitle,
403 htmlPath: context.htmlPath,
404 layoutIntent: latestPageRecord?.layout_intent || context.layoutIntent,
405 layoutId: latestPageRecord?.layout_id || context.layoutId,
406 layoutContractVersion:
407 latestPageRecord?.layout_contract_version || context.layoutContractVersion,
408 status: 'completed',
409 error: null
410 })
411 const updatedSessionPages = existingSessionPages
412 .filter((page) => !page.deleted_at)
413 .map((page) =>
414 page.file_slug === context.pageId
415 ? {
416 ...page,
417 title: actualTitle,
418 html_path: context.htmlPath,
419 status: 'completed',
420 error: null
421 }
422 : page
423 )
424 .sort((a, b) => a.page_number - b.page_number)
425
426 // Emit page_updated event
427 emitChunk({
428 type: 'page_updated',
429 payload: {
430 runId: context.runId,
431 stage: 'rendering',
432 label: progressText(context.appLocale, 'completed'),
433 progress: 95,
434 currentPage: context.pageNumber,
435 totalPages: updatedSessionPages.length,
436 id: context.messagePageId,
437 pageNumber: context.pageNumber,
438 title: actualTitle,
439 pageId: context.pageId,
440 htmlPath: context.htmlPath,
441 html: newHtml,
442 sourceUrl: getPageSourceUrl(context.htmlPath)
443 }
444 })
445
446 const assistantContent =
447 generationResult.summary.trim() ||
448 uiText(
449 context.appLocale,
450 `第 ${context.pageNumber} 页已重新生成。`,
451 `Page ${context.pageNumber} has been regenerated.`
452 )
453 const assistantMessageId = await db.addMessage(context.sessionId, {
454 role: 'assistant',
455 content: assistantContent,
456 type: 'text',
457 chat_scope: context.messageScope,
458 page_id: context.messagePageId,
459 run_model: context.runModel
460 })
461 emitChunk({
462 type: 'assistant_message',
463 payload: {
464 id: assistantMessageId,
465 runId: context.runId,
466 content: assistantContent,
467 chatType: context.messageScope,
468 pageId: context.messagePageId
469 }
470 })
471
472 // Finalize — update metadata and project status, but only mark session 'completed'
473 // if there are no remaining failed pages.
474 await db.updateSessionMetadata(context.sessionId, {
475 lastRunId: context.runId,
476 entryMode: 'multi_page',
477 indexPath,
478 projectId: context.projectId
479 })
480 await db.updateProjectStatus(context.projectId, 'draft')
481
482 // Check if there are still failed pages in the session
483 const remainingSessionPages = await db.listSessionPages(context.sessionId)
484 const hasFailedPages = remainingSessionPages.some((page) => page.status !== 'completed')
485 // If other pages are still failed, session must NOT be 'completed'
486 const targetStatus = hasFailedPages ? 'failed' : 'completed'
487
488 await db.updateSessionStatus(context.sessionId, targetStatus)
489 await ctx.history.recordOperation({
490 sessionId: context.sessionId,
491 projectDir: context.projectDir,
492 type: 'retry',
493 scope: 'page',
494 prompt: `重新生成第 ${context.pageNumber} 页「${context.title}」`,
495 metadata: {
496 runId: context.runId,
497 pageId: context.pageId
498 }
499 })
500 if (context.abortSignal.aborted) throw new Error('生成已取消')
501 await db.updateGenerationRunStatus(context.runId, 'completed', null)
502
503 log.info('[generate:retrySinglePage] completed', {
504 sessionId: context.sessionId,
505 pageId: context.pageId,
506 hasFailedPages,
507 targetStatus
508 })
509
510 emitChunk({
511 type: 'run_completed',
512 payload: {
513 runId: context.runId,
514 totalPages: updatedSessionPages.length
515 }
516 })
517 }
518
518 lines TYPESCRIPT