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