返回 oh-my-ppt
add-page-flow.ts
根目录 / src / main / generation / add-page-flow.ts
1 import log from 'electron-log/main.js'
2 import {
3 createGenerationPageCallbacks,
4 generatePagesWithRetry,
5 resolvePageHtmlPath,
6 uiText
7 } from './generation-utils'
8 import {
9 type GenerationContext,
10 resolveCommonContext,
11 type RuntimeJobExecutionContext
12 } from './context'
13 import { finalizeGenerationSuccess } from './finalization'
14 import { progressText } from '@shared/progress'
15 import path from 'path'
16 import fs from 'fs'
17 import { customAlphabet, nanoid } from 'nanoid'
18 import { type LayoutIntent } from '@shared/layout-intent'
19 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
20 import { buildProjectIndexHtml, buildPageScaffoldHtml, type DeckPageFile } from '../session/template-builder'
21 import { planNewPage } from './agent-runner'
22 import type { DesignContract } from '@shared/generation'
23 import type { ModelTimeoutProfile } from '@shared/model-timeout'
24 import type { ModelRuntimeConfig } from '../agent-runtime/model'
25 import { createPageImageFinalizer } from './page-image-finalizer'
26
27 const pageSlugId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10)
28
29 // ── Independent AddPage context (not shared with generation/retry/edit) ──
30
31 export type AddPageContext = {
32 sessionId: string
33 runId: string
34 userDescription: string
35 insertAfterPageNumber: number
36 targetPageId?: string
37 provider: string
38 apiKey: string
39 model: string
40 modelConfigId?: string
41 modelConfigName?: string
42 runModel?: string
43 providerBaseUrl: string
44 maxTokens: number
45 modelRuntime: ModelRuntimeConfig
46 modelTimeouts: Record<ModelTimeoutProfile, number>
47 projectDir: string
48 abortSignal: AbortSignal
49 styleId: string
50 styleSkillPrompt: string
51 imageGenerationPrompt: string
52 styleKey: string
53 styleName: string
54 styleVersion: string
55 slideSize: import('@shared/slide-size').SlideSizePreset
56 topic: string
57 deckTitle: string
58 appLocale: 'zh' | 'en'
59 sessionRecord: Record<string, unknown>
60 previousSessionStatus: string
61 messageScope: 'main' | 'page'
62 messagePageId?: string
63 projectId: string
64 effectiveMode: 'addPage'
65 visualEnabled: boolean
66 imageModelConfigId?: string
67 }
68
69 export async function resolveAddPageContext(
70 ctx: GenerationContext,
71 sessionId: string,
72 userDescription: string,
73 insertAfterPageNumber: number,
74 modelConfigId?: string,
75 targetPageId?: string,
76 execution?: RuntimeJobExecutionContext
77 ): Promise<AddPageContext> {
78 log.info('[generate:addPage] resolving context', {
79 sessionId,
80 insertAfterPageNumber,
81 targetPageId
82 })
83 const common = await resolveCommonContext(ctx, sessionId, modelConfigId, execution)
84 const { sessionRecord } = common
85
86 log.info('[generate:addPage] context resolved', {
87 sessionId,
88 projectDir: common.projectDir,
89 styleId: common.styleId,
90 provider: common.provider,
91 model: common.model,
92 insertAfterPageNumber
93 })
94
95 return {
96 ...common,
97 sessionId,
98 userDescription,
99 insertAfterPageNumber,
100 targetPageId,
101 sessionRecord,
102 messageScope: 'main' as const,
103 messagePageId: undefined,
104 effectiveMode: 'addPage' as const
105 }
106 }
107
108 // ── Execute the full add-page generation ──
109
110 export async function executeAddPageGeneration(
111 ctx: GenerationContext,
112 context: AddPageContext
113 ): Promise<void> {
114 const {
115 db,
116 agentManager,
117 sessionProject: { getPageSourceUrl },
118 runtimeEmitters: { createDeckProgressEmitter },
119 tuning: {
120 designContractTemperature: DESIGN_CONTRACT_TEMPERATURE,
121 pageGenerationTemperature: PAGE_GENERATION_TEMPERATURE
122 }
123 } = ctx
124
125 if (!context.apiKey) {
126 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
127 }
128
129 const emitChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
130 const sessionRecord = context.sessionRecord
131 const indexPath = path.join(context.projectDir, 'index.html')
132 await ctx.history.ensureBaseline(context.sessionId, context.projectDir)
133
134 // ── Step 1: Read designContract from session independent field ──
135 let designContract: DesignContract | undefined
136 if (
137 typeof sessionRecord.designContract === 'string' &&
138 sessionRecord.designContract.trim().length > 0
139 ) {
140 try {
141 designContract = JSON.parse(sessionRecord.designContract) as DesignContract
142 } catch {
143 // ignore malformed design contract
144 }
145 }
146 if (!designContract) {
147 throw new Error('当前会话缺少设计契约,无法新增页面。请先完成首次生成。')
148 }
149
150 // ── Step 2: Read existing pages from session_pages ──
151 const existingPages = await db.listSessionPages(context.sessionId)
152
153 if (existingPages.length === 0) {
154 throw new Error('当前会话没有已完成的页面,无法新增。请先完成首次生成。')
155 }
156
157 const insertAfterPageNumber = context.insertAfterPageNumber
158 const userDescription = context.userDescription
159 const targetPage = context.targetPageId
160 ? existingPages.find(
161 (page) => page.id === context.targetPageId || page.file_slug === context.targetPageId
162 )
163 : null
164 if (context.targetPageId && !targetPage) {
165 throw new Error('未找到新增页面的空白占位页')
166 }
167
168 // ── Step 3: Plan new page ──
169 emitChunk({
170 type: 'stage_started',
171 payload: {
172 runId: context.runId,
173 stage: 'planning',
174 label: uiText(context.appLocale, '正在规划新增页面', 'Planning the new page'),
175 progress: 2,
176 totalPages: 1
177 }
178 })
179
180 const newPageNumber =
181 targetPage?.page_number ?? Math.max(...existingPages.map((p) => p.page_number)) + 1
182 const newPageEntityId = targetPage?.id ?? nanoid()
183 const newPageId = targetPage?.file_slug ?? `page-${pageSlugId()}`
184 const newHtmlPath = targetPage
185 ? resolvePageHtmlPath({
186 projectDir: context.projectDir,
187 fileSlug: newPageId,
188 candidates: [targetPage.html_path]
189 })
190 : path.join(context.projectDir, `${newPageId}.html`)
191
192 const existingTitles = existingPages.map((p) => p.title).filter(Boolean)
193
194 let planResult: { title: string; contentOutline: string; layoutIntent: LayoutIntent }
195 try {
196 planResult = await planNewPage({
197 provider: context.provider,
198 apiKey: context.apiKey,
199 model: context.model,
200 baseUrl: context.providerBaseUrl,
201 maxTokens: context.maxTokens,
202 modelRuntime: context.modelRuntime,
203 modelTimeoutMs: context.modelTimeouts.planning,
204 temperature: DESIGN_CONTRACT_TEMPERATURE,
205 appLocale: context.appLocale,
206 userDescription,
207 topic: context.topic,
208 existingTitles,
209 sourceDocumentPaths: [],
210 signal: context.abortSignal
211 })
212 } catch (planError) {
213 // Retry plan once
214 try {
215 planResult = await planNewPage({
216 provider: context.provider,
217 apiKey: context.apiKey,
218 model: context.model,
219 baseUrl: context.providerBaseUrl,
220 maxTokens: context.maxTokens,
221 modelRuntime: context.modelRuntime,
222 modelTimeoutMs: context.modelTimeouts.planning,
223 temperature: DESIGN_CONTRACT_TEMPERATURE,
224 appLocale: context.appLocale,
225 userDescription,
226 topic: context.topic,
227 existingTitles,
228 sourceDocumentPaths: [],
229 signal: context.abortSignal
230 })
231 } catch {
232 throw new Error(
233 `规划新页面失败:${planError instanceof Error ? planError.message : String(planError)}`
234 )
235 }
236 }
237
238 // ── Step 4: Create scaffold ──
239 if (!targetPage) {
240 await fs.promises.writeFile(
241 newHtmlPath,
242 buildPageScaffoldHtml(
243 {
244 pageNumber: newPageNumber,
245 pageId: newPageId,
246 title: planResult.title
247 },
248 context.slideSize
249 ),
250 'utf-8'
251 )
252 }
253
254 // ── Step 5: Generate with agent ──
255 emitChunk({
256 type: 'stage_started',
257 payload: {
258 runId: context.runId,
259 stage: 'rendering',
260 label: uiText(context.appLocale, '正在生成新增页面', 'Generating the new page'),
261 progress: 10,
262 totalPages: 1
263 }
264 })
265
266 await db.createGenerationRun({
267 id: context.runId,
268 sessionId: context.sessionId,
269 mode: 'addPage',
270 totalPages: 1,
271 modelConfigId: context.modelConfigId,
272 metadata: {
273 addPage: true,
274 pageId: newPageId,
275 insertAfterPageNumber,
276 modelConfigId: context.modelConfigId,
277 modelConfigName: context.modelConfigName,
278 provider: context.provider,
279 model: context.model
280 }
281 })
282 await db.upsertGenerationPage({
283 runId: context.runId,
284 sessionId: context.sessionId,
285 pageId: newPageId,
286 pageNumber: newPageNumber,
287 title: planResult.title,
288 contentOutline: planResult.contentOutline,
289 layoutIntent: planResult.layoutIntent,
290 htmlPath: newHtmlPath,
291 status: 'pending'
292 })
293 await db.upsertSessionPage({
294 id: newPageEntityId,
295 sessionId: context.sessionId,
296 legacyPageId: null,
297 fileSlug: newPageId,
298 pageNumber: newPageNumber,
299 title: planResult.title,
300 htmlPath: newHtmlPath,
301 status: 'pending',
302 error: null
303 })
304
305 const pageFileMap: Record<string, string> = { [newPageId]: newHtmlPath }
306 const pageNumbers: Record<string, number> = { [newPageId]: newPageNumber }
307 const pageCallbacks = createGenerationPageCallbacks({
308 db,
309 runId: context.runId,
310 sessionId: context.sessionId
311 })
312 let agentSummary = ''
313 try {
314 const generationResult = await generatePagesWithRetry({
315 runArgs: {
316 sessionId: context.sessionId,
317 provider: context.provider,
318 apiKey: context.apiKey,
319 model: context.model,
320 baseUrl: context.providerBaseUrl,
321 maxTokens: context.maxTokens,
322 modelTimeoutMs: context.modelTimeouts.agent,
323 temperature: PAGE_GENERATION_TEMPERATURE,
324 styleId: context.styleId,
325 styleSkillPrompt: context.styleSkillPrompt,
326 hasStyleImageDirection: Boolean(context.imageGenerationPrompt.trim()),
327 styleKey: context.styleKey,
328 styleName: context.styleName,
329 styleVersion: context.styleVersion,
330 slideSize: context.slideSize,
331 appLocale: context.appLocale,
332 topic: context.topic,
333 deckTitle: context.deckTitle,
334 userMessage: userDescription,
335 outlineTitles: [planResult.title],
336 outlineItems: [planResult],
337 sourceDocumentPaths: [],
338 generationMode: 'generate',
339 visualEnabled: context.visualEnabled,
340 renderingLabel: uiText(context.appLocale, '正在生成新增页面', 'Generating the new page'),
341 pageTasks: [
342 {
343 pageNumber: newPageNumber,
344 pageId: newPageId,
345 title: planResult.title,
346 contentOutline: planResult.contentOutline,
347 layoutIntent: planResult.layoutIntent
348 }
349 ],
350 designContract,
351 projectDir: context.projectDir,
352 indexPath,
353 pageFileMap,
354 pageNumbers,
355 agentManager,
356 emit: (chunk) => emitChunk(chunk),
357 finalizePage: createPageImageFinalizer(ctx, {
358 sessionId: context.sessionId,
359 runId: context.runId,
360 visualEnabled: context.visualEnabled,
361 imageModelConfigId: context.imageModelConfigId,
362 imageGenerationPrompt: context.imageGenerationPrompt,
363 imagePromptDirector: {
364 provider: context.provider,
365 apiKey: context.apiKey,
366 model: context.model,
367 baseUrl: context.providerBaseUrl,
368 maxTokens: context.maxTokens,
369 modelRuntime: context.modelRuntime,
370 modelTimeoutMs: context.modelTimeouts.agent,
371 locale: context.appLocale
372 },
373 abortSignal: context.abortSignal
374 }),
375 ...pageCallbacks,
376 runId: context.runId,
377 signal: context.abortSignal
378 },
379 emitChunk,
380 appLocale: context.appLocale,
381 runId: context.runId,
382 totalPages: 1,
383 retryDetail: uiText(
384 context.appLocale,
385 `页面生成失败,正在重试...`,
386 `Page generation failed, retrying...`
387 )
388 })
389 agentSummary = generationResult.summary.trim()
390 if (context.abortSignal.aborted) throw new Error('生成已取消')
391
392 // ── Step 6: Validate generated page ──
393 if (!fs.existsSync(newHtmlPath)) {
394 throw new Error(`${newPageId}.html 缺失`)
395 }
396 const newPageValidation = validatePersistedPageHtml(
397 await fs.promises.readFile(newHtmlPath, 'utf-8'),
398 newPageId
399 )
400 if (!newPageValidation.valid) {
401 throw new Error(`新页面 HTML 验证失败: ${newPageValidation.errors.join('; ')}`)
402 }
403 const generatedPage = (await db.listGenerationPages(context.runId)).find(
404 (page) => page.page_id === newPageId
405 )
406 await db.upsertSessionPage({
407 id: newPageEntityId,
408 sessionId: context.sessionId,
409 legacyPageId: targetPage?.legacy_page_id || null,
410 fileSlug: newPageId,
411 pageNumber: newPageNumber,
412 title: generatedPage?.title || planResult.title,
413 htmlPath: newHtmlPath,
414 layoutIntent: generatedPage?.layout_intent || planResult.layoutIntent,
415 layoutId: generatedPage?.layout_id || null,
416 layoutContractVersion: generatedPage?.layout_contract_version || null,
417 status: 'completed',
418 error: null
419 })
420 } catch (error) {
421 const errorMessage = error instanceof Error ? error.message : 'Page generation failed'
422 await db.upsertSessionPage({
423 id: newPageEntityId,
424 sessionId: context.sessionId,
425 legacyPageId: null,
426 fileSlug: newPageId,
427 pageNumber: newPageNumber,
428 title: planResult.title,
429 htmlPath: newHtmlPath,
430 status: 'failed',
431 error: errorMessage
432 })
433 throw error
434 }
435
436 // ── Step 7: Merge into existing pages and renumber ──
437 const newPageHtml = await fs.promises.readFile(newHtmlPath, 'utf-8')
438 const newPageEntry = {
439 id: newPageEntityId,
440 pageNumber: targetPage?.page_number ?? insertAfterPageNumber + 1,
441 title: planResult.title,
442 pageId: newPageId,
443 htmlPath: newHtmlPath,
444 html: newPageHtml
445 }
446
447 // Read existing page HTMLs for the merge
448 const existingPageDescriptors = await Promise.all(
449 existingPages.map(async (page) => {
450 const pageId = page.file_slug
451 const htmlPath = resolvePageHtmlPath({
452 projectDir: context.projectDir,
453 fileSlug: pageId,
454 candidates: [page.html_path]
455 })
456 const html = fs.existsSync(htmlPath) ? await fs.promises.readFile(htmlPath, 'utf-8') : ''
457 return {
458 id: page.id,
459 pageNumber: page.page_number,
460 title: page.title,
461 pageId,
462 htmlPath,
463 html
464 }
465 })
466 )
467
468 const mergedPages = targetPage
469 ? existingPageDescriptors.map((page) => (page.id === targetPage.id ? newPageEntry : page))
470 : [
471 ...existingPageDescriptors.filter((page) => page.pageNumber <= insertAfterPageNumber),
472 newPageEntry,
473 ...existingPageDescriptors.filter((page) => page.pageNumber > insertAfterPageNumber)
474 ]
475
476 // Renumber
477 const renumberedPages = mergedPages.map((page, index) => ({
478 ...page,
479 pageNumber: index + 1
480 }))
481
482 // ── Step 8: Rebuild index.html ──
483 await fs.promises.writeFile(
484 indexPath,
485 buildProjectIndexHtml(
486 context.deckTitle,
487 renumberedPages.map(
488 (page): DeckPageFile => ({
489 id: page.id,
490 pageNumber: page.pageNumber,
491 pageId: page.pageId,
492 title: page.title,
493 htmlPath: path.basename(page.htmlPath)
494 })
495 ),
496 context.slideSize
497 ),
498 'utf-8'
499 )
500
501 const sessionPagesAfterGeneration = await db.listSessionPages(context.sessionId, {
502 includeDeleted: true
503 })
504 const sessionPageById = new Map(sessionPagesAfterGeneration.map((page) => [page.id, page]))
505 for (const page of renumberedPages) {
506 const sessionPage = sessionPageById.get(page.id)
507 await db.upsertSessionPage({
508 id: page.id,
509 sessionId: context.sessionId,
510 legacyPageId: sessionPage?.legacy_page_id || null,
511 fileSlug: page.pageId,
512 pageNumber: page.pageNumber,
513 title: page.title,
514 htmlPath: page.htmlPath,
515 layoutIntent: sessionPage?.layout_intent,
516 layoutId: sessionPage?.layout_id,
517 layoutContractVersion: sessionPage?.layout_contract_version,
518 status: sessionPage?.status || 'completed',
519 error: sessionPage?.error || null
520 })
521 }
522
523 // ── Step 9: Emit page_generated event ──
524 const renumberedNewPage = renumberedPages.find((p) => p.pageId === newPageId)
525 const generatedPayload = {
526 pageNumber: renumberedNewPage?.pageNumber ?? newPageEntry.pageNumber,
527 title: newPageEntry.title,
528 pageId: newPageEntry.pageId,
529 htmlPath: newPageEntry.htmlPath,
530 html: newPageEntry.html,
531 sourceUrl: getPageSourceUrl(newPageEntry.htmlPath)
532 }
533
534 emitChunk({
535 type: 'page_generated',
536 payload: {
537 runId: context.runId,
538 stage: 'rendering',
539 label: progressText(context.appLocale, 'completed'),
540 progress: 95,
541 currentPage: generatedPayload.pageNumber,
542 totalPages: renumberedPages.length,
543 ...generatedPayload
544 }
545 })
546
547 // ── Step 10: Finalize ──
548 // Persist assistant message
549 const assistantContent =
550 agentSummary ||
551 uiText(
552 context.appLocale,
553 `已新增页面「${planResult.title}」并插入到第 ${insertAfterPageNumber} 页之后。`,
554 `Added page "${planResult.title}" after page ${insertAfterPageNumber}.`
555 )
556 await db.addMessage(context.sessionId, {
557 role: 'assistant',
558 content: assistantContent,
559 type: 'text',
560 chat_scope: 'main' as const,
561 run_model: context.runModel
562 })
563 emitChunk({
564 type: 'assistant_message',
565 payload: {
566 runId: context.runId,
567 content: assistantContent,
568 chatType: 'main',
569 pageId: undefined
570 }
571 })
572
573 await finalizeGenerationSuccess(ctx, {
574 context,
575 indexPath,
576 totalPages: renumberedPages.length,
577 generatedPages: renumberedPages
578 })
579 }
580
580 lines TYPESCRIPT