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