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