返回 oh-my-ppt
template-deck-flow.ts
根目录 / src / main / generation / template-deck-flow.ts
1 import fs from 'fs'
2 import path from 'path'
3 import { progressText } from '@shared/progress'
4 import { normalizeLayoutIntent, type LayoutIntent } from '@shared/layout-intent'
5 import { buildProjectIndexHtml, type DeckPageFile } from '../session/template-builder'
6 import { planDeckWithLLM, runDeepAgentDeckGeneration } from './agent-runner'
7 import { isPlaceholderPageHtml, validatePersistedPageHtml } from '../presentation/html/html-utils'
8 import { finalizeGenerationSuccess } from './finalization'
9 import { uiText } from './generation-utils'
10 import type { DeckContext, EmitAssistantFn } from './types'
11 import { resolveDeckContext } from './deck-flow'
12 import { parseJsonObject } from '../ipc/utils'
13 import { resolveTemplateDesignContract } from '../templates/template-design-contract'
14 import { canUseSourcePlanDirectly, mapSourcePlanToOutlineItems } from './source-plan'
15 import type { GenerationContext, RuntimeJobExecutionContext } from './context'
16 import { createPageImageFinalizer } from './page-image-finalizer'
17
18 type TemplateSeedPage = {
19 id: string
20 pageNumber: number
21 pageId: string
22 title: string
23 htmlPath: string
24 status: string
25 }
26
27 type TemplateDeckContext = DeckContext & {
28 templateSeedPages: TemplateSeedPage[]
29 templateRetry: boolean
30 }
31
32 function isTemplateSession(sessionRecord: Record<string, unknown>): boolean {
33 const metadata = parseJsonObject(sessionRecord.metadata ?? sessionRecord.metadata_json)
34 return metadata.source === 'template' && typeof metadata.templateId === 'string'
35 }
36
37 export function shouldUseTemplateDeckFlow(sessionRecord: Record<string, unknown>): boolean {
38 return isTemplateSession(sessionRecord)
39 }
40
41 export async function resolveTemplateDeckContext(
42 ctx: GenerationContext,
43 event: Electron.IpcMainInvokeEvent,
44 payload: unknown,
45 execution?: RuntimeJobExecutionContext
46 ): Promise<TemplateDeckContext> {
47 const context = await resolveDeckContext(ctx, event, payload, execution)
48 if (!isTemplateSession(context.sessionRecord)) {
49 throw new Error('当前会话不是模板会话,不能使用模板生成链路')
50 }
51 const payloadRecord =
52 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
53 const templateRetry = payloadRecord.retry === true
54
55 const sessionPages = await ctx.db.listSessionPages(context.sessionId)
56 const allSeedPages = sessionPages
57 .filter((page) => page.html_path && page.file_slug)
58 .sort((a, b) => a.page_number - b.page_number)
59 .map((page) => ({
60 id: page.id,
61 pageNumber: page.page_number,
62 pageId: page.file_slug,
63 title: page.title || `第 ${page.page_number} 页`,
64 htmlPath: page.html_path,
65 status: page.status
66 }))
67 if (allSeedPages.length === 0) {
68 throw new Error('模板会话缺少已清洗的页面基底')
69 }
70 const seedPages = templateRetry
71 ? allSeedPages.filter((page) => page.status !== 'completed')
72 : allSeedPages
73 if (templateRetry && seedPages.length === 0) {
74 throw new Error('当前模板会话没有未完成页面。')
75 }
76
77 return {
78 ...context,
79 totalPages: seedPages.length,
80 templateSeedPages: seedPages,
81 templateRetry
82 }
83 }
84
85 export async function executeTemplateDeckGeneration(
86 ctx: GenerationContext,
87 emitAssistant: EmitAssistantFn,
88 context: TemplateDeckContext
89 ): Promise<void> {
90 const {
91 db,
92 agentManager,
93 sessionProject: { getPageSourceUrl, validateProjectIndexHtml },
94 runtimeEmitters: { createDeckProgressEmitter },
95 tuning: {
96 plannerTemperature: PLANNER_TEMPERATURE,
97 pageGenerationTemperature: PAGE_GENERATION_TEMPERATURE
98 }
99 } = ctx
100
101 if (!context.apiKey) {
102 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
103 }
104 if (context.templateSeedPages.length === 0) {
105 throw new Error('模板生成链路缺少模板页面基底')
106 }
107
108 const emitDeckChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
109 const templateMetadata = parseJsonObject(
110 context.sessionRecord.metadata ?? context.sessionRecord.metadata_json
111 )
112 const templateDesignContract = resolveTemplateDesignContract(
113 context.sessionRecord.designContract,
114 templateMetadata
115 )
116 await db.updateSessionDesignContract(context.sessionId, templateDesignContract)
117 const allSessionPages = await db.listSessionPages(context.sessionId)
118 const allPageRefs = allSessionPages
119 .filter((page) => page.html_path && page.file_slug)
120 .sort((a, b) => a.page_number - b.page_number)
121 .map((page) => ({
122 id: page.id,
123 pageNumber: page.page_number,
124 title: page.title || `第 ${page.page_number} 页`,
125 pageId: page.file_slug,
126 htmlPath: page.html_path
127 }))
128 const pageRefs = context.templateSeedPages.map((page) => ({
129 id: page.id,
130 pageNumber: page.pageNumber,
131 title: page.title,
132 pageId: page.pageId,
133 htmlPath: page.htmlPath
134 }))
135 const fullDeckPageCount = Math.max(allPageRefs.length, pageRefs.length)
136 const pageFileMap = Object.fromEntries(pageRefs.map((page) => [page.pageId, page.htmlPath]))
137 const pageNumbers = Object.fromEntries(pageRefs.map((page) => [page.pageId, page.pageNumber]))
138 const indexPath = path.join(context.projectDir, 'index.html')
139 const templateSystemPromptAddendum = [
140 '## 模板设计系统模式',
141 '- 当前页面文件来自用户模板复制并清洗后的页面基底;它定义本会话的当前设计系统。',
142 '- 以模板页面和 styleId 共同作为设计依据,优先保持视觉连续性。',
143 '- 本链路不抽象、不重算 designContract;直接从页面基底继承背景、配色、字体尺度、组件语言、留白节奏和首尾页角色。',
144 '- 如果上下文里存在 designContract,它只代表模板继承的字体与历史元数据;页面基底才是视觉事实来源。',
145 '- 不要无故换成一套全新的风格、背景、配色、字体尺度、组件语言或首尾页角色。',
146 '- 背景图、纹理图、装饰图片、蒙版、叠加层、CSS background-image/url(...)、SVG image href 属于模板骨架,不属于旧业务内容;生成时必须保留或等价复现。',
147 '- 写回页面时要使用模板里读到的本地资源路径,不要因为替换文字/数据而删除背景层、装饰层或承载它们的结构容器。',
148 '- 可以为了适配新内容做必要的局部调整:信息密度、模块数量、图表类型、局部排列、文字层级和避免遮挡的尺寸变化。',
149 '- 旧模板里的业务文字、数字、公司名、日期和结论不是事实来源,必须用用户 brief/source document 替换。',
150 '- 新增/复用的中间页应沿着模板设计系统延展,而不是机械复制旧内容。'
151 ].join('\n')
152 const templateSinglePagePromptAddendum = [
153 'Template design system for this slide:',
154 '- The existing target page file is a copied template page base. Preserve its visual system and layout language.',
155 '- Replace old text/data/media meaning with the new slide content, but do not redesign the whole page.',
156 '- Treat background images, texture images, decorative images, masks, overlay layers, CSS background-image/url(...) references, and SVG image hrefs as template structure, not old business content.',
157 '- Keep those template assets and their local paths in the written page unless the user explicitly asks to remove them; text/data changes must not strip the visual shell.',
158 '- Keep color language, typography scale, spacing rhythm, component shapes, and chart/table styling unless a local adjustment is needed to avoid overlap.',
159 '- Do not infer or invent a separate deck-wide design contract for this template run.',
160 '- If a design contract is present, treat it as inherited font/runtime metadata only; the page base remains the visual source of truth.',
161 '- Do not treat old template business text, numbers, company names, dates, or conclusions as facts.'
162 ].join('\n')
163
164 emitDeckChunk({
165 type: 'stage_started',
166 payload: {
167 runId: context.runId,
168 stage: 'preflight',
169 label: progressText(context.appLocale, 'understanding'),
170 progress: 2,
171 totalPages: fullDeckPageCount
172 }
173 })
174
175 await db.addMessage(context.sessionId, {
176 role: 'system',
177 content: uiText(
178 context.appLocale,
179 '正在按模板设计系统准备生成内容。',
180 'Preparing content generation with the template design system.'
181 ),
182 type: 'stream_chunk',
183 chat_scope: context.messageScope,
184 page_id: context.messagePageId,
185 run_model: context.runModel
186 })
187
188 await db.createGenerationRun({
189 id: context.runId,
190 sessionId: context.sessionId,
191 mode: 'generate',
192 totalPages: pageRefs.length,
193 modelConfigId: context.modelConfigId,
194 metadata: {
195 templateGeneration: true,
196 templateRetry: context.templateRetry,
197 topic: context.topic,
198 styleId: context.styleId,
199 modelConfigId: context.modelConfigId,
200 modelConfigName: context.modelConfigName,
201 provider: context.provider,
202 model: context.model,
203 projectDir: context.projectDir,
204 indexPath
205 }
206 })
207
208 emitDeckChunk({
209 type: 'stage_started',
210 payload: {
211 runId: context.runId,
212 stage: 'planning',
213 label: progressText(context.appLocale, 'planning'),
214 progress: 6,
215 totalPages: fullDeckPageCount
216 }
217 })
218
219 const latestPageSnapshot = context.templateRetry
220 ? await db.listLatestGenerationPageSnapshot(context.sessionId)
221 : []
222 const shouldUseSourcePlan =
223 !context.templateRetry &&
224 canUseSourcePlanDirectly({
225 sourcePlan: context.sourcePlan,
226 totalPages: pageRefs.length,
227 userMessage: context.userMessage
228 })
229 const plannedOutlineItems = context.templateRetry
230 ? pageRefs.map((page) => {
231 const snapshot = latestPageSnapshot.find((item) => item.page_id === page.pageId)
232 return {
233 title: snapshot?.title?.trim() || page.title,
234 contentOutline: snapshot?.content_outline?.trim() || '',
235 layoutIntent: snapshot?.layout_intent
236 ? normalizeLayoutIntent(snapshot.layout_intent)
237 : undefined
238 }
239 })
240 : shouldUseSourcePlan && context.sourcePlan
241 ? mapSourcePlanToOutlineItems(context.sourcePlan)
242 : await 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
262 const outlineItems = pageRefs.map((page, index) => {
263 const planned = plannedOutlineItems[index]
264 return {
265 title: planned?.title?.trim() || page.title,
266 contentOutline: planned?.contentOutline?.trim() || '',
267 layoutIntent: planned?.layoutIntent
268 }
269 })
270 const outlineTitles = outlineItems.map((item) => item.title)
271 const existingSessionPages = await db.listSessionPages(context.sessionId, {
272 includeDeleted: true
273 })
274 const existingSessionPageBySlug = new Map(
275 existingSessionPages.map((page) => [page.file_slug, page])
276 )
277 for (let index = 0; index < pageRefs.length; index += 1) {
278 const page = pageRefs[index]
279 page.title = outlineTitles[index] || page.title
280 await db.upsertGenerationPage({
281 runId: context.runId,
282 sessionId: context.sessionId,
283 pageId: page.pageId,
284 pageNumber: page.pageNumber,
285 title: page.title,
286 contentOutline: outlineItems[index]?.contentOutline || '',
287 layoutIntent: outlineItems[index]?.layoutIntent,
288 htmlPath: page.htmlPath,
289 status: 'pending'
290 })
291 const existing = existingSessionPageBySlug.get(page.pageId)
292 await db.upsertSessionPage({
293 id: existing?.id || page.id,
294 sessionId: context.sessionId,
295 legacyPageId: existing?.legacy_page_id || null,
296 fileSlug: page.pageId,
297 pageNumber: page.pageNumber,
298 title: page.title,
299 htmlPath: page.htmlPath,
300 status: 'pending',
301 error: null
302 })
303 emitDeckChunk({
304 type: 'page_planned',
305 payload: {
306 runId: context.runId,
307 stage: 'planning',
308 label: progressText(context.appLocale, 'planning'),
309 progress: 9,
310 currentPage: page.pageNumber,
311 totalPages: fullDeckPageCount,
312 id: page.id,
313 pageNumber: page.pageNumber,
314 pageId: page.pageId,
315 title: page.title,
316 htmlPath: page.htmlPath
317 }
318 })
319 }
320
321 const titleByPageId = new Map(pageRefs.map((page) => [page.pageId, page.title]))
322 await fs.promises.writeFile(
323 indexPath,
324 buildProjectIndexHtml(
325 context.deckTitle,
326 allPageRefs.map(
327 (page): DeckPageFile => ({
328 id: page.id,
329 pageNumber: page.pageNumber,
330 pageId: page.pageId,
331 title: titleByPageId.get(page.pageId) || page.title,
332 htmlPath: path.basename(page.htmlPath)
333 })
334 ),
335 context.slideSize
336 ),
337 'utf-8'
338 )
339
340 emitDeckChunk({
341 type: 'llm_status',
342 payload: {
343 runId: context.runId,
344 stage: 'preflight',
345 label: progressText(context.appLocale, 'generating'),
346 progress: 10,
347 totalPages: fullDeckPageCount,
348 detail: uiText(
349 context.appLocale,
350 context.templateRetry
351 ? `已准备继续生成 ${pageRefs.length} 个未完成模板页面`
352 : '已按模板设计系统完成规划并更新目录标题',
353 context.templateRetry
354 ? `Prepared to continue ${pageRefs.length} unfinished template pages`
355 : 'Planning completed with the template design system and index titles updated'
356 )
357 }
358 })
359
360 const persistedGeneratedPagesById = new Map<
361 string,
362 {
363 pageNumber: number
364 title: string
365 pageId: string
366 htmlPath: string
367 }
368 >()
369 let completedTargetPageCount = 0
370 const persistGenerationSnapshotMetadata = async (): Promise<void> => {
371 await db.updateSessionMetadata(context.sessionId, {
372 ...templateMetadata,
373 lastRunId: context.runId,
374 entryMode: 'template_multi_page',
375 indexPath,
376 projectId: context.projectId
377 })
378 }
379 const persistSessionPageLayoutSource = async (
380 page: {
381 pageNumber: number
382 pageId: string
383 title: string
384 htmlPath: string
385 layoutIntent?: LayoutIntent
386 layoutId: string
387 layoutContractVersion: number
388 },
389 status: 'completed' | 'failed',
390 error: string | null
391 ): Promise<void> => {
392 const pageRef = pageRefs.find((item) => item.pageId === page.pageId)
393 const existing = existingSessionPageBySlug.get(page.pageId)
394 if (!pageRef) return
395 await db.upsertSessionPage({
396 id: existing?.id || pageRef.id,
397 sessionId: context.sessionId,
398 legacyPageId: existing?.legacy_page_id || null,
399 fileSlug: page.pageId,
400 pageNumber: page.pageNumber,
401 title: page.title,
402 htmlPath: page.htmlPath,
403 layoutIntent: page.layoutIntent || null,
404 layoutId: page.layoutId,
405 layoutContractVersion: page.layoutContractVersion,
406 status,
407 error
408 })
409 }
410 const persistCompletedGeneratedPage = async (page: {
411 pageNumber: number
412 pageId: string
413 title: string
414 contentOutline: string
415 layoutIntent?: LayoutIntent
416 layoutId: string
417 layoutContractVersion: number
418 htmlPath: string
419 }): Promise<void> => {
420 if (!fs.existsSync(page.htmlPath)) {
421 throw new Error(`${page.pageId}.html 缺失`)
422 }
423 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
424 const validation = validatePersistedPageHtml(html, page.pageId)
425 if (!validation.valid) {
426 throw new Error(`HTML 验证失败 (${page.pageId}): ${validation.errors.join('; ')}`)
427 }
428 await db.upsertGenerationPage({
429 runId: context.runId,
430 sessionId: context.sessionId,
431 pageId: page.pageId,
432 pageNumber: page.pageNumber,
433 title: page.title,
434 contentOutline: page.contentOutline,
435 layoutIntent: page.layoutIntent,
436 layoutId: page.layoutId,
437 layoutContractVersion: page.layoutContractVersion,
438 htmlPath: page.htmlPath,
439 status: 'completed'
440 })
441 await persistSessionPageLayoutSource(page, 'completed', null)
442 persistedGeneratedPagesById.set(page.pageId, {
443 pageNumber: page.pageNumber,
444 title: page.title,
445 pageId: page.pageId,
446 htmlPath: page.htmlPath
447 })
448 completedTargetPageCount += 1
449 const pageRef = pageRefs.find((item) => item.pageId === page.pageId)
450 emitDeckChunk({
451 type: 'page_generated',
452 payload: {
453 runId: context.runId,
454 stage: 'rendering',
455 label: progressText(context.appLocale, 'completed'),
456 progress: 10 + Math.round((completedTargetPageCount / Math.max(pageRefs.length, 1)) * 80),
457 currentPage: page.pageNumber,
458 totalPages: fullDeckPageCount,
459 id: pageRef?.id,
460 pageNumber: page.pageNumber,
461 title: page.title,
462 html,
463 pageId: page.pageId,
464 htmlPath: page.htmlPath,
465 sourceUrl: getPageSourceUrl(page.htmlPath)
466 }
467 })
468 await persistGenerationSnapshotMetadata()
469 }
470 const persistFailedGeneratedPage = async (page: {
471 pageNumber: number
472 pageId: string
473 title: string
474 contentOutline: string
475 layoutIntent?: LayoutIntent
476 layoutId: string
477 layoutContractVersion: number
478 htmlPath: string
479 reason: string
480 }): Promise<void> => {
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: 'failed',
493 error: page.reason
494 })
495 await persistSessionPageLayoutSource(page, 'failed', page.reason)
496 await persistGenerationSnapshotMetadata()
497 }
498
499 const { summary: agentSummary, failedPages } = await runDeepAgentDeckGeneration({
500 sessionId: context.sessionId,
501 provider: context.provider,
502 apiKey: context.apiKey,
503 model: context.model,
504 baseUrl: context.providerBaseUrl,
505 maxTokens: context.maxTokens,
506 modelTimeoutMs: context.modelTimeouts.agent,
507 temperature: PAGE_GENERATION_TEMPERATURE,
508 styleId: context.styleId,
509 styleSkillPrompt: context.styleSkill.prompt,
510 hasStyleImageDirection: false,
511 styleKey: context.styleKey,
512 styleName: context.styleName,
513 styleVersion: context.styleVersion,
514 slideSize: context.slideSize,
515 appLocale: context.appLocale,
516 topic: context.topic,
517 deckTitle: context.deckTitle,
518 userMessage: context.userMessage,
519 outlineTitles,
520 outlineItems,
521 pageTasks: pageRefs.map((page, index) => ({
522 pageNumber: page.pageNumber,
523 pageId: page.pageId,
524 title: page.title,
525 contentOutline: outlineItems[index]?.contentOutline || '',
526 layoutIntent: outlineItems[index]?.layoutIntent
527 })),
528 sourceDocumentPaths: context.sourceDocumentPaths,
529 referenceDocumentPath: context.referenceDocumentPath,
530 sourcePlan: context.sourcePlan,
531 designContract: templateDesignContract,
532 systemPromptAddendum: templateSystemPromptAddendum,
533 singlePagePromptAddendum: templateSinglePagePromptAddendum,
534 requireTemplatePageRead: true,
535 generationMode: 'generate',
536 visualEnabled: false,
537 projectDir: context.projectDir,
538 indexPath,
539 pageFileMap,
540 pageNumbers,
541 agentManager,
542 emit: (chunk) => emitDeckChunk(chunk),
543 finalizePage: createPageImageFinalizer(ctx, {
544 sessionId: context.sessionId,
545 runId: context.runId,
546 visualEnabled: false,
547 abortSignal: context.abortSignal
548 }),
549 onPageCompleted: persistCompletedGeneratedPage,
550 onPageFailed: persistFailedGeneratedPage,
551 runId: context.runId,
552 signal: context.abortSignal
553 })
554
555 const failedPageIdSet = new Set(failedPages.map((item) => item.pageId))
556 const postValidationFailures: Array<{ pageId: string; title: string; reason: string }> = []
557 if (!fs.existsSync(indexPath)) {
558 postValidationFailures.push({
559 pageId: 'index',
560 title: 'index.html',
561 reason: 'index.html 缺失'
562 })
563 } else {
564 const indexHtml = await fs.promises.readFile(indexPath, 'utf-8')
565 const indexErrors = validateProjectIndexHtml(indexHtml)
566 if (indexErrors.length > 0) {
567 postValidationFailures.push({
568 pageId: 'index',
569 title: 'index.html',
570 reason: indexErrors.join('; ')
571 })
572 }
573 }
574
575 const pageDescriptors: Array<{
576 id?: string
577 pageNumber: number
578 title: string
579 pageId: string
580 htmlPath: string
581 html: string
582 }> = []
583 const placeholderPages: string[] = []
584 for (const pageRef of pageRefs) {
585 if (failedPageIdSet.has(pageRef.pageId)) continue
586 if (!fs.existsSync(pageRef.htmlPath)) {
587 postValidationFailures.push({
588 pageId: pageRef.pageId,
589 title: pageRef.title,
590 reason: `${pageRef.pageId}.html 缺失`
591 })
592 continue
593 }
594 const html = await fs.promises.readFile(pageRef.htmlPath, 'utf-8')
595 const validation = validatePersistedPageHtml(html, pageRef.pageId)
596 if (!validation.valid) {
597 postValidationFailures.push({
598 pageId: pageRef.pageId,
599 title: pageRef.title,
600 reason: validation.errors.join('; ')
601 })
602 continue
603 }
604 if (isPlaceholderPageHtml(html)) {
605 placeholderPages.push(pageRef.pageId)
606 }
607 pageDescriptors.push({
608 id: pageRef.id,
609 pageNumber: pageRef.pageNumber,
610 title: pageRef.title,
611 pageId: pageRef.pageId,
612 htmlPath: pageRef.htmlPath,
613 html
614 })
615 if (!persistedGeneratedPagesById.has(pageRef.pageId)) {
616 const outlineIndex = pageRefs.findIndex((item) => item.pageId === pageRef.pageId)
617 await db.upsertGenerationPage({
618 runId: context.runId,
619 sessionId: context.sessionId,
620 pageId: pageRef.pageId,
621 pageNumber: pageRef.pageNumber,
622 title: pageRef.title,
623 contentOutline: outlineItems[outlineIndex]?.contentOutline || '',
624 layoutIntent: outlineItems[outlineIndex]?.layoutIntent,
625 htmlPath: pageRef.htmlPath,
626 status: 'completed'
627 })
628 }
629 }
630
631 const allFailedPages = [
632 ...failedPages,
633 ...postValidationFailures.filter((item) => item.pageId !== 'index')
634 ]
635 if (allFailedPages.length > 0 || postValidationFailures.some((item) => item.pageId === 'index')) {
636 const failedDetails = [
637 ...allFailedPages,
638 ...postValidationFailures.filter((item) => item.pageId === 'index')
639 ]
640 .map((item) => `${item.pageId}(${item.title}):${item.reason}`)
641 .join(';')
642 const existingSessionPages = await db.listSessionPages(context.sessionId, {
643 includeDeleted: true
644 })
645 const existingBySlug = new Map(existingSessionPages.map((page) => [page.file_slug, page]))
646 for (const pageRef of pageRefs) {
647 const failed = allFailedPages.find((item) => item.pageId === pageRef.pageId)
648 const existing = existingBySlug.get(pageRef.pageId)
649 await db.upsertSessionPage({
650 id: existing?.id || pageRef.id,
651 sessionId: context.sessionId,
652 legacyPageId: existing?.legacy_page_id || null,
653 fileSlug: pageRef.pageId,
654 pageNumber: pageRef.pageNumber,
655 title: pageRef.title,
656 htmlPath: pageRef.htmlPath,
657 status: failed ? 'failed' : 'completed',
658 error: failed?.reason || null
659 })
660 }
661 await db.updateGenerationRunStatus(
662 context.runId,
663 pageDescriptors.length > 0 ? 'partial' : 'failed',
664 failedDetails
665 )
666 await persistGenerationSnapshotMetadata()
667 await db.updateProjectStatus(context.projectId, 'draft')
668 throw new Error(
669 `模板生成部分页面失败(${allFailedPages.length}/${pageRefs.length}):${allFailedPages
670 .map((item) => `${item.pageId}(${item.title})`)
671 .join(', ')}`
672 )
673 }
674
675 if (placeholderPages.length > 0) {
676 emitDeckChunk({
677 type: 'llm_status',
678 payload: {
679 runId: context.runId,
680 stage: 'validation',
681 label: progressText(context.appLocale, 'completed'),
682 progress: 94,
683 totalPages: fullDeckPageCount,
684 detail: uiText(
685 context.appLocale,
686 `以下页面可能仍是占位内容:${placeholderPages.join(', ')}`,
687 `These pages may still contain placeholders: ${placeholderPages.join(', ')}`
688 )
689 }
690 })
691 }
692
693 const fallbackCompletionSummary = uiText(
694 context.appLocale,
695 context.templateRetry
696 ? `未完成模板页已继续生成完成。当前共 ${fullDeckPageCount} 页,主题「${context.topic}」。`
697 : `模板生成已完成。共 ${fullDeckPageCount} 页,主题「${context.topic}」。`,
698 context.templateRetry
699 ? `Unfinished template pages are complete. The deck now has ${fullDeckPageCount} pages for "${context.topic}".`
700 : `Template generation completed. It has ${fullDeckPageCount} pages for "${context.topic}".`
701 )
702 await emitAssistant(context, agentSummary.trim() || fallbackCompletionSummary)
703 await finalizeGenerationSuccess(ctx, {
704 context,
705 indexPath,
706 totalPages: fullDeckPageCount,
707 generatedPages: pageDescriptors
708 })
709 await persistGenerationSnapshotMetadata()
710 }
711
711 lines TYPESCRIPT