返回 oh-my-ppt
deck-flow.ts
根目录 / src / main / generation / deck-flow.ts
1 import type { DeckContext, EmitAssistantFn } from './types'
2 import { 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 log from 'electron-log/main.js'
8 import { type LayoutIntent } from '@shared/layout-intent'
9 import { isPlaceholderPageHtml, validatePersistedPageHtml } from '../presentation/html/html-utils'
10 import { buildProjectIndexHtml, type DeckPageFile } from '../session/template-builder'
11 import {
12 buildDesignContractWithLLM,
13 planDeckWithLLM,
14 runDeepAgentDeckGeneration
15 } from './agent-runner'
16 import type { GeneratedPagePayload } from '@shared/generation'
17 import { sleep } from '../ipc/utils'
18 import { customAlphabet, nanoid } from 'nanoid'
19 import {
20 buildOutlineTitles,
21 buildTotalPages,
22 type GenerationContext,
23 normalizeGeneratePayload,
24 type RuntimeJobExecutionContext,
25 resolveCommonContext,
26 resolveSourceDocuments
27 } from './context'
28 import { canUseSourcePlanDirectly, mapSourcePlanToOutlineItems } from './source-plan'
29
30 const pageSlugId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10)
31
32 export async function resolveDeckContext(
33 ctx: GenerationContext,
34 _event: Electron.IpcMainInvokeEvent,
35 payload: unknown,
36 execution?: RuntimeJobExecutionContext
37 ): Promise<DeckContext> {
38 const input = normalizeGeneratePayload(payload)
39 const { db, localFiles } = ctx
40 if (!input.sessionId) throw new Error('sessionId 不能为空')
41
42 const common = await resolveCommonContext(ctx, input.sessionId, input.modelConfigId, execution)
43 const userMessage = `${input.rawUserMessage}${localFiles.formatImagePathsForPrompt([])}`
44 const userProvidedOutlineTitles = buildOutlineTitles(input.rawUserMessage)
45 const totalPages = buildTotalPages(common.sessionRecord)
46 const sourceDocumentPaths = await resolveSourceDocuments(ctx, {
47 sessionId: input.sessionId,
48 projectDir: common.projectDir,
49 rawDocPaths: input.rawDocPaths,
50 mode: 'generate',
51 sessionRecord: common.sessionRecord
52 })
53
54 await db.addMessage(input.sessionId, {
55 role: 'user',
56 content: input.rawUserMessage,
57 type: 'text',
58 chat_scope: 'main',
59 image_paths: [],
60 run_model: common.runModel
61 })
62 await db.updateSessionStatus(input.sessionId, 'active')
63
64 return {
65 sessionId: input.sessionId,
66 userMessage,
67 requestedType: input.requestedType,
68 effectiveMode: 'generate',
69 selectedPageId: undefined,
70 selectPageIds: [],
71 htmlPath: undefined,
72 selector: undefined,
73 elementTag: undefined,
74 elementText: undefined,
75 session: common.session,
76 sessionRecord: common.sessionRecord,
77 previousSessionStatus: common.previousSessionStatus,
78 projectDir: common.projectDir,
79 abortSignal: common.abortSignal,
80 runId: common.runId,
81 styleId: common.styleId,
82 styleSkill: common.styleSkill,
83 styleKey: common.styleKey,
84 styleName: common.styleName,
85 styleVersion: common.styleVersion,
86 slideSize: common.slideSize,
87 userProvidedOutlineTitles,
88 totalPages,
89 provider: common.provider,
90 apiKey: common.apiKey,
91 model: common.model,
92 modelConfigId: common.modelConfigId,
93 modelConfigName: common.modelConfigName,
94 runModel: common.runModel,
95 modelTimeouts: common.modelTimeouts,
96 providerBaseUrl: common.providerBaseUrl,
97 maxTokens: common.maxTokens,
98 modelRuntime: common.modelRuntime,
99 projectId: common.projectId,
100 messageScope: 'main',
101 messagePageId: undefined,
102 imagePaths: [],
103 videoPaths: [],
104 sourceDocumentPaths,
105 sourcePlan: common.sourcePlan,
106 topic: common.topic,
107 deckTitle: common.deckTitle,
108 appLocale: common.appLocale,
109 fontSelection: common.fontSelection,
110 animationPreferences: input.animationPreferences
111 }
112 }
113
114 export async function executeDeckGeneration(
115 ctx: GenerationContext,
116 emitAssistant: EmitAssistantFn,
117 context: DeckContext
118 ): Promise<void> {
119 const {
120 db,
121 agentManager,
122 sessionProject: { getPageSourceUrl, validateProjectIndexHtml },
123 runtimeEmitters: { createDeckProgressEmitter },
124 sessionScaffold: { scaffoldProjectFiles },
125 tuning: {
126 plannerTemperature: PLANNER_TEMPERATURE,
127 designContractTemperature: DESIGN_CONTRACT_TEMPERATURE,
128 pageGenerationTemperature: PAGE_GENERATION_TEMPERATURE
129 }
130 } = ctx
131
132 if (!context.apiKey) {
133 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
134 }
135
136 const emitDeckChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
137
138 emitDeckChunk({
139 type: 'stage_started',
140 payload: {
141 runId: context.runId,
142 stage: 'preflight',
143 label: progressText(context.appLocale, 'understanding'),
144 progress: 2,
145 totalPages: context.totalPages
146 }
147 })
148 await db.addMessage(context.sessionId, {
149 role: 'system',
150 content: uiText(
151 context.appLocale,
152 '正在梳理需求并准备生成画布。',
153 'Organizing requirements and preparing the canvas.'
154 ),
155 type: 'stream_chunk',
156 chat_scope: context.messageScope,
157 page_id: context.messagePageId,
158 run_model: context.runModel
159 })
160 await sleep(120, context.abortSignal)
161
162 const pageRefs = Array.from({ length: context.totalPages }, (_unused, index) => {
163 const pageNumber = index + 1
164 const id = nanoid()
165 const pageId = `page-${pageSlugId()}`
166 const htmlPath = path.join(context.projectDir, `${pageId}.html`)
167 const fallbackTitle = context.userProvidedOutlineTitles[index] || `Slide ${pageNumber}`
168 return { id, pageNumber, title: fallbackTitle, pageId, htmlPath }
169 })
170 const pageFileMap = Object.fromEntries(pageRefs.map((page) => [page.pageId, page.htmlPath]))
171 const pageNumbers = Object.fromEntries(pageRefs.map((page) => [page.pageId, page.pageNumber]))
172 const indexPath = path.join(context.projectDir, 'index.html')
173 await db.createGenerationRun({
174 id: context.runId,
175 sessionId: context.sessionId,
176 mode: 'generate',
177 totalPages: pageRefs.length,
178 modelConfigId: context.modelConfigId,
179 animationPreferences: context.animationPreferences,
180 metadata: {
181 topic: context.topic,
182 styleId: context.styleId,
183 modelConfigId: context.modelConfigId,
184 modelConfigName: context.modelConfigName,
185 provider: context.provider,
186 model: context.model,
187 projectDir: context.projectDir,
188 indexPath
189 }
190 })
191
192 emitDeckChunk({
193 type: 'stage_progress',
194 payload: {
195 runId: context.runId,
196 stage: 'planning',
197 label: progressText(context.appLocale, 'planning'),
198 progress: 6,
199 totalPages: context.totalPages
200 }
201 })
202 const scaffoldPromise = scaffoldProjectFiles({
203 deckTitle: context.deckTitle,
204 indexPath,
205 pages: pageRefs,
206 slideSize: context.slideSize
207 }).then(() => {
208 emitDeckChunk({
209 type: 'llm_status',
210 payload: {
211 runId: context.runId,
212 stage: 'preflight',
213 label: progressText(context.appLocale, 'preparing'),
214 progress: 4,
215 totalPages: pageRefs.length,
216 detail: uiText(
217 context.appLocale,
218 `已创建 index.html 与 ${pageRefs.length} 个页面骨架`,
219 `Created index.html and ${pageRefs.length} page shells`
220 )
221 }
222 })
223 })
224
225 const shouldUseSourcePlan = canUseSourcePlanDirectly({
226 sourcePlan: context.sourcePlan,
227 totalPages: pageRefs.length,
228 userMessage: context.userMessage
229 })
230 const plannerPromise =
231 shouldUseSourcePlan && context.sourcePlan
232 ? Promise.resolve(mapSourcePlanToOutlineItems(context.sourcePlan))
233 : planDeckWithLLM({
234 provider: context.provider,
235 apiKey: context.apiKey,
236 model: context.model,
237 baseUrl: context.providerBaseUrl,
238 maxTokens: context.maxTokens,
239 modelRuntime: context.modelRuntime,
240 modelTimeoutMs: context.modelTimeouts.planning,
241 temperature: PLANNER_TEMPERATURE,
242 styleId: context.styleId,
243 totalPages: pageRefs.length,
244 appLocale: context.appLocale,
245 topic: context.topic,
246 userMessage: context.userMessage,
247 sourceDocumentPaths: context.sourceDocumentPaths,
248 emit: (chunk) => emitDeckChunk(chunk),
249 runId: context.runId,
250 signal: context.abortSignal
251 })
252 if (shouldUseSourcePlan) {
253 log.info('[generate:deck] using source page skeleton as outline plan', {
254 sessionId: context.sessionId,
255 pageCount: pageRefs.length,
256 sourceDocumentPath: context.sourcePlan?.sourceDocumentPath ?? null
257 })
258 emitDeckChunk({
259 type: 'llm_status',
260 payload: {
261 runId: context.runId,
262 stage: 'planning',
263 label: progressText(context.appLocale, 'planning'),
264 progress: 9,
265 totalPages: pageRefs.length,
266 detail: uiText(
267 context.appLocale,
268 `已使用源文档结构生成 ${pageRefs.length} 页计划`,
269 `Using source document structure for ${pageRefs.length} slide plans`
270 )
271 }
272 })
273 }
274 const designContractPromise = sleep(500, context.abortSignal).then(() =>
275 buildDesignContractWithLLM({
276 provider: context.provider,
277 apiKey: context.apiKey,
278 model: context.model,
279 baseUrl: context.providerBaseUrl,
280 maxTokens: context.maxTokens,
281 modelRuntime: context.modelRuntime,
282 modelTimeoutMs: context.modelTimeouts.design,
283 temperature: DESIGN_CONTRACT_TEMPERATURE,
284 styleId: context.styleId,
285 styleSkillPrompt: context.styleSkill.prompt,
286 styleKey: context.styleKey,
287 styleName: context.styleName,
288 styleVersion: context.styleVersion,
289 appLocale: context.appLocale,
290 totalPages: context.totalPages,
291 slideSize: context.slideSize,
292 topic: context.topic,
293 userMessage: context.userMessage,
294 fontSelection: context.fontSelection,
295 emit: (chunk) => emitDeckChunk(chunk),
296 runId: context.runId,
297 signal: context.abortSignal
298 })
299 )
300 const [plannedOutlineItems, designContract] = await Promise.all([
301 plannerPromise,
302 designContractPromise,
303 scaffoldPromise
304 ])
305 await db.updateSessionDesignContract(context.sessionId, designContract)
306 const outlineItems = pageRefs.map((page, index) => {
307 const planned = plannedOutlineItems[index]
308 return {
309 title: planned?.title?.trim() || page.title,
310 contentOutline: planned?.contentOutline?.trim() || '',
311 layoutIntent: planned?.layoutIntent
312 }
313 })
314 const outlineTitles = outlineItems.map((item) => item.title)
315 for (const page of pageRefs) {
316 page.title = outlineTitles[page.pageNumber - 1] || page.title
317 await db.upsertGenerationPage({
318 runId: context.runId,
319 sessionId: context.sessionId,
320 pageId: page.pageId,
321 pageNumber: page.pageNumber,
322 title: page.title,
323 contentOutline: outlineItems[page.pageNumber - 1]?.contentOutline || '',
324 layoutIntent: outlineItems[page.pageNumber - 1]?.layoutIntent,
325 htmlPath: page.htmlPath,
326 status: 'pending'
327 })
328 }
329
330 await fs.promises.writeFile(
331 indexPath,
332 buildProjectIndexHtml(
333 context.deckTitle,
334 pageRefs.map(
335 (page): DeckPageFile => ({
336 id: page.id,
337 pageNumber: page.pageNumber,
338 pageId: page.pageId,
339 title: page.title,
340 htmlPath: path.basename(page.htmlPath)
341 })
342 ),
343 context.slideSize
344 ),
345 'utf-8'
346 )
347 emitDeckChunk({
348 type: 'llm_status',
349 payload: {
350 runId: context.runId,
351 stage: 'preflight',
352 label: progressText(context.appLocale, 'generating'),
353 progress: 10,
354 totalPages: pageRefs.length,
355 detail: uiText(
356 context.appLocale,
357 `已完成规划并更新目录标题,设计契约:${designContract.theme}`,
358 `Planning completed and index titles updated. Design contract: ${designContract.theme}`
359 )
360 }
361 })
362
363 await sleep(120, context.abortSignal)
364
365 const beforePageMap = new Map<string, string>()
366 const beforePageResults = await Promise.all(
367 pageRefs.map(async (page) => ({
368 pageId: page.pageId,
369 html: await fs.promises.readFile(page.htmlPath, 'utf-8')
370 }))
371 )
372 for (const item of beforePageResults) {
373 beforePageMap.set(item.pageId, item.html)
374 }
375
376 const persistedGeneratedPagesById = new Map<
377 string,
378 {
379 pageNumber: number
380 title: string
381 pageId: string
382 htmlPath: string
383 }
384 >()
385 const persistedFailedPagesById = new Map<
386 string,
387 {
388 pageId: string
389 title: string
390 reason: string
391 }
392 >()
393 const persistGenerationSnapshotMetadata = async (): Promise<void> => {
394 await db.updateSessionMetadata(context.sessionId, {
395 lastRunId: context.runId,
396 entryMode: 'multi_page',
397 indexPath,
398 projectId: context.projectId
399 })
400 }
401 const persistCompletedGeneratedPage = async (page: {
402 pageNumber: number
403 pageId: string
404 title: string
405 contentOutline: string
406 layoutIntent?: LayoutIntent
407 htmlPath: string
408 }): Promise<void> => {
409 if (!fs.existsSync(page.htmlPath)) {
410 throw new Error(`${page.pageId}.html 缺失`)
411 }
412 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
413 const validation = validatePersistedPageHtml(html, page.pageId)
414 if (!validation.valid) {
415 throw new Error(`HTML 验证失败 (${page.pageId}): ${validation.errors.join('; ')}`)
416 }
417 await db.upsertGenerationPage({
418 runId: context.runId,
419 sessionId: context.sessionId,
420 pageId: page.pageId,
421 pageNumber: page.pageNumber,
422 title: page.title,
423 contentOutline: page.contentOutline,
424 layoutIntent: page.layoutIntent,
425 htmlPath: page.htmlPath,
426 status: 'completed'
427 })
428 persistedFailedPagesById.delete(page.pageId)
429 persistedGeneratedPagesById.set(page.pageId, {
430 pageNumber: page.pageNumber,
431 title: page.title,
432 pageId: page.pageId,
433 htmlPath: page.htmlPath
434 })
435 const pageRef = pageRefs.find((item) => item.pageId === page.pageId)
436 emitDeckChunk({
437 type: 'page_generated',
438 payload: {
439 runId: context.runId,
440 stage: 'rendering',
441 label: progressText(context.appLocale, 'completed'),
442 progress: 10 + Math.round((page.pageNumber / Math.max(pageRefs.length, 1)) * 80),
443 currentPage: page.pageNumber,
444 totalPages: pageRefs.length,
445 id: pageRef?.id,
446 pageNumber: page.pageNumber,
447 title: page.title,
448 html,
449 pageId: page.pageId,
450 htmlPath: page.htmlPath,
451 sourceUrl: getPageSourceUrl(page.htmlPath)
452 }
453 })
454 await persistGenerationSnapshotMetadata()
455 }
456 const persistFailedGeneratedPage = async (page: {
457 pageNumber: number
458 pageId: string
459 title: string
460 contentOutline: string
461 layoutIntent?: LayoutIntent
462 htmlPath: string
463 reason: string
464 }): Promise<void> => {
465 await db.upsertGenerationPage({
466 runId: context.runId,
467 sessionId: context.sessionId,
468 pageId: page.pageId,
469 pageNumber: page.pageNumber,
470 title: page.title,
471 contentOutline: page.contentOutline,
472 layoutIntent: page.layoutIntent,
473 htmlPath: page.htmlPath,
474 status: 'failed',
475 error: page.reason
476 })
477 persistedGeneratedPagesById.delete(page.pageId)
478 persistedFailedPagesById.set(page.pageId, {
479 pageId: page.pageId,
480 title: page.title,
481 reason: page.reason
482 })
483 await persistGenerationSnapshotMetadata()
484 }
485
486 const { summary: agentSummary, failedPages } = await runDeepAgentDeckGeneration({
487 sessionId: context.sessionId,
488 provider: context.provider,
489 apiKey: context.apiKey,
490 model: context.model,
491 baseUrl: context.providerBaseUrl,
492 maxTokens: context.maxTokens,
493 modelTimeoutMs: context.modelTimeouts.agent,
494 temperature: PAGE_GENERATION_TEMPERATURE,
495 styleId: context.styleId,
496 styleSkillPrompt: context.styleSkill.prompt,
497 styleKey: context.styleKey,
498 styleName: context.styleName,
499 styleVersion: context.styleVersion,
500 slideSize: context.slideSize,
501 appLocale: context.appLocale,
502 animationPreferences: context.animationPreferences,
503 topic: context.topic,
504 deckTitle: context.deckTitle,
505 userMessage: context.userMessage,
506 outlineTitles,
507 outlineItems,
508 pageTasks: pageRefs.map((page, index) => ({
509 pageNumber: page.pageNumber,
510 pageId: page.pageId,
511 title: page.title,
512 contentOutline: outlineItems[index]?.contentOutline || '',
513 layoutIntent: outlineItems[index]?.layoutIntent
514 })),
515 sourceDocumentPaths: context.sourceDocumentPaths,
516 generationMode: 'generate',
517 designContract,
518 projectDir: context.projectDir,
519 indexPath,
520 pageFileMap,
521 pageNumbers,
522 agentManager,
523 emit: (chunk) => emitDeckChunk(chunk),
524 onPageCompleted: persistCompletedGeneratedPage,
525 onPageFailed: persistFailedGeneratedPage,
526 runId: context.runId,
527 signal: context.abortSignal
528 })
529
530 const failedPageIdSet = new Set(failedPages.map((item) => item.pageId))
531 const postValidationErrors: string[] = []
532 const postValidationFailures: Array<{ pageId: string; title: string; reason: string }> = []
533 if (!fs.existsSync(indexPath)) {
534 postValidationErrors.push('index.html 缺失')
535 } else {
536 const indexHtml = await fs.promises.readFile(indexPath, 'utf-8')
537 postValidationErrors.push(...validateProjectIndexHtml(indexHtml))
538 }
539 const validationPages = await Promise.all(
540 pageRefs.map(async (page) => {
541 if (!fs.existsSync(page.htmlPath)) {
542 return { pageId: page.pageId, missing: true, html: '' }
543 }
544 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
545 return { pageId: page.pageId, missing: false, html }
546 })
547 )
548 for (const item of validationPages) {
549 const pageRef = pageRefs.find((page) => page.pageId === item.pageId)
550 if (item.missing) {
551 const reason = `${item.pageId}.html 缺失`
552 postValidationErrors.push(reason)
553 if (!failedPageIdSet.has(item.pageId)) {
554 postValidationFailures.push({
555 pageId: item.pageId,
556 title: pageRef?.title || item.pageId,
557 reason
558 })
559 }
560 continue
561 }
562 if (!/<html[\s>]/i.test(item.html)) {
563 const reason = `${item.pageId}.html 缺少 <html>`
564 postValidationErrors.push(reason)
565 if (!failedPageIdSet.has(item.pageId)) {
566 postValidationFailures.push({
567 pageId: item.pageId,
568 title: pageRef?.title || item.pageId,
569 reason
570 })
571 }
572 continue
573 }
574 if (!failedPageIdSet.has(item.pageId)) {
575 const validation = validatePersistedPageHtml(item.html, item.pageId)
576 if (!validation.valid) {
577 const reason = validation.errors.join('; ')
578 postValidationErrors.push(`${item.pageId}.html ${reason}`)
579 postValidationFailures.push({
580 pageId: item.pageId,
581 title: pageRef?.title || item.pageId,
582 reason
583 })
584 }
585 }
586 }
587 for (const failure of postValidationFailures) {
588 failedPageIdSet.add(failure.pageId)
589 failedPages.push(failure)
590 }
591 emitDeckChunk({
592 type: 'llm_status',
593 payload: {
594 runId: context.runId,
595 stage: 'validation',
596 label: progressText(
597 context.appLocale,
598 postValidationErrors.length > 0 ? 'failed' : 'checking'
599 ),
600 progress: 92,
601 totalPages: outlineTitles.length,
602 detail:
603 postValidationErrors.length > 0
604 ? postValidationErrors.join('; ')
605 : uiText(
606 context.appLocale,
607 `全部 ${pageRefs.length} 个页面文件都已准备完成`,
608 `All ${pageRefs.length} page files are ready`
609 )
610 }
611 })
612
613 const placeholderPages: string[] = []
614 const pageDescriptors: Array<{
615 id: string
616 pageNumber: number
617 title: string
618 pageId: string
619 htmlPath: string
620 html: string
621 }> = []
622 const generatedPageReads = await Promise.all(
623 pageRefs.map(async (pageRef) => {
624 if (!fs.existsSync(pageRef.htmlPath)) return null
625 const html = await fs.promises.readFile(pageRef.htmlPath, 'utf-8')
626 return { pageRef, html }
627 })
628 )
629 for (const item of generatedPageReads) {
630 if (!item) continue
631 const { pageRef, html } = item
632 if (failedPageIdSet.has(pageRef.pageId)) {
633 continue
634 }
635 if (isPlaceholderPageHtml(html)) {
636 const reason = '页面仍为占位内容,模型没有成功写入真实页面'
637 placeholderPages.push(pageRef.pageId)
638 failedPageIdSet.add(pageRef.pageId)
639 failedPages.push({
640 pageId: pageRef.pageId,
641 title: pageRef.title,
642 reason
643 })
644 continue
645 }
646 const page: GeneratedPagePayload = {
647 id: pageRef.id,
648 pageNumber: pageRef.pageNumber,
649 title: pageRef.title,
650 html,
651 pageId: pageRef.pageId,
652 htmlPath: pageRef.htmlPath,
653 sourceUrl: getPageSourceUrl(pageRef.htmlPath)
654 }
655 pageDescriptors.push({
656 id: pageRef.id,
657 pageNumber: pageRef.pageNumber,
658 title: pageRef.title,
659 pageId: pageRef.pageId,
660 htmlPath: pageRef.htmlPath,
661 html
662 })
663 if (!persistedGeneratedPagesById.has(pageRef.pageId)) {
664 await db.upsertGenerationPage({
665 runId: context.runId,
666 sessionId: context.sessionId,
667 pageId: pageRef.pageId,
668 pageNumber: pageRef.pageNumber,
669 title: pageRef.title,
670 contentOutline: outlineItems[pageRef.pageNumber - 1]?.contentOutline || '',
671 layoutIntent: outlineItems[pageRef.pageNumber - 1]?.layoutIntent,
672 htmlPath: pageRef.htmlPath,
673 status: 'completed'
674 })
675 }
676 const changed = beforePageMap.get(pageRef.pageId) !== html
677 await db.addMessage(context.sessionId, {
678 role: 'tool',
679 content: `${changed ? '已更新' : '已确认'} ${page.pageId}: ${page.title}`,
680 type: 'tool_result',
681 tool_name: 'update_page_file',
682 tool_call_id: context.runId,
683 chat_scope: context.messageScope,
684 page_id: context.messagePageId,
685 run_model: context.runModel
686 })
687 }
688
689 if (placeholderPages.length > 0) {
690 emitDeckChunk({
691 type: 'llm_status',
692 payload: {
693 runId: context.runId,
694 stage: 'rendering',
695 label: progressText(context.appLocale, 'checking'),
696 progress: 90,
697 totalPages: outlineTitles.length,
698 detail: uiText(
699 context.appLocale,
700 `以下页面可能仍是占位内容:${placeholderPages.join(', ')}`,
701 `These pages may still contain placeholders: ${placeholderPages.join(', ')}`
702 )
703 }
704 })
705 }
706
707 if (failedPages.length > 0) {
708 const failedDetails = failedPages
709 .map((item) => `${item.pageId}(${item.title}):${item.reason}`)
710 .join(';')
711 for (const failedPage of failedPages) {
712 const pageRef = pageRefs.find((page) => page.pageId === failedPage.pageId)
713 if (!pageRef) continue
714 emitDeckChunk({
715 type: 'page_failed',
716 payload: {
717 runId: context.runId,
718 stage: 'validation',
719 label: progressText(context.appLocale, 'failed'),
720 progress: 92,
721 currentPage: pageRef.pageNumber,
722 totalPages: pageRefs.length,
723 pageNumber: pageRef.pageNumber,
724 pageId: pageRef.pageId,
725 title: pageRef.title,
726 htmlPath: pageRef.htmlPath,
727 error: failedPage.reason
728 }
729 })
730 await db.upsertGenerationPage({
731 runId: context.runId,
732 sessionId: context.sessionId,
733 pageId: pageRef.pageId,
734 pageNumber: pageRef.pageNumber,
735 title: pageRef.title,
736 contentOutline: outlineItems[pageRef.pageNumber - 1]?.contentOutline || '',
737 layoutIntent: outlineItems[pageRef.pageNumber - 1]?.layoutIntent,
738 htmlPath: pageRef.htmlPath,
739 status: 'failed',
740 error: failedPage.reason
741 })
742 }
743 const existingSessionPages = await db.listSessionPages(context.sessionId, {
744 includeDeleted: true
745 })
746 const existingBySlug = new Map(existingSessionPages.map((sp) => [sp.file_slug, sp]))
747 for (const failedPage of failedPages) {
748 const pageRef = pageRefs.find((page) => page.pageId === failedPage.pageId)
749 if (!pageRef) continue
750 const existing = existingBySlug.get(pageRef.pageId)
751 await db.upsertSessionPage({
752 id: existing?.id || pageRef.id,
753 sessionId: context.sessionId,
754 legacyPageId:
755 existing?.legacy_page_id || (pageRef.pageId.match(/^page-\d+$/) ? pageRef.pageId : null),
756 fileSlug: pageRef.pageId,
757 pageNumber: pageRef.pageNumber,
758 title: pageRef.title,
759 htmlPath: pageRef.htmlPath,
760 status: 'failed',
761 error: failedPage.reason
762 })
763 }
764 for (const page of pageDescriptors) {
765 const existing = existingBySlug.get(page.pageId)
766 await db.upsertSessionPage({
767 id: existing?.id || page.id,
768 sessionId: context.sessionId,
769 legacyPageId:
770 existing?.legacy_page_id || (page.pageId.match(/^page-\d+$/) ? page.pageId : null),
771 fileSlug: page.pageId,
772 pageNumber: page.pageNumber,
773 title: page.title,
774 htmlPath: page.htmlPath,
775 status: 'completed',
776 error: null
777 })
778 }
779 await db.updateGenerationRunStatus(
780 context.runId,
781 pageDescriptors.length > 0 ? 'partial' : 'failed',
782 failedDetails
783 )
784 await db.updateSessionMetadata(context.sessionId, {
785 lastRunId: context.runId,
786 entryMode: 'multi_page',
787 indexPath,
788 projectId: context.projectId
789 })
790 await db.updateSessionDesignContract(context.sessionId, designContract)
791 await db.updateProjectStatus(context.projectId, 'draft')
792 emitDeckChunk({
793 type: 'llm_status',
794 payload: {
795 runId: context.runId,
796 stage: 'rendering',
797 label: progressText(context.appLocale, 'failed'),
798 progress: 90,
799 totalPages: outlineTitles.length,
800 detail: uiText(
801 context.appLocale,
802 `本次已完成 ${pageDescriptors.length}/${pageRefs.length} 页,失败页面:${failedDetails}`,
803 `${pageDescriptors.length}/${pageRefs.length} pages completed. Failed pages: ${failedDetails}`
804 )
805 }
806 })
807 throw new Error(
808 `部分页面生成失败(${failedPages.length}/${pageRefs.length}):${failedPages
809 .map((item) => `${item.pageId}(${item.title})`)
810 .join(', ')}`
811 )
812 }
813
814 const fallbackCompletionSummary =
815 placeholderPages.length > 0
816 ? uiText(
817 context.appLocale,
818 `演示已生成完成。当前共 ${pageDescriptors.length} 页,主题「${context.topic}」。其中 ${placeholderPages.length} 页可以继续优化。`,
819 `The presentation has been generated. It has ${pageDescriptors.length} pages for "${context.topic}". ${placeholderPages.length} pages can still be improved.`
820 )
821 : uiText(
822 context.appLocale,
823 `演示已生成完成。共 ${pageDescriptors.length} 页,主题「${context.topic}」。`,
824 `The presentation has been generated. It has ${pageDescriptors.length} pages for "${context.topic}".`
825 )
826 await emitAssistant(context, agentSummary.trim() || fallbackCompletionSummary)
827
828 await db.updateGenerationRunStatus(context.runId, 'completed', null)
829 await finalizeGenerationSuccess(ctx, {
830 context,
831 indexPath,
832 totalPages: outlineTitles.length,
833 generatedPages: pageDescriptors,
834 designContract
835 })
836 }
837
837 lines TYPESCRIPT