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