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