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