| 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 |