返回 oh-my-ppt
edit-flow.ts
根目录 / src / main / generation / edit-flow.ts
1 import type { EditContext, EmitAssistantFn, GenerateChatType } from './types'
2 import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3 import { FilesystemBackend, createDeepAgent } from 'deepagents'
4 import { z } from 'zod'
5 import {
6 buildEditNoChangeRetryMessage,
7 buildEditToolSchemaRetryMessage,
8 buildEditValidationRetryMessage,
9 type EditedPageDescriptor,
10 isEditToolSchemaRetryableError,
11 isEditValidationRetryableError,
12 resolvePageHtmlPath,
13 uiText,
14 validateChangedPages
15 } from './generation-utils'
16 import log from 'electron-log/main.js'
17 import { progressText } from '@shared/progress'
18 import path from 'path'
19 import fs from 'fs'
20 import { nanoid } from 'nanoid'
21 import { normalizeLayoutIntent } from '@shared/layout-intent'
22 import { hasCompatiblePageLayoutSource, validateLayoutSlots } from './layout-slot-validator'
23 import { runDeepAgentEdit } from './agent-runner'
24 import { createPageImageFinalizer } from './page-image-finalizer'
25 import { buildGenerationImageIntentRules } from '../agent-runtime/prompt/composers/generation-image-intent-rules'
26 import { getLayoutMasterTemplate } from '@shared/layout-master'
27 import { formatSelectedElementRuntimeContext } from '../agent-runtime/prompt/selected-element-context'
28 import {
29 type DesignContract,
30 SESSION_PAGE_EDIT_INTENTS,
31 type GeneratedPagePayload,
32 type PageReferenceContext,
33 type SessionPageEditAssessment,
34 type SessionPageEditPlan,
35 type SelectedElementRuntimeContext
36 } from '@shared/generation'
37 import { resolveModel } from '../agent-runtime/model'
38 import { resolveModelTimeoutMs } from '@shared/model-timeout'
39 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
40 import {
41 buildOutlineTitles,
42 buildTotalPages,
43 type GenerationContext,
44 normalizeGeneratePayload,
45 type RuntimeJobExecutionContext,
46 resolveCommonContext,
47 resolveSessionReferenceDocumentPath,
48 resolveSourceDocuments
49 } from './context'
50 import { resolvePageReferenceContext } from './source-plan'
51 import {
52 buildLocalSuccessfulEditSummary,
53 emitSuccessfulEditSummary
54 } from './edit-summary'
55
56 const sessionPageEditAssessmentSchema = z.object({
57 intent: z.enum(SESSION_PAGE_EDIT_INTENTS),
58 target: z.string().min(1).max(500),
59 summary: z.string().min(1).max(1500),
60 changes: z.array(z.string().min(1).max(500)).min(1).max(8),
61 confirmationQuestion: z.string().min(1).max(300),
62 requiresConfirmation: z.boolean()
63 })
64
65 type PageEditAssessmentResult = SessionPageEditAssessment & {
66 reply: string
67 targetPageId: string
68 targetPageNumber?: number
69 }
70
71 type RecordedSessionPageEditAssessment = SessionPageEditPlan & {
72 requiresConfirmation: boolean
73 }
74
75 const buildApprovedPlanInstruction = (plan: SessionPageEditPlan | undefined): string => {
76 if (!plan) return ''
77 return [
78 '[User-approved edit plan]',
79 `Intent: ${plan.intent}`,
80 `Target: ${plan.target}`,
81 `Summary: ${plan.summary}`,
82 'Approved changes:',
83 ...plan.changes.map((change, index) => `${index + 1}. ${change}`),
84 'Apply this approved scope. If page source requires a small implementation adjustment, keep the result within the approved intent and changes.'
85 ].join('\n')
86 }
87
88 const buildPageEditAssessmentSystemPrompt = (locale: 'zh' | 'en', args: {
89 targetPageId: string
90 targetPageNumber?: number
91 selector?: string
92 elementTag?: string
93 elementText?: string
94 selectedElementContext?: SelectedElementRuntimeContext
95 }): string => {
96 const target = `/${args.targetPageId}.html${args.targetPageNumber ? ` (slide ${args.targetPageNumber})` : ''}`
97 const selectorContext = [
98 args.selector ? `CSS selector: ${args.selector}` : '',
99 args.elementTag ? `Element: <${args.elementTag}>${args.elementText ? ` ${args.elementText}` : ''}` : '',
100 formatSelectedElementRuntimeContext(args.selectedElementContext)
101 ]
102 .filter(Boolean)
103 .join('\n')
104 const localeRule = locale === 'en' ? 'Use English.' : '使用简体中文。'
105 return [
106 'You are a presentation page-edit intent and execution-risk assessor.',
107 'This is a read-only assessment phase. You must never modify, create, rename, or delete files.',
108 'You may inspect the project files only to understand the target page and selected element.',
109 'Do not propose changes outside the target page. Preserve unrelated content and page shell structure.',
110 'Before finishing, call record_session_page_edit_assessment exactly once.',
111 'Set requiresConfirmation=false only when the request has a concrete target and outcome, and can be applied without choosing a design direction, scope, or content strategy.',
112 'Set requiresConfirmation=true when the request is ambiguous, broad, requests optimization/redesign, has multiple plausible outcomes, or could change meaning or page structure beyond an explicit local instruction.',
113 'Always provide the concrete proposed changes. They are shown to the user only when confirmation is required.',
114 localeRule,
115 '',
116 `Target page: ${target}`,
117 selectorContext
118 ]
119 .filter(Boolean)
120 .join('\n')
121 }
122
123 const buildPageEditAssessmentUserPrompt = (args: {
124 userMessage: string
125 imagePrompt: string
126 targetPageId: string
127 targetPageNumber?: number
128 selector?: string
129 elementTag?: string
130 elementText?: string
131 selectedElementContext?: SelectedElementRuntimeContext
132 }): string =>
133 [
134 'Assess the edit intent and whether explicit confirmation is required. Do not perform the edit.',
135 '',
136 'User request:',
137 args.userMessage,
138 args.imagePrompt,
139 '',
140 `Target page: ${args.targetPageId}${args.targetPageNumber ? ` (slide ${args.targetPageNumber})` : ''}`,
141 args.selector ? `Target selector: ${args.selector}` : '',
142 args.elementTag ? `Target element: <${args.elementTag}>${args.elementText ? ` ${args.elementText}` : ''}` : '',
143 formatSelectedElementRuntimeContext(args.selectedElementContext)
144 ]
145 .filter(Boolean)
146 .join('\n')
147
148 const createSessionPageEditAssessmentTool = (): {
149 tool: StructuredToolInterface
150 getAssessment: () => SessionPageEditAssessment | null
151 } => {
152 let assessment: RecordedSessionPageEditAssessment | null = null
153 const recorder = tool(
154 async (input) => {
155 assessment = input as RecordedSessionPageEditAssessment
156 return assessment.requiresConfirmation
157 ? 'Assessment recorded. The host will show the proposed plan to the user for confirmation.'
158 : 'Assessment recorded. The host will start the existing page-edit job directly.'
159 },
160 {
161 name: 'record_session_page_edit_assessment',
162 description:
163 'Record the single-page edit intent, scope, proposed changes, and whether user confirmation is needed. Call exactly once for every request. This tool only records an assessment and cannot modify the presentation.',
164 schema: sessionPageEditAssessmentSchema
165 }
166 )
167 return {
168 tool: recorder as unknown as StructuredToolInterface,
169 getAssessment: () => {
170 if (!assessment) return null
171 const { requiresConfirmation, ...plan } = assessment
172 return { plan, requiresConfirmation }
173 }
174 }
175 }
176
177 export async function assessPageEdit(
178 ctx: GenerationContext,
179 payload: unknown,
180 signal?: AbortSignal
181 ): Promise<PageEditAssessmentResult> {
182 const input = normalizeGeneratePayload(payload)
183 if (!input.sessionId) throw new Error('sessionId 不能为空')
184 if (input.requestedType !== 'page' || input.chatType !== 'page') {
185 throw new Error('仅支持分析当前页面的修改请求')
186 }
187 if (!input.rawUserMessage.trim()) throw new Error('请输入页面修改需求')
188 const requestedPageId = input.chatPageId || input.selectedPageId
189 if (!requestedPageId) throw new Error('页面修改分析需要指定目标页面')
190
191 const [session, pages, activeModel, modelTimeouts] = await Promise.all([
192 ctx.db.getSession(input.sessionId),
193 ctx.db.listSessionPages(input.sessionId),
194 resolveModelConfigForTask(
195 { db: ctx.db, decryptApiKey: ctx.credentials.decryptApiKey },
196 {
197 modelConfigId: input.modelConfigId,
198 purpose: 'generation:page-edit-plan'
199 }
200 ),
201 resolveGlobalModelTimeouts({ db: ctx.db })
202 ])
203 if (!session) throw new Error('Session not found')
204 if (!activeModel.apiKey) {
205 throw new Error(`当前 provider "${activeModel.provider}" 缺少 API Key,请先到设置页配置。`)
206 }
207 const page = pages.find((item) => item.id === requestedPageId || item.file_slug === requestedPageId)
208 if (!page) throw new Error(`Selected page not found in session_pages: ${requestedPageId}`)
209 const projectDir = await ctx.sessionProject.resolveSessionProjectDir(input.sessionId)
210 const pagePath = resolvePageHtmlPath({
211 projectDir,
212 fileSlug: page.file_slug,
213 candidates: [page.html_path]
214 })
215 if (!fs.existsSync(pagePath)) throw new Error(`目标页面文件不存在: ${page.file_slug}.html`)
216
217 const appLocale = (await ctx.db.getAllSettings()).locale === 'en' ? 'en' : 'zh'
218 const model = resolveModel(
219 activeModel.provider,
220 activeModel.apiKey,
221 activeModel.model,
222 activeModel.baseUrl,
223 0.2,
224 activeModel.maxTokens,
225 ctx.modelRuntime
226 )
227 const assessmentRecorder = createSessionPageEditAssessmentTool()
228 const agent = createDeepAgent({
229 model: model as any,
230 backend: new FilesystemBackend({ rootDir: projectDir, virtualMode: true }),
231 tools: [assessmentRecorder.tool] as unknown as StructuredToolInterface[],
232 permissions: [
233 { operations: ['read'], paths: ['/**'] },
234 { operations: ['write'], paths: ['/**'], mode: 'deny' }
235 ],
236 systemPrompt: buildPageEditAssessmentSystemPrompt(appLocale, {
237 targetPageId: page.file_slug,
238 targetPageNumber: page.page_number,
239 selector: input.selector,
240 elementTag: input.elementTag,
241 elementText: input.elementText,
242 selectedElementContext: input.selectedElementContext
243 })
244 })
245 const imagePrompt = ctx.localFiles.formatImagePathsForPrompt(input.rawImagePaths, input.rawVideoPaths)
246 const stream = await agent.stream(
247 {
248 messages: [
249 {
250 role: 'user',
251 content: buildPageEditAssessmentUserPrompt({
252 userMessage: input.rawUserMessage,
253 imagePrompt,
254 targetPageId: page.file_slug,
255 targetPageNumber: page.page_number,
256 selector: input.selector,
257 elementTag: input.elementTag,
258 elementText: input.elementText,
259 selectedElementContext: input.selectedElementContext
260 })
261 }
262 ]
263 },
264 {
265 streamMode: ['updates', 'messages'],
266 subgraphs: true,
267 signal: signal
268 ? AbortSignal.any([
269 signal,
270 AbortSignal.timeout(resolveModelTimeoutMs(modelTimeouts.planning, 'planning'))
271 ])
272 : AbortSignal.timeout(resolveModelTimeoutMs(modelTimeouts.planning, 'planning'))
273 }
274 )
275 for await (const _chunk of stream as AsyncIterable<unknown>) {
276 // Consuming the stream executes the read-only assessment tool call.
277 }
278 const assessment = assessmentRecorder.getAssessment()
279 if (!assessment) throw new Error('AI 未完成页面修改意图分析,请重试或补充需求。')
280 log.info('[page-edit:assess] complete', {
281 sessionId: input.sessionId,
282 targetPageId: page.file_slug,
283 targetPageNumber: page.page_number,
284 intent: assessment.plan.intent,
285 requiresConfirmation: assessment.requiresConfirmation
286 })
287 return {
288 reply: assessment.plan.summary,
289 ...assessment,
290 targetPageId: page.file_slug,
291 targetPageNumber: page.page_number
292 }
293 }
294
295 export async function resolveEditContext(
296 ctx: GenerationContext,
297 _event: Electron.IpcMainInvokeEvent,
298 payload: unknown,
299 execution?: RuntimeJobExecutionContext
300 ): Promise<EditContext> {
301 const input = normalizeGeneratePayload(payload)
302 const { db, localFiles } = ctx
303 if (!input.sessionId) throw new Error('sessionId 不能为空')
304
305 const common = await resolveCommonContext(ctx, input.sessionId, input.modelConfigId, execution)
306 const sourceDocumentPaths = await resolveSourceDocuments(ctx, {
307 sessionId: input.sessionId,
308 projectDir: common.projectDir,
309 rawDocPaths: input.rawDocPaths,
310 mode: 'edit',
311 sessionRecord: common.sessionRecord
312 })
313 const referenceDocumentPath =
314 resolveSessionReferenceDocumentPath(common.projectDir, common.sessionRecord) ?? undefined
315 const imagePaths = input.rawImagePaths
316 const videoPaths = input.rawVideoPaths
317 const userMessage = [
318 input.rawUserMessage,
319 localFiles.formatImagePathsForPrompt(imagePaths, videoPaths),
320 buildApprovedPlanInstruction(input.approvedPlan)
321 ]
322 .filter(Boolean)
323 .join('\n\n')
324 const chatType: GenerateChatType = input.chatType
325 const chatPageId = chatType === 'page' ? input.chatPageId || input.selectedPageId : undefined
326 if (chatType === 'page' && !chatPageId) {
327 throw new Error('chatType=page requires chatPageId or selectedPageId')
328 }
329
330 if (input.persistUserMessage) {
331 await db.addMessage(input.sessionId, {
332 id: input.clientMessageId,
333 role: 'user',
334 content: input.rawUserMessage,
335 type: 'text',
336 chat_scope: chatType,
337 page_id: chatType === 'page' ? chatPageId : undefined,
338 selector: chatType === 'page' ? input.selector : undefined,
339 image_paths: imagePaths,
340 video_paths: videoPaths,
341 run_model: common.runModel
342 })
343 }
344 await db.updateSessionStatus(input.sessionId, 'active')
345
346 return {
347 sessionId: input.sessionId,
348 userMessage,
349 requestedType: 'page',
350 effectiveMode: 'edit',
351 resetVisualStyle: input.resetVisualStyle,
352 selectedPageId: input.selectedPageId,
353 selectPageIds: input.chatType === 'main' ? input.selectPageIds : [],
354 htmlPath: input.htmlPath,
355 selector: input.selector,
356 elementTag: input.elementTag,
357 elementText: input.elementText,
358 selectedElementContext: input.selectedElementContext,
359 session: common.session,
360 sessionRecord: common.sessionRecord,
361 previousSessionStatus: common.previousSessionStatus,
362 projectDir: common.projectDir,
363 abortSignal: common.abortSignal,
364 runId: common.runId,
365 styleId: common.styleId,
366 styleSkill: common.styleSkill,
367 visualEnabled: common.visualEnabled,
368 imageModelConfigId: common.imageModelConfigId,
369 imageGenerationPrompt: common.imageGenerationPrompt,
370 styleKey: common.styleKey,
371 styleName: common.styleName,
372 styleVersion: common.styleVersion,
373 slideSize: common.slideSize,
374 userProvidedOutlineTitles: buildOutlineTitles(input.rawUserMessage),
375 totalPages: buildTotalPages(common.sessionRecord),
376 provider: common.provider,
377 apiKey: common.apiKey,
378 model: common.model,
379 modelConfigId: common.modelConfigId,
380 modelConfigName: common.modelConfigName,
381 runModel: common.runModel,
382 modelTimeouts: common.modelTimeouts,
383 providerBaseUrl: common.providerBaseUrl,
384 maxTokens: common.maxTokens,
385 modelRuntime: common.modelRuntime,
386 projectId: common.projectId,
387 messageScope: chatType,
388 messagePageId: chatType === 'page' ? chatPageId : undefined,
389 imagePaths,
390 videoPaths,
391 sourceDocumentPaths,
392 referenceDocumentPath,
393 sourcePlan: common.sourcePlan,
394 topic: common.topic,
395 deckTitle: common.deckTitle,
396 appLocale: common.appLocale,
397 fontSelection: common.fontSelection,
398 animationPreferences: null
399 }
400 }
401
402 export async function executeEditGeneration(
403 ctx: GenerationContext,
404 emitAssistant: EmitAssistantFn,
405 context: EditContext
406 ): Promise<void> {
407 const {
408 db,
409 agentManager,
410 sessionProject: { getPageSourceUrl, validateProjectIndexHtml },
411 runtimeEmitters: { createDeckProgressEmitter },
412 tuning: {
413 pageEditWithSelectorTemperature: PAGE_EDIT_WITH_SELECTOR_TEMPERATURE,
414 pageEditDefaultTemperature: PAGE_EDIT_DEFAULT_TEMPERATURE
415 }
416 } = ctx
417
418 if (!context.apiKey) {
419 throw new Error(`当前 provider "${context.provider}" 缺少 API Key,请先到设置页配置。`)
420 }
421 if (context.messageScope === 'main') {
422 throw new Error('主会话编辑需要走 deck 全页编辑流程,不能进入单页编辑流程。')
423 }
424
425 const indexPath = path.join(context.projectDir, 'index.html')
426 const pageIdFromPath =
427 typeof context.htmlPath === 'string'
428 ? path.basename(context.htmlPath).match(/^([a-z0-9_-]+)\.html$/i)?.[1]
429 : undefined
430 let resolvedSelectedPageId = context.selectedPageId || pageIdFromPath
431 const selectedSelector = context.selector
432
433 let outlineTitles: string[] = context.userProvidedOutlineTitles
434 let pageRefs: Array<{
435 id: string
436 pageNumber: number
437 title: string
438 pageId: string
439 htmlPath: string
440 }> = []
441 let savedDesignContract: DesignContract | undefined
442 const sessionPages = await db.listSessionPages(context.sessionId)
443 if (sessionPages.length === 0) {
444 throw new Error('session_pages is empty after migration; cannot edit this session')
445 }
446 const layoutSourceByPageId = new Map(
447 sessionPages.map((page) => [
448 page.file_slug,
449 {
450 layoutIntent: page.layout_intent ? normalizeLayoutIntent(page.layout_intent) : null,
451 layoutId: page.layout_id,
452 layoutContractVersion: page.layout_contract_version
453 }
454 ])
455 )
456 const pageReferenceContexts: Record<string, PageReferenceContext> = {}
457 for (const page of sessionPages) {
458 const pageReferenceContext = resolvePageReferenceContext({
459 referenceDocumentPath: context.referenceDocumentPath,
460 sourcePlan: context.sourcePlan,
461 pageNumber: page.page_number
462 })
463 if (pageReferenceContext) pageReferenceContexts[page.file_slug] = pageReferenceContext
464 }
465 const selectedSessionPage = resolvedSelectedPageId
466 ? sessionPages.find(
467 (page) => page.id === resolvedSelectedPageId || page.file_slug === resolvedSelectedPageId
468 )
469 : undefined
470 if (selectedSessionPage) {
471 resolvedSelectedPageId = selectedSessionPage.file_slug
472 }
473 pageRefs = sessionPages.map((page) => ({
474 id: page.id,
475 pageNumber: page.page_number,
476 title: page.title || `第${page.page_number}页`,
477 pageId: page.file_slug,
478 htmlPath: resolvePageHtmlPath({
479 projectDir: context.projectDir,
480 fileSlug: page.file_slug,
481 candidates: [page.html_path]
482 })
483 }))
484 if (outlineTitles.length === 0) {
485 outlineTitles = pageRefs.map((page) => page.title)
486 }
487 const latestPageSnapshot = await db.listLatestGenerationPageSnapshot(context.sessionId)
488 const failedPageInfoById = new Map<string, { title: string; reason: string }>()
489 for (const page of sessionPages) {
490 if (page.status !== 'failed') continue
491 failedPageInfoById.set(page.file_slug, {
492 title: page.title || page.file_slug,
493 reason: page.error || '页面仍需修复'
494 })
495 }
496 // Read designContract from the dedicated column
497 const sessionRecord = (context.session || {}) as Record<string, unknown>
498 if (
499 typeof sessionRecord.designContract === 'string' &&
500 sessionRecord.designContract.trim().length > 0
501 ) {
502 try {
503 savedDesignContract = JSON.parse(sessionRecord.designContract) as DesignContract
504 } catch {
505 /* ignore */
506 }
507 }
508 if (resolvedSelectedPageId && !pageRefs.some((ref) => ref.pageId === resolvedSelectedPageId)) {
509 throw new Error(`Selected page not found in session_pages: ${resolvedSelectedPageId}`)
510 }
511 pageRefs.sort((a, b) => a.pageNumber - b.pageNumber)
512 if (!resolvedSelectedPageId && pageRefs.length > 0) {
513 resolvedSelectedPageId = pageRefs[0].pageId
514 }
515 const resolvedSelectedPageNumber =
516 pageRefs.find((ref) => ref.pageId === resolvedSelectedPageId)?.pageNumber || undefined
517 const editTotalPages = 1
518 if (outlineTitles.length !== pageRefs.length) {
519 outlineTitles = pageRefs.map((ref) => ref.title)
520 }
521
522 const outlineByPageId = new Map(
523 latestPageSnapshot.map((page) => [page.page_id, page.content_outline || ''])
524 )
525 const layoutIntentByPageId = new Map(
526 latestPageSnapshot.map((page) => [
527 page.page_id,
528 page.layout_intent ? normalizeLayoutIntent(page.layout_intent) : undefined
529 ])
530 )
531 const outlineItems = pageRefs.map((ref) => ({
532 title: ref.title,
533 contentOutline: outlineByPageId.get(ref.pageId) || '',
534 layoutIntent: layoutIntentByPageId.get(ref.pageId)
535 }))
536 const pageFileMap = Object.fromEntries(pageRefs.map((p) => [p.pageId, p.htmlPath]))
537 const pageNumbers = Object.fromEntries(pageRefs.map((p) => [p.pageId, p.pageNumber]))
538 const beforeMap = new Map<string, string>()
539 const existingPageIdsBeforeRun: string[] = []
540 const beforeReads = await Promise.all(
541 pageRefs.map(async (ref) => {
542 if (!fs.existsSync(ref.htmlPath)) return null
543 const html = await fs.promises.readFile(ref.htmlPath, 'utf-8')
544 return { pageId: ref.pageId, html }
545 })
546 )
547 for (const item of beforeReads) {
548 if (!item) continue
549 existingPageIdsBeforeRun.push(item.pageId)
550 beforeMap.set(item.pageId, item.html)
551 }
552
553 const emitEditChunk = createDeckProgressEmitter(context.sessionId, context.appLocale)
554
555 emitEditChunk({
556 type: 'stage_started',
557 payload: {
558 runId: context.runId,
559 stage: 'editing',
560 label: resolvedSelectedPageNumber
561 ? uiText(
562 context.appLocale,
563 `正在准备编辑第 ${resolvedSelectedPageNumber} 页`,
564 `Preparing to edit page ${resolvedSelectedPageNumber}`
565 )
566 : uiText(context.appLocale, '正在定位需要编辑的页面', 'Locating pages to edit'),
567 progress: 10,
568 totalPages: editTotalPages
569 }
570 })
571
572 const editTemperature = selectedSelector
573 ? PAGE_EDIT_WITH_SELECTOR_TEMPERATURE
574 : PAGE_EDIT_DEFAULT_TEMPERATURE
575
576 const beforeIndexHtml = fs.existsSync(indexPath)
577 ? await fs.promises.readFile(indexPath, 'utf-8')
578 : ''
579 await ctx.history.ensureBaseline(context.sessionId, context.projectDir)
580
581 const editRunArgs = {
582 sessionId: context.sessionId,
583 provider: context.provider,
584 apiKey: context.apiKey,
585 model: context.model,
586 baseUrl: context.providerBaseUrl,
587 maxTokens: context.maxTokens,
588 modelTimeoutMs: context.modelTimeouts.agent,
589 temperature: editTemperature,
590 styleId: context.styleId,
591 styleSkillPrompt: context.styleSkill.prompt,
592 hasStyleImageDirection: Boolean(context.imageGenerationPrompt.trim()),
593 styleKey: context.styleKey,
594 styleName: context.styleName,
595 styleVersion: context.styleVersion,
596 slideSize: context.slideSize,
597 appLocale: context.appLocale,
598 topic: context.topic,
599 deckTitle: context.deckTitle,
600 userMessage: context.userMessage,
601 imageIntentAddendum: (() => {
602 if (selectedSelector || !context.visualEnabled || !resolvedSelectedPageId) return ''
603 const source = layoutSourceByPageId.get(resolvedSelectedPageId)
604 if (
605 !source?.layoutIntent ||
606 !source.layoutId ||
607 !source.layoutContractVersion ||
608 !hasCompatiblePageLayoutSource(source)
609 ) {
610 return ''
611 }
612 const template = getLayoutMasterTemplate(source.layoutId)
613 return template
614 ? buildGenerationImageIntentRules({
615 visualEnabled: true,
616 template,
617 hasStyleImageDirection: Boolean(context.imageGenerationPrompt.trim())
618 })
619 : ''
620 })(),
621 outlineTitles,
622 outlineItems,
623 sourceDocumentPaths: context.sourceDocumentPaths,
624 referenceDocumentPath: context.referenceDocumentPath,
625 pageReferenceContexts,
626 projectDir: context.projectDir,
627 indexPath,
628 pageFileMap,
629 pageNumbers,
630 designContract: savedDesignContract,
631 editScope: 'page',
632 selectedPageId: resolvedSelectedPageId,
633 selectedPageNumber: resolvedSelectedPageNumber,
634 selectedSelector,
635 elementTag: context.elementTag,
636 elementText: context.elementText,
637 selectedElementContext: context.selectedElementContext,
638 existingPageIds: existingPageIdsBeforeRun,
639 finalizeEditedPage: selectedSelector
640 ? undefined
641 : async (pageId, refineImageLayout) => {
642 const ref = pageRefs.find((item) => item.pageId === pageId)
643 const source = layoutSourceByPageId.get(pageId)
644 if (!ref) return
645 if (
646 !source?.layoutIntent ||
647 !source.layoutId ||
648 !source.layoutContractVersion ||
649 !hasCompatiblePageLayoutSource(source)
650 ) {
651 log.info('[images:fulfillment] page skipped', {
652 sessionId: context.sessionId,
653 runId: context.runId,
654 pageId,
655 reason: 'page edit has no compatible layout source',
656 layoutIntent: source?.layoutIntent || null,
657 layoutId: source?.layoutId || null,
658 layoutContractVersion: source?.layoutContractVersion || null
659 })
660 return
661 }
662 const finalizer = createPageImageFinalizer(ctx, {
663 sessionId: context.sessionId,
664 runId: context.runId,
665 visualEnabled: context.visualEnabled,
666 imageModelConfigId: context.imageModelConfigId,
667 imageGenerationPrompt: context.imageGenerationPrompt,
668 imagePromptDirector: {
669 provider: context.provider,
670 apiKey: context.apiKey,
671 model: context.model,
672 baseUrl: context.providerBaseUrl,
673 maxTokens: context.maxTokens,
674 modelRuntime: context.modelRuntime,
675 modelTimeoutMs: context.modelTimeouts.agent,
676 locale: context.appLocale
677 },
678 abortSignal: context.abortSignal
679 })
680 await finalizer(
681 {
682 pageNumber: ref.pageNumber,
683 pageId: ref.pageId,
684 title: ref.title,
685 contentOutline: outlineByPageId.get(ref.pageId) || '',
686 layoutIntent: source.layoutIntent || undefined,
687 layoutId: source.layoutId,
688 layoutContractVersion: source.layoutContractVersion,
689 htmlPath: ref.htmlPath
690 },
691 refineImageLayout
692 )
693 },
694 agentManager,
695 emit: (chunk) => emitEditChunk(chunk),
696 runId: context.runId,
697 signal: context.abortSignal
698 } satisfies Parameters<typeof runDeepAgentEdit>[0]
699 const runEditAttempt = async (userMessage: string, retryDetail?: string): Promise<void> => {
700 if (retryDetail) {
701 emitEditChunk({
702 type: 'llm_status',
703 payload: {
704 runId: context.runId,
705 stage: 'editing',
706 label: resolvedSelectedPageNumber
707 ? uiText(
708 context.appLocale,
709 `正在重试第 ${resolvedSelectedPageNumber} 页的编辑`,
710 `Retrying the edit for page ${resolvedSelectedPageNumber}`
711 )
712 : uiText(context.appLocale, '正在重试页面编辑', 'Retrying the page edit'),
713 progress: 55,
714 totalPages: editTotalPages,
715 detail: retryDetail
716 }
717 })
718 }
719 return runDeepAgentEdit({ ...editRunArgs, userMessage })
720 }
721 let editToolSchemaRetryUsed = false
722 let editValidationRetryUsed = false
723 const failWithUserMessage = async (userMessage: string): Promise<never> => {
724 await db.updateGenerationRunStatus(context.runId, 'failed', userMessage)
725 throw new Error(userMessage)
726 }
727 const runRetryAttempt = async (
728 userMessage: string,
729 retryDetail: string,
730 failureMessage: string,
731 logLabel: string
732 ): Promise<void> => {
733 try {
734 await runEditAttempt(userMessage, retryDetail)
735 } catch (retryError) {
736 log.error(logLabel, {
737 sessionId: context.sessionId,
738 runId: context.runId,
739 detail: retryError instanceof Error ? retryError.message : String(retryError)
740 })
741 return failWithUserMessage(failureMessage)
742 }
743 }
744 try {
745 await runEditAttempt(context.userMessage)
746 } catch (error) {
747 const canRetryByValidation = isEditValidationRetryableError(error)
748 const canRetryBySchema = isEditToolSchemaRetryableError(error)
749 if (!canRetryByValidation && !canRetryBySchema) throw error
750 if (canRetryBySchema) {
751 editToolSchemaRetryUsed = true
752 } else {
753 editValidationRetryUsed = true
754 }
755 const detail = error instanceof Error ? error.message : String(error)
756 log.warn('[generate:start] edit validation/tool retry scheduled', {
757 sessionId: context.sessionId,
758 runId: context.runId,
759 detail,
760 kind: canRetryBySchema ? 'tool_schema' : 'validation'
761 })
762 const retryMessage = canRetryBySchema
763 ? buildEditToolSchemaRetryMessage({
764 originalMessage: context.userMessage,
765 detail,
766 allowedTool: selectedSelector ? 'edit_file' : 'update_single_page_file',
767 selectedPageId: resolvedSelectedPageId || null
768 })
769 : buildEditValidationRetryMessage(context.userMessage, detail)
770 await runRetryAttempt(
771 retryMessage,
772 uiText(
773 context.appLocale,
774 canRetryBySchema
775 ? '工具调用参数不完整,正在自动重试一次。'
776 : '页面校验失败,正在自动重试一次。',
777 canRetryBySchema
778 ? 'Tool call schema invalid; retrying once.'
779 : 'Page validation failed; retrying once.'
780 ),
781 uiText(
782 context.appLocale,
783 '页面编辑重试失败,请重新描述要修改的内容。',
784 'Page edit retry failed. Please describe the desired change again.'
785 ),
786 '[generate:start] edit retry failed'
787 )
788 }
789 const afterIndexHtml = fs.existsSync(indexPath)
790 ? await fs.promises.readFile(indexPath, 'utf-8')
791 : ''
792 const indexChanged = beforeIndexHtml !== afterIndexHtml
793 if (indexChanged) {
794 const indexValidationErrors = validateProjectIndexHtml(afterIndexHtml)
795 if (indexValidationErrors.length > 0) {
796 const details = indexValidationErrors.join('; ')
797 log.error('[generate:start] edit index validation failed', {
798 sessionId: context.sessionId,
799 runId: context.runId,
800 details
801 })
802 await failWithUserMessage(
803 uiText(
804 context.appLocale,
805 '页面壳层校验失败,请重新描述要修改的内容。',
806 'Page shell validation failed. Please describe the desired change again.'
807 )
808 )
809 }
810 }
811
812 let pageDescriptors: EditedPageDescriptor[] = []
813 let changedPageDescriptors: EditedPageDescriptor[] = []
814 const readEditedPages = async (): Promise<{
815 pageDescriptors: typeof pageDescriptors
816 changedPageDescriptors: typeof changedPageDescriptors
817 }> => {
818 const nextPageDescriptors: typeof pageDescriptors = []
819 const nextChangedPageDescriptors: typeof changedPageDescriptors = []
820 const editedPageReads = await Promise.all(
821 pageRefs.map(async (ref) => {
822 if (!fs.existsSync(ref.htmlPath)) return null
823 const html = await fs.promises.readFile(ref.htmlPath, 'utf-8')
824 return { ref, html }
825 })
826 )
827 for (const item of editedPageReads) {
828 if (!item) continue
829 const { ref, html } = item
830 nextPageDescriptors.push({
831 id: ref.id,
832 pageNumber: ref.pageNumber,
833 title: ref.title,
834 pageId: ref.pageId,
835 html,
836 htmlPath: ref.htmlPath
837 })
838 const isExisting = existingPageIdsBeforeRun.includes(ref.pageId)
839 const changed = beforeMap.get(ref.pageId) !== html
840 if (!changed && isExisting) continue
841 nextChangedPageDescriptors.push({
842 id: ref.id,
843 pageNumber: ref.pageNumber,
844 title: ref.title,
845 pageId: ref.pageId,
846 html,
847 htmlPath: ref.htmlPath
848 })
849 }
850 return {
851 pageDescriptors: nextPageDescriptors,
852 changedPageDescriptors: nextChangedPageDescriptors
853 }
854 }
855 ;({ pageDescriptors, changedPageDescriptors } = await readEditedPages())
856
857 if (!selectedSelector && changedPageDescriptors.length === 0) {
858 const detail = uiText(
859 context.appLocale,
860 '本次编辑没有检测到任何页面落盘变化。',
861 'The edit completed without any detected page changes.'
862 )
863 log.warn('[generate:start] edit no-change retry scheduled', {
864 sessionId: context.sessionId,
865 runId: context.runId,
866 selectedPageId: resolvedSelectedPageId || null,
867 detail,
868 schemaRetryUsed: editToolSchemaRetryUsed
869 })
870 await runRetryAttempt(
871 buildEditNoChangeRetryMessage({
872 originalMessage: context.userMessage,
873 allowedTool: 'update_single_page_file',
874 selectedPageId: resolvedSelectedPageId || null
875 }),
876 uiText(
877 context.appLocale,
878 '没有检测到页面变化,正在自动重试一次。',
879 'No page changes detected; retrying once.'
880 ),
881 uiText(
882 context.appLocale,
883 '页面编辑重试后仍未产生变化,请重新描述要修改的内容。',
884 'The page edit still did not produce changes after retry. Please describe the desired change again.'
885 ),
886 '[generate:start] edit no-change retry failed'
887 )
888 ;({ pageDescriptors, changedPageDescriptors } = await readEditedPages())
889 if (changedPageDescriptors.length === 0) {
890 const message = uiText(
891 context.appLocale,
892 '页面编辑没有产生任何落盘变化,请重新描述要修改的页面内容。',
893 'The page edit did not produce any persisted page changes. Please describe the desired page content change again.'
894 )
895 await db.updateGenerationRunStatus(context.runId, 'failed', message)
896 throw new Error(message)
897 }
898 }
899
900 const invalidChangedPages = validateChangedPages(changedPageDescriptors)
901 if (invalidChangedPages.length > 0) {
902 const details = invalidChangedPages
903 .map((item) => `${item.page.pageId}(${item.page.title}):${item.reason}`)
904 .join(';')
905 if (editValidationRetryUsed) {
906 log.error('[generate:start] edit result validation failed after retry', {
907 sessionId: context.sessionId,
908 runId: context.runId,
909 details
910 })
911 await failWithUserMessage(
912 uiText(
913 context.appLocale,
914 '页面编辑结果校验失败,请重新描述要修改的内容。',
915 'Page edit validation failed. Please describe the desired change again.'
916 )
917 )
918 }
919 editValidationRetryUsed = true
920 log.warn('[generate:start] edit result validation retry scheduled', {
921 sessionId: context.sessionId,
922 runId: context.runId,
923 details
924 })
925 await runRetryAttempt(
926 buildEditValidationRetryMessage(context.userMessage, `页面编辑结果验证失败:${details}`),
927 uiText(
928 context.appLocale,
929 '页面校验失败,正在自动重试一次。',
930 'Page validation failed; retrying once.'
931 ),
932 uiText(
933 context.appLocale,
934 '页面编辑重试失败,请重新描述要修改的内容。',
935 'Page edit retry failed. Please describe the desired change again.'
936 ),
937 '[generate:start] edit validation retry failed'
938 )
939 ;({ pageDescriptors, changedPageDescriptors } = await readEditedPages())
940 const retryInvalidChangedPages = validateChangedPages(changedPageDescriptors)
941 if (retryInvalidChangedPages.length > 0) {
942 const retryDetails = retryInvalidChangedPages
943 .map((item) => `${item.page.pageId}(${item.page.title}):${item.reason}`)
944 .join(';')
945 log.error('[generate:start] edit result validation failed after retry', {
946 sessionId: context.sessionId,
947 runId: context.runId,
948 details: retryDetails
949 })
950 await failWithUserMessage(
951 uiText(
952 context.appLocale,
953 '页面编辑结果校验失败,请重新描述要修改的内容。',
954 'Page edit validation failed. Please describe the desired change again.'
955 )
956 )
957 }
958 }
959
960 const detachedLayoutSourcePageIds = new Set<string>()
961 if (!selectedSelector) {
962 for (const page of changedPageDescriptors) {
963 const source = layoutSourceByPageId.get(page.pageId)
964 const validation = validateLayoutSlots({
965 html: page.html,
966 layoutIntent: source?.layoutIntent,
967 layoutId: source?.layoutId,
968 layoutContractVersion: source?.layoutContractVersion
969 })
970 if (!validation.valid) {
971 detachedLayoutSourcePageIds.add(page.pageId)
972 }
973 }
974 }
975
976 for (const page of changedPageDescriptors) {
977 const isExisting = existingPageIdsBeforeRun.includes(page.pageId)
978 const payload: GeneratedPagePayload = {
979 id: page.id,
980 pageNumber: page.pageNumber,
981 title: page.title,
982 html: page.html,
983 pageId: page.pageId,
984 htmlPath: page.htmlPath,
985 sourceUrl: getPageSourceUrl(page.htmlPath)
986 }
987 emitEditChunk({
988 type: isExisting ? 'page_updated' : 'page_generated',
989 payload: {
990 runId: context.runId,
991 stage: 'editing',
992 label: progressText(context.appLocale, 'completed'),
993 progress: 90,
994 currentPage: page.pageNumber,
995 totalPages: editTotalPages,
996 ...payload
997 }
998 })
999 }
1000
1001 const changedPageIdSet = new Set(changedPageDescriptors.map((page) => page.pageId))
1002 for (const page of changedPageDescriptors) {
1003 const outlineItem = outlineItems.find((_item, index) => pageRefs[index]?.pageId === page.pageId)
1004 const source = layoutSourceByPageId.get(page.pageId)
1005 const retainsLayoutSource = !detachedLayoutSourcePageIds.has(page.pageId)
1006 await db.upsertGenerationPage({
1007 runId: context.runId,
1008 sessionId: context.sessionId,
1009 pageId: page.pageId,
1010 pageNumber: page.pageNumber,
1011 title: page.title,
1012 contentOutline: outlineItem?.contentOutline || '',
1013 layoutIntent: retainsLayoutSource
1014 ? source?.layoutIntent || outlineItem?.layoutIntent
1015 : null,
1016 layoutId: retainsLayoutSource ? source?.layoutId : null,
1017 layoutContractVersion: retainsLayoutSource ? source?.layoutContractVersion : null,
1018 htmlPath: page.htmlPath,
1019 status: 'completed'
1020 })
1021 }
1022
1023 const remainingFailedPageInfoById = new Map(failedPageInfoById)
1024 for (const pageId of changedPageIdSet) {
1025 remainingFailedPageInfoById.delete(pageId)
1026 }
1027 const generatedPagesForMetadata = pageDescriptors.filter(
1028 (page) => !remainingFailedPageInfoById.has(page.pageId)
1029 )
1030 const remainingFailedPages = Array.from(remainingFailedPageInfoById.entries()).map(
1031 ([pageId, info]) => ({
1032 pageId,
1033 title: info.title || pageRefs.find((ref) => ref.pageId === pageId)?.title || pageId,
1034 reason: info.reason || '页面仍需修复'
1035 })
1036 )
1037
1038 await db.updateSessionMetadata(context.sessionId, {
1039 lastRunId: context.runId,
1040 entryMode: 'multi_page',
1041 indexPath,
1042 projectId: context.projectId
1043 })
1044 const existingSessionPages = await db.listSessionPages(context.sessionId, {
1045 includeDeleted: true
1046 })
1047 const existingBySlug = new Map(existingSessionPages.map((sp) => [sp.file_slug, sp]))
1048 for (const page of generatedPagesForMetadata) {
1049 const existing = existingBySlug.get(page.pageId)
1050 const source = layoutSourceByPageId.get(page.pageId)
1051 const retainsLayoutSource = !detachedLayoutSourcePageIds.has(page.pageId)
1052 await db.upsertSessionPage({
1053 id: existing?.id || nanoid(),
1054 sessionId: context.sessionId,
1055 legacyPageId:
1056 existing?.legacy_page_id || (page.pageId.match(/^page-\d+$/) ? page.pageId : null),
1057 fileSlug: page.pageId,
1058 pageNumber: page.pageNumber,
1059 title: page.title,
1060 htmlPath: page.htmlPath,
1061 layoutIntent: retainsLayoutSource ? source?.layoutIntent : null,
1062 layoutId: retainsLayoutSource ? source?.layoutId : null,
1063 layoutContractVersion: retainsLayoutSource ? source?.layoutContractVersion : null,
1064 status: 'completed',
1065 error: null
1066 })
1067 }
1068 await db.updateProjectStatus(context.projectId, 'draft')
1069 await db.updateSessionStatus(
1070 context.sessionId,
1071 remainingFailedPages.length > 0 ? 'failed' : 'completed'
1072 )
1073 await db.updateGenerationRunStatus(
1074 context.runId,
1075 remainingFailedPages.length > 0 ? 'partial' : 'completed',
1076 remainingFailedPages.length > 0
1077 ? remainingFailedPages
1078 .map((page) => `${page.pageId}(${page.title}):${page.reason}`)
1079 .join(';')
1080 : null
1081 )
1082 if (remainingFailedPages.length === 0) {
1083 await ctx.history.recordOperation({
1084 sessionId: context.sessionId,
1085 projectDir: context.projectDir,
1086 type: 'edit',
1087 scope: selectedSelector ? 'selector' : 'page',
1088 prompt: context.userMessage,
1089 metadata: {
1090 runId: context.runId,
1091 selectedPageId: resolvedSelectedPageId || null,
1092 selector: selectedSelector || null
1093 }
1094 })
1095 }
1096 const editSummary = buildLocalSuccessfulEditSummary({
1097 context,
1098 changedPages: changedPageDescriptors,
1099 editScope: selectedSelector ? 'selector' : 'page'
1100 })
1101 await emitSuccessfulEditSummary(context, editSummary, emitAssistant)
1102 log.info('[generate:start] edit completed', {
1103 sessionId: context.sessionId,
1104 styleId: context.styleId,
1105 changedPages: Array.from(changedPageIdSet),
1106 remainingFailedPages: remainingFailedPages.map((page) => page.pageId)
1107 })
1108 emitEditChunk({
1109 type: 'run_completed',
1110 payload: {
1111 runId: context.runId,
1112 totalPages: editTotalPages
1113 }
1114 })
1115 }
1116
1116 lines TYPESCRIPT