返回 oh-my-ppt
edit-deck-allpage-flow.ts
根目录 / src / main / generation / edit-deck-allpage-flow.ts
1 import fs from 'fs'
2 import path from 'path'
3 import log from 'electron-log/main.js'
4 import { nanoid } from 'nanoid'
5 import { progressText } from '@shared/progress'
6 import { normalizeLayoutIntent } from '@shared/layout-intent'
7 import {
8 MAX_SELECTED_PAGES,
9 MAX_STYLE_SWITCH_PAGES,
10 type DesignContract,
11 type GeneratedPagePayload
12 } from '@shared/generation'
13 import type { GenerationContext } from './context'
14 import type { EditContext, EmitAssistantFn } from './types'
15 import {
16 buildEditNoChangeRetryMessage,
17 buildEditToolSchemaRetryMessage,
18 buildEditValidationRetryMessage,
19 isEditToolSchemaRetryableError,
20 isEditValidationRetryableError,
21 resolvePageHtmlPath,
22 uiText,
23 validateChangedPages
24 } from './generation-utils'
25 import {
26 executeDeckEditBatchFlow,
27 type DeckEditBatchResult,
28 type DeckEditCompletedBatch,
29 type DeckEditFailedBatch
30 } from './edit-deck-batch-flow'
31 import { runDeepAgentDeckAllPageEdit } from './agent-runner'
32 import { resolveRemainingFailedPageInfo } from './edit-deck-failure-state'
33 import {
34 buildLocalSuccessfulEditSummary,
35 emitSuccessfulEditSummary
36 } from './edit-summary'
37
38 export function filterPageRefsBySelectedPageIds<T extends { pageId: string }>(
39 pageRefs: T[],
40 selectPageIds: string[]
41 ): T[] {
42 if (selectPageIds.length === 0) return pageRefs
43 const requestedPageIdSet = new Set(selectPageIds)
44 return pageRefs.filter((ref) => requestedPageIdSet.has(ref.pageId))
45 }
46
47 export const isDeckEditRateLimitRetryableError = (error: unknown): boolean => {
48 const message = error instanceof Error ? error.message : String(error || '')
49 return /\b429\b|too many requests|rate.?limit|resource exhausted/i.test(message)
50 }
51
52 export async function executeDeckAllPageEditGeneration(
53 ctx: GenerationContext,
54 emitAssistant: EmitAssistantFn,
55 context: EditContext
56 ): Promise<void> {
57 const {
58 db,
59 agentManager,
60 sessionProject: { getPageSourceUrl },
61 runtimeEmitters: { createDeckProgressEmitter },
62 tuning: { pageEditDefaultTemperature: PAGE_EDIT_DEFAULT_TEMPERATURE }
63 } = ctx
64
65 if (!context.apiKey) {
66 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
67 }
68 if (context.messageScope !== 'main') {
69 throw new Error('deck 全页编辑只接受主会话消息。')
70 }
71
72 const projectDir = context.projectDir
73 const indexPath = path.join(projectDir, 'index.html')
74 let outlineTitles: string[] = context.userProvidedOutlineTitles
75 let pageRefs: Array<{
76 id: string
77 pageNumber: number
78 title: string
79 pageId: string
80 htmlPath: string
81 }> = []
82 let savedDesignContract: DesignContract | undefined = context.designContract
83
84 const sessionPages = await db.listSessionPages(context.sessionId)
85 if (sessionPages.length === 0) {
86 throw new Error('session_pages is empty after migration; cannot edit this session')
87 }
88 pageRefs = sessionPages.map((page) => ({
89 id: page.id,
90 pageNumber: page.page_number,
91 title: page.title || `第${page.page_number}页`,
92 pageId: page.file_slug,
93 htmlPath: resolvePageHtmlPath({
94 projectDir,
95 fileSlug: page.file_slug,
96 candidates: [page.html_path]
97 })
98 }))
99 if (outlineTitles.length === 0) {
100 outlineTitles = pageRefs.map((page) => page.title)
101 }
102
103 const latestPageSnapshot = await db.listLatestGenerationPageSnapshot(context.sessionId)
104 const failedPageInfoById = new Map<string, { title: string; reason: string }>()
105 for (const page of sessionPages) {
106 if (page.status !== 'failed') continue
107 failedPageInfoById.set(page.file_slug, {
108 title: page.title || page.file_slug,
109 reason: page.error || '页面仍需修复'
110 })
111 }
112
113 const sessionRecord = (context.session || {}) as Record<string, unknown>
114 if (
115 !savedDesignContract &&
116 !context.resetVisualStyle &&
117 typeof sessionRecord.designContract === 'string' &&
118 sessionRecord.designContract.trim().length > 0
119 ) {
120 try {
121 savedDesignContract = JSON.parse(sessionRecord.designContract) as DesignContract
122 } catch {
123 /* ignore invalid persisted design contract */
124 }
125 }
126
127 pageRefs.sort((a, b) => a.pageNumber - b.pageNumber)
128 const requestedPageIdSet = new Set(context.selectPageIds || [])
129 const selectedPageRefs = filterPageRefsBySelectedPageIds(pageRefs, context.selectPageIds || [])
130 if (requestedPageIdSet.size > 0 && selectedPageRefs.length === 0) {
131 throw new Error(
132 `Selected pages not found in session_pages: ${Array.from(requestedPageIdSet).join(', ')}`
133 )
134 }
135 const pageLimit = context.resetVisualStyle ? MAX_STYLE_SWITCH_PAGES : MAX_SELECTED_PAGES
136 if (selectedPageRefs.length > pageLimit) {
137 throw new Error(
138 uiText(
139 context.appLocale,
140 `一次最多编辑 ${pageLimit} 页,请先选择更小的页面范围。`,
141 `You can edit at most ${pageLimit} pages at a time. Select a smaller page range.`
142 )
143 )
144 }
145 if (outlineTitles.length !== pageRefs.length) {
146 outlineTitles = pageRefs.map((ref) => ref.title)
147 }
148
149 const outlineByPageId = new Map(
150 latestPageSnapshot.map((page) => [page.page_id, page.content_outline || ''])
151 )
152 const layoutIntentByPageId = new Map(
153 latestPageSnapshot.map((page) => [
154 page.page_id,
155 !context.resetVisualStyle && page.layout_intent
156 ? normalizeLayoutIntent(page.layout_intent)
157 : undefined
158 ])
159 )
160 const outlineItems = pageRefs.map((ref) => ({
161 title: ref.title,
162 contentOutline: outlineByPageId.get(ref.pageId) || '',
163 layoutIntent: layoutIntentByPageId.get(ref.pageId)
164 }))
165 const pageFileMap = Object.fromEntries(pageRefs.map((p) => [p.pageId, p.htmlPath]))
166 const pageNumbers = Object.fromEntries(pageRefs.map((p) => [p.pageId, p.pageNumber]))
167 const selectedPageIds = selectedPageRefs.map((p) => p.pageId)
168 const existingPageIdsBeforeRun: string[] = []
169 const beforeReads = await Promise.all(
170 pageRefs.map(async (ref) => {
171 if (!fs.existsSync(ref.htmlPath)) return null
172 const html = await fs.promises.readFile(ref.htmlPath, 'utf-8')
173 return { pageId: ref.pageId, html }
174 })
175 )
176 for (const item of beforeReads) {
177 if (!item) continue
178 existingPageIdsBeforeRun.push(item.pageId)
179 }
180
181 if (!context.skipGenerationRunCreation) {
182 await db.createGenerationRun({
183 id: context.runId,
184 sessionId: context.sessionId,
185 mode: 'edit',
186 totalPages: selectedPageRefs.length,
187 modelConfigId: context.modelConfigId,
188 metadata: {
189 editScope: 'deck',
190 selectedPageId: null,
191 selectPageIds: selectedPageIds,
192 selector: null,
193 modelConfigId: context.modelConfigId,
194 modelConfigName: context.modelConfigName,
195 provider: context.provider,
196 model: context.model
197 }
198 })
199 }
200
201 const emitEditChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
202 emitEditChunk({
203 type: 'stage_started',
204 payload: {
205 runId: context.runId,
206 stage: 'editing',
207 label: uiText(context.appLocale, '正在准备批量编辑', 'Preparing batch edit'),
208 progress: 10,
209 totalPages: selectedPageRefs.length
210 }
211 })
212
213 await ctx.history.ensureBaseline(context.sessionId, projectDir)
214
215 const editRunArgs = {
216 sessionId: context.sessionId,
217 provider: context.provider,
218 apiKey: context.apiKey,
219 model: context.model,
220 baseUrl: context.providerBaseUrl,
221 maxTokens: context.maxTokens,
222 modelTimeoutMs: context.modelTimeouts.agent,
223 temperature: PAGE_EDIT_DEFAULT_TEMPERATURE,
224 styleId: context.styleId,
225 styleSkillPrompt: context.styleSkill.prompt,
226 styleKey: context.styleKey,
227 styleName: context.styleName,
228 styleVersion: context.styleVersion,
229 slideSize: context.slideSize,
230 appLocale: context.appLocale,
231 topic: context.topic,
232 deckTitle: context.deckTitle,
233 userMessage: context.userMessage,
234 outlineTitles,
235 outlineItems,
236 sourceDocumentPaths: context.sourceDocumentPaths,
237 projectDir,
238 indexPath,
239 pageFileMap,
240 pageNumbers,
241 designContract: savedDesignContract,
242 existingPageIds: existingPageIdsBeforeRun,
243 agentManager,
244 runId: context.runId,
245 signal: context.abortSignal
246 } satisfies Omit<Parameters<typeof runDeepAgentDeckAllPageEdit>[0], 'selectPageIds' | 'emit'>
247
248 const outlineItemByPageId = new Map(
249 pageRefs.map((page, index) => [page.pageId, outlineItems[index]])
250 )
251 const existingSessionPages = await db.listSessionPages(context.sessionId, {
252 includeDeleted: true
253 })
254 const existingBySlug = new Map(existingSessionPages.map((sp) => [sp.file_slug, sp]))
255 let batchResults: DeckEditBatchResult[]
256 try {
257 context.onDeckEditStarted?.()
258 batchResults = await executeDeckEditBatchFlow({
259 pageRefs: selectedPageRefs,
260 indexPath,
261 originalUserMessage: context.userMessage,
262 runId: context.runId,
263 appLocale: context.appLocale,
264 signal: context.abortSignal,
265 emit: emitEditChunk,
266 validateChangedPages,
267 buildRetryMessage: ({ baseMessage, error, kind }) => {
268 const detail = error instanceof Error ? error.message : String(error || '')
269 if (kind === 'no_change') {
270 return buildEditNoChangeRetryMessage({
271 originalMessage: baseMessage,
272 allowedTool: 'update_page_file',
273 selectedPageId: null
274 })
275 }
276 if (kind === 'validation' || isEditValidationRetryableError(error)) {
277 return buildEditValidationRetryMessage(baseMessage, detail)
278 }
279 if (isEditToolSchemaRetryableError(error)) {
280 return buildEditToolSchemaRetryMessage({
281 originalMessage: baseMessage,
282 detail,
283 allowedTool: 'update_page_file',
284 selectedPageId: null
285 })
286 }
287 if (isDeckEditRateLimitRetryableError(error)) {
288 return [
289 baseMessage,
290 '',
291 'Retry requirement:',
292 `- The previous page request was rate limited: ${detail}`,
293 '- Retry this page once after the configured stagger delay.',
294 '- Edit only the current page and do not modify index.html.'
295 ].join('\n')
296 }
297 return null
298 },
299 runPageAttempt: async ({ pageId, userMessage, isRetry, emit }) => {
300 if (isRetry) {
301 const retryPage = selectedPageRefs.find((page) => page.pageId === pageId)
302 const currentPage = Math.max(
303 1,
304 selectedPageRefs.findIndex((page) => page.pageId === pageId) + 1
305 )
306 emit({
307 type: 'llm_status',
308 payload: {
309 runId: context.runId,
310 stage: 'editing',
311 label: uiText(
312 context.appLocale,
313 `正在重试 P${retryPage?.pageNumber ?? currentPage}`,
314 `Retrying P${retryPage?.pageNumber ?? currentPage}`
315 ),
316 progress: 0,
317 currentPage,
318 totalPages: selectedPageRefs.length,
319 detail: uiText(
320 context.appLocale,
321 `正在重试页面:${pageId}`,
322 `Retrying page: ${pageId}`
323 )
324 }
325 })
326 }
327 return runDeepAgentDeckAllPageEdit({
328 ...editRunArgs,
329 userMessage,
330 selectPageIds: [pageId],
331 emit
332 })
333 },
334 onPageCompleted: async (result) => {
335 const pageRef = selectedPageRefs.find((p) => p.pageId === result.pageId)
336 if (!pageRef) return
337 const outlineItem = outlineItemByPageId.get(result.pageId)
338 await db.upsertGenerationPage({
339 runId: context.runId,
340 sessionId: context.sessionId,
341 pageId: result.pageId,
342 pageNumber: pageRef.pageNumber,
343 title: pageRef.title,
344 contentOutline: outlineItem?.contentOutline || '',
345 layoutIntent: outlineItem?.layoutIntent,
346 htmlPath: pageRef.htmlPath,
347 status: 'completed',
348 retryCount: result.retryCount
349 })
350 const existing = existingBySlug.get(result.pageId)
351 await db.upsertSessionPage({
352 id: existing?.id || nanoid(),
353 sessionId: context.sessionId,
354 legacyPageId:
355 existing?.legacy_page_id || (result.pageId.match(/^page-\d+$/) ? result.pageId : null),
356 fileSlug: result.pageId,
357 pageNumber: pageRef.pageNumber,
358 title: pageRef.title,
359 htmlPath: pageRef.htmlPath,
360 status: 'completed',
361 error: null
362 })
363 for (const page of result.changedPages) {
364 const isExisting = existingPageIdsBeforeRun.includes(page.pageId)
365 const payload: GeneratedPagePayload = {
366 id: page.id,
367 focusPage: false,
368 pageNumber: page.pageNumber,
369 title: page.title,
370 html: page.html,
371 pageId: page.pageId,
372 htmlPath: page.htmlPath,
373 sourceUrl: getPageSourceUrl(page.htmlPath)
374 }
375 emitEditChunk({
376 type: isExisting ? 'page_updated' : 'page_generated',
377 payload: {
378 runId: context.runId,
379 stage: 'editing',
380 label: uiText(
381 context.appLocale,
382 `P${page.pageNumber} 修改结果已保存`,
383 `P${page.pageNumber} edit saved`
384 ),
385 progress: 90,
386 currentPage: page.pageNumber,
387 totalPages: selectedPageRefs.length,
388 ...payload
389 }
390 })
391 }
392 },
393 onPageFailed: async (result) => {
394 const pageRef = selectedPageRefs.find((p) => p.pageId === result.pageId)
395 if (!pageRef) return
396 const outlineItem = outlineItemByPageId.get(result.pageId)
397 await db.upsertGenerationPage({
398 runId: context.runId,
399 sessionId: context.sessionId,
400 pageId: result.pageId,
401 pageNumber: pageRef.pageNumber,
402 title: pageRef.title,
403 contentOutline: outlineItem?.contentOutline || '',
404 layoutIntent: outlineItem?.layoutIntent,
405 htmlPath: pageRef.htmlPath,
406 status: 'failed',
407 error: result.reason,
408 retryCount: result.retryCount
409 })
410 const existing = existingBySlug.get(result.pageId)
411 await db.upsertSessionPage({
412 id: existing?.id || pageRef.id || nanoid(),
413 sessionId: context.sessionId,
414 legacyPageId:
415 existing?.legacy_page_id || (result.pageId.match(/^page-\d+$/) ? result.pageId : null),
416 fileSlug: result.pageId,
417 pageNumber: pageRef.pageNumber,
418 title: pageRef.title,
419 htmlPath: pageRef.htmlPath,
420 status: existing?.status || 'failed',
421 error: existing?.error || null
422 })
423 emitEditChunk({
424 type: 'page_failed',
425 payload: {
426 runId: context.runId,
427 stage: 'editing',
428 label: progressText(context.appLocale, 'failed'),
429 progress: 90,
430 currentPage: pageRef.pageNumber,
431 totalPages: selectedPageRefs.length,
432 pageNumber: pageRef.pageNumber,
433 pageId: pageRef.pageId,
434 title: pageRef.title,
435 htmlPath: pageRef.htmlPath,
436 error: result.reason
437 }
438 })
439 }
440 })
441 } catch (error) {
442 const message =
443 error instanceof Error && error.message.length > 0 ? error.message : 'Deck edit failed'
444 log.error('[generate:start] deck edit batch flow aborted', {
445 sessionId: context.sessionId,
446 runId: context.runId,
447 message
448 })
449 await db.updateGenerationRunStatus(context.runId, 'failed', message)
450 throw error
451 }
452
453 const completedBatchResults = batchResults.filter(
454 (result): result is DeckEditCompletedBatch => result.status === 'completed'
455 )
456 const failedBatchResults = batchResults.filter(
457 (result): result is DeckEditFailedBatch => result.status === 'failed'
458 )
459 const changedPageIdSet = new Set(
460 completedBatchResults.flatMap((r) => r.changedPages.map((p) => p.pageId))
461 )
462
463 const remainingFailedPageInfoById = resolveRemainingFailedPageInfo({
464 previousFailures: failedPageInfoById,
465 failedResults: failedBatchResults,
466 completedPageIds: changedPageIdSet,
467 pageRefs
468 })
469 const changedPages = completedBatchResults.flatMap((r) => r.changedPages)
470 const failedPageLabels = failedBatchResults.map((item) => {
471 const page = pageRefs.find((ref) => ref.pageId === item.pageId)
472 return uiText(
473 context.appLocale,
474 `第${page?.pageNumber || item.pageId}页`,
475 page?.pageNumber ? `page ${page.pageNumber}` : item.pageId
476 )
477 })
478 const summaryArgs = {
479 context,
480 changedPages,
481 editScope: 'deck' as const,
482 failedPageLabels
483 }
484 const fallbackEditSummary = buildLocalSuccessfulEditSummary(summaryArgs)
485
486 await db.updateSessionMetadata(context.sessionId, {
487 lastRunId: context.runId,
488 entryMode: 'multi_page',
489 indexPath,
490 projectId: context.projectId
491 })
492 await db.updateProjectStatus(context.projectId, 'draft')
493 await db.updateSessionStatus(
494 context.sessionId,
495 remainingFailedPageInfoById.size > 0 ? 'failed' : 'completed'
496 )
497 const runStatus =
498 failedBatchResults.length === 0
499 ? 'completed'
500 : completedBatchResults.length > 0
501 ? 'partial'
502 : 'failed'
503 const failedDetails = failedBatchResults
504 .map((page) => `${page.pageId}:${page.reason}`)
505 .join(';')
506 await db.updateGenerationRunStatus(
507 context.runId,
508 runStatus,
509 failedBatchResults.length > 0 ? failedDetails : null
510 )
511 if (changedPageIdSet.size > 0) {
512 await ctx.history.recordOperation({
513 sessionId: context.sessionId,
514 projectDir,
515 type: 'edit',
516 scope: 'deck',
517 prompt: context.userMessage,
518 metadata: {
519 runId: context.runId,
520 changedPageIds: Array.from(changedPageIdSet),
521 selectPageIds: selectedPageIds,
522 failedPageIds: failedBatchResults.map((page) => page.pageId),
523 failedPageReasons: Object.fromEntries(
524 failedBatchResults.map((page) => [page.pageId, page.reason])
525 )
526 }
527 })
528 }
529 await emitSuccessfulEditSummary(context, fallbackEditSummary, emitAssistant)
530 log.info('[generate:start] deck all-page edit completed', {
531 sessionId: context.sessionId,
532 styleId: context.styleId,
533 changedPages: Array.from(changedPageIdSet),
534 failedPages: failedBatchResults.map((page) => page.pageId),
535 remainingFailedPages: Array.from(remainingFailedPageInfoById.keys()),
536 batchCount: batchResults.length
537 })
538 if (runStatus === 'failed') {
539 emitEditChunk({
540 type: 'run_error',
541 payload: {
542 runId: context.runId,
543 message: failedDetails || fallbackEditSummary,
544 completedPageCount: 0,
545 failedPageCount: failedBatchResults.length
546 }
547 })
548 } else {
549 emitEditChunk({
550 type: 'run_completed',
551 payload: {
552 runId: context.runId,
553 totalPages: selectedPageRefs.length,
554 completedPageCount: changedPageIdSet.size,
555 failedPageCount: failedBatchResults.length
556 }
557 })
558 }
559 }
560
560 lines TYPESCRIPT