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