| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import type { GenerateChunkEvent } from '@shared/generation' |
| 4 | import { progressText } from '@shared/progress' |
| 5 | import type { PPTDatabase } from '../db/database' |
| 6 | import { validatePersistedPageHtml } from '../presentation/html/html-utils' |
| 7 | import { runDeepAgentDeckGeneration } from './agent-runner' |
| 8 | import type { AnyFlowContext, EmitAssistantFn } from './types' |
| 9 | import { STABLE_HTML_FRAGMENT_PROTOCOL } from '../agent-runtime/prompt' |
| 10 | |
| 11 | export const uiText = (locale: 'zh' | 'en', zh: string, en: string): string => |
| 12 | locale === 'en' ? en : zh |
| 13 | |
| 14 | export const resolvePageHtmlPath = (args: { |
| 15 | projectDir: string |
| 16 | fileSlug: string |
| 17 | candidates?: Array<string | null | undefined> |
| 18 | }): string => { |
| 19 | const projectRoot = path.resolve(args.projectDir) |
| 20 | const fallback = path.resolve(projectRoot, `${args.fileSlug}.html`) |
| 21 | const candidates = [...(args.candidates || []), fallback] |
| 22 | for (const candidate of candidates) { |
| 23 | if (typeof candidate !== 'string' || candidate.trim().length === 0) continue |
| 24 | const resolved = path.isAbsolute(candidate) |
| 25 | ? path.resolve(candidate) |
| 26 | : path.resolve(projectRoot, candidate) |
| 27 | const relativeToProject = path.relative(projectRoot, resolved) |
| 28 | if (relativeToProject.startsWith('..') || path.isAbsolute(relativeToProject)) continue |
| 29 | if (fs.existsSync(resolved)) return resolved |
| 30 | } |
| 31 | return fallback |
| 32 | } |
| 33 | |
| 34 | export const isEditValidationRetryableError = (error: unknown): boolean => { |
| 35 | const message = error instanceof Error ? error.message : String(error || '') |
| 36 | return /HTML 验证失败|HTML 落盘校验失败|页面编辑结果验证失败/i.test(message) |
| 37 | } |
| 38 | |
| 39 | export const isStructuralFragmentValidationError = (detail: string): boolean => |
| 40 | /HTML 末尾存在未闭合标签|开闭标签数量不一致|闭标签多于开标签|缺少结尾|缺少 <\/body>/i.test( |
| 41 | detail |
| 42 | ) |
| 43 | |
| 44 | export const isEditToolSchemaRetryableError = (error: unknown): boolean => { |
| 45 | const message = error instanceof Error ? error.message : String(error || '') |
| 46 | if (!/Received tool input did not match expected schema/i.test(message)) return false |
| 47 | return /Error invoking tool '(update_single_page_file|update_page_file|edit_file)'/i.test(message) |
| 48 | } |
| 49 | |
| 50 | export const buildEditValidationRetryMessage = (originalMessage: string, detail: string): string => { |
| 51 | const structuralRetry = isStructuralFragmentValidationError(detail) |
| 52 | return [ |
| 53 | originalMessage, |
| 54 | '', |
| 55 | 'Retry requirement:', |
| 56 | `- The previous edit failed validation: ${detail}`, |
| 57 | structuralRetry |
| 58 | ? '- The previous fragment had unbalanced or unfinished tags. Do not patch that broken fragment; rewrite a simpler, shallower fragment from scratch.' |
| 59 | : '- Retry once and fix the validation error directly.', |
| 60 | structuralRetry |
| 61 | ? '- Use one root div, no page shell (section[data-page-scaffold], main[data-role="content"], or runtime frame), grid/flex direct children, aim for 3 nesting levels and avoid exceeding 4, fewer wrappers, and fewer modules.' |
| 62 | : '- Only modify the affected page HTML. Keep the page scaffold, runtime scripts, and balanced tags valid.', |
| 63 | structuralRetry ? STABLE_HTML_FRAGMENT_PROTOCOL : '', |
| 64 | '- Do not modify index.html.' |
| 65 | ].filter(Boolean).join('\n') |
| 66 | } |
| 67 | |
| 68 | export const buildEditToolSchemaRetryMessage = (args: { |
| 69 | originalMessage: string |
| 70 | detail: string |
| 71 | allowedTool: 'update_single_page_file' | 'update_page_file' | 'edit_file' |
| 72 | selectedPageId?: string | null |
| 73 | }): string => { |
| 74 | const targetPageLine = |
| 75 | args.allowedTool === 'edit_file' |
| 76 | ? '- You must target only the selected page file and provide file_path, old_string, and new_string.' |
| 77 | : args.selectedPageId |
| 78 | ? `- For this task, pageId must be exactly: "${args.selectedPageId}".` |
| 79 | : '- You must provide a valid pageId explicitly for each page you modify.' |
| 80 | const callLine = |
| 81 | args.allowedTool === 'update_single_page_file' |
| 82 | ? 'You must call update_single_page_file(pageId, content) exactly once.' |
| 83 | : args.allowedTool === 'update_page_file' |
| 84 | ? 'You must call update_page_file(pageId, content) with explicit pageId for each page you modify.' |
| 85 | : args.allowedTool === 'edit_file' |
| 86 | ? 'You must call edit_file(file_path, old_string, new_string) with all required fields (old_string is required).' |
| 87 | : 'You must fix the tool call arguments and provide all required fields.' |
| 88 | const contentLine = |
| 89 | args.allowedTool === 'edit_file' |
| 90 | ? '- old_string must exactly match the current file content and new_string must contain the replacement only.' |
| 91 | : '- content must be a complete creative page HTML fragment only (no html/head/body).' |
| 92 | return [ |
| 93 | args.originalMessage, |
| 94 | '', |
| 95 | 'Retry requirement:', |
| 96 | `- The previous run failed because the tool call schema was invalid: ${args.detail}`, |
| 97 | '- Retry once. You must fix the tool call arguments and ensure all required fields are provided.', |
| 98 | `- ${callLine}`, |
| 99 | targetPageLine, |
| 100 | contentLine, |
| 101 | '- Do not add any explanations or extra text outside the tool call.', |
| 102 | '- Do not modify index.html.' |
| 103 | ].join('\n') |
| 104 | } |
| 105 | |
| 106 | export const buildEditNoChangeRetryMessage = (args: { |
| 107 | originalMessage: string |
| 108 | allowedTool: 'update_single_page_file' | 'update_page_file' |
| 109 | selectedPageId?: string | null |
| 110 | }): string => { |
| 111 | const callLine = |
| 112 | args.allowedTool === 'update_single_page_file' |
| 113 | ? 'You must call update_single_page_file(pageId, content) exactly once.' |
| 114 | : 'You must call update_page_file(pageId, content) with explicit pageId for each page you modify.' |
| 115 | const targetPageLine = args.selectedPageId |
| 116 | ? `- For this task, pageId must be exactly: "${args.selectedPageId}".` |
| 117 | : '- You must provide a valid pageId explicitly for each page you modify.' |
| 118 | return [ |
| 119 | args.originalMessage, |
| 120 | '', |
| 121 | 'Retry requirement:', |
| 122 | '- The previous run completed without writing any page changes.', |
| 123 | '- Retry once and make the requested edit by writing the updated page HTML.', |
| 124 | `- ${callLine}`, |
| 125 | targetPageLine, |
| 126 | '- content must be a complete creative page HTML fragment only (no html/head/body).', |
| 127 | '- Do not use edit_file or write_file.', |
| 128 | '- Do not modify index.html.' |
| 129 | ].join('\n') |
| 130 | } |
| 131 | |
| 132 | export type EditedPageDescriptor = { |
| 133 | id?: string |
| 134 | pageNumber: number |
| 135 | title: string |
| 136 | pageId: string |
| 137 | html: string |
| 138 | htmlPath: string |
| 139 | } |
| 140 | |
| 141 | export type InvalidEditedPage = { |
| 142 | page: EditedPageDescriptor |
| 143 | reason: string |
| 144 | } |
| 145 | |
| 146 | export const validateChangedPages = ( |
| 147 | changedPageDescriptors: EditedPageDescriptor[] |
| 148 | ): InvalidEditedPage[] => |
| 149 | changedPageDescriptors |
| 150 | .map((page) => { |
| 151 | const validation = validatePersistedPageHtml(page.html, page.pageId) |
| 152 | return validation.valid |
| 153 | ? null |
| 154 | : { |
| 155 | page, |
| 156 | reason: validation.errors.join('; ') |
| 157 | } |
| 158 | }) |
| 159 | .filter((item): item is InvalidEditedPage => Boolean(item)) |
| 160 | |
| 161 | type DeckGenerationArgs = Parameters<typeof runDeepAgentDeckGeneration>[0] |
| 162 | type DeckGenerationResult = Awaited<ReturnType<typeof runDeepAgentDeckGeneration>> |
| 163 | |
| 164 | type CreateGenerationPageCallbacksArgs = { |
| 165 | db: Pick<PPTDatabase, 'upsertGenerationPage'> |
| 166 | runId: string |
| 167 | sessionId: string |
| 168 | } |
| 169 | |
| 170 | type GeneratePagesWithRetryArgs = { |
| 171 | runArgs: DeckGenerationArgs |
| 172 | emitChunk: (chunk: GenerateChunkEvent) => void |
| 173 | appLocale: 'zh' | 'en' |
| 174 | runId: string |
| 175 | totalPages: number |
| 176 | retryDetail?: string |
| 177 | beforeRetry?: () => Promise<void> |
| 178 | buildRetryRunArgs?: (runArgs: DeckGenerationArgs) => DeckGenerationArgs |
| 179 | } |
| 180 | |
| 181 | function buildFallbackFailedPages( |
| 182 | runArgs: DeckGenerationArgs, |
| 183 | reason: string |
| 184 | ): DeckGenerationResult['failedPages'] { |
| 185 | if (Array.isArray(runArgs.pageTasks) && runArgs.pageTasks.length > 0) { |
| 186 | return runArgs.pageTasks.map((task) => ({ |
| 187 | pageId: task.pageId, |
| 188 | title: task.title, |
| 189 | reason |
| 190 | })) |
| 191 | } |
| 192 | if (Array.isArray(runArgs.outlineTitles) && runArgs.outlineTitles.length > 0) { |
| 193 | const pageIds = Object.keys(runArgs.pageFileMap || {}) |
| 194 | if (pageIds.length > 0) { |
| 195 | return runArgs.outlineTitles.map((title, index) => ({ |
| 196 | pageId: pageIds[index] || pageIds[Math.min(index, pageIds.length - 1)], |
| 197 | title, |
| 198 | reason |
| 199 | })) |
| 200 | } |
| 201 | } |
| 202 | const fallbackPageId = Object.keys(runArgs.pageFileMap || {})[0] || 'unknown-page' |
| 203 | return [{ pageId: fallbackPageId, title: 'Untitled', reason }] |
| 204 | } |
| 205 | |
| 206 | export function createGenerationPageCallbacks( |
| 207 | args: CreateGenerationPageCallbacksArgs |
| 208 | ): Pick<DeckGenerationArgs, 'onPageCompleted' | 'onPageFailed'> { |
| 209 | const { db, runId, sessionId } = args |
| 210 | const onPageCompleted: NonNullable<DeckGenerationArgs['onPageCompleted']> = async (page) => { |
| 211 | if (!fs.existsSync(page.htmlPath)) { |
| 212 | throw new Error(`${page.pageId}.html 缺失`) |
| 213 | } |
| 214 | const html = await fs.promises.readFile(page.htmlPath, 'utf-8') |
| 215 | const validation = validatePersistedPageHtml(html, page.pageId) |
| 216 | if (!validation.valid) { |
| 217 | throw new Error(`HTML 验证失败 (${page.pageId}): ${validation.errors.join('; ')}`) |
| 218 | } |
| 219 | await db.upsertGenerationPage({ |
| 220 | runId, |
| 221 | sessionId, |
| 222 | pageId: page.pageId, |
| 223 | pageNumber: page.pageNumber, |
| 224 | title: page.title, |
| 225 | contentOutline: page.contentOutline, |
| 226 | layoutIntent: page.layoutIntent, |
| 227 | htmlPath: page.htmlPath, |
| 228 | status: 'completed' |
| 229 | }) |
| 230 | } |
| 231 | |
| 232 | const onPageFailed: NonNullable<DeckGenerationArgs['onPageFailed']> = async (page) => { |
| 233 | await db.upsertGenerationPage({ |
| 234 | runId, |
| 235 | sessionId, |
| 236 | pageId: page.pageId, |
| 237 | pageNumber: page.pageNumber, |
| 238 | title: page.title, |
| 239 | contentOutline: page.contentOutline, |
| 240 | layoutIntent: page.layoutIntent, |
| 241 | htmlPath: page.htmlPath, |
| 242 | status: 'failed', |
| 243 | error: page.reason |
| 244 | }) |
| 245 | } |
| 246 | |
| 247 | return { onPageCompleted, onPageFailed } |
| 248 | } |
| 249 | |
| 250 | export async function generatePagesWithRetry( |
| 251 | args: GeneratePagesWithRetryArgs |
| 252 | ): Promise<DeckGenerationResult> { |
| 253 | const { |
| 254 | runArgs, |
| 255 | emitChunk, |
| 256 | appLocale, |
| 257 | runId, |
| 258 | totalPages, |
| 259 | retryDetail, |
| 260 | beforeRetry, |
| 261 | buildRetryRunArgs |
| 262 | } = args |
| 263 | |
| 264 | const firstResult = await runDeepAgentDeckGeneration(runArgs).catch((err) => { |
| 265 | const reason = err instanceof Error ? err.message : String(err) |
| 266 | return { |
| 267 | summary: '', |
| 268 | failedPages: buildFallbackFailedPages(runArgs, reason) |
| 269 | } satisfies DeckGenerationResult |
| 270 | }) |
| 271 | |
| 272 | if (firstResult.failedPages.length === 0) return firstResult |
| 273 | |
| 274 | emitChunk({ |
| 275 | type: 'llm_status', |
| 276 | payload: { |
| 277 | runId, |
| 278 | stage: 'rendering', |
| 279 | label: progressText(appLocale, 'retrying'), |
| 280 | progress: 15, |
| 281 | totalPages, |
| 282 | detail: retryDetail |
| 283 | } |
| 284 | }) |
| 285 | |
| 286 | if (beforeRetry) { |
| 287 | await beforeRetry() |
| 288 | } |
| 289 | |
| 290 | const retryResult = await runDeepAgentDeckGeneration( |
| 291 | buildRetryRunArgs ? buildRetryRunArgs(runArgs) : runArgs |
| 292 | ) |
| 293 | if (retryResult.failedPages.length > 0) { |
| 294 | throw new Error(retryResult.failedPages.map((p) => `${p.pageId}: ${p.reason}`).join('; ')) |
| 295 | } |
| 296 | return retryResult |
| 297 | } |
| 298 | |
| 299 | export function createEmitAssistantMessage( |
| 300 | db: Pick<PPTDatabase, 'addMessage'>, |
| 301 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 302 | emitGenerateChunk: (sessionId: string, chunk: any) => void |
| 303 | ): EmitAssistantFn { |
| 304 | return async (context: AnyFlowContext, content: string): Promise<void> => { |
| 305 | if (!content.trim()) return |
| 306 | const messageId = await db.addMessage(context.sessionId, { |
| 307 | role: 'assistant', |
| 308 | content: content.trim(), |
| 309 | type: 'text', |
| 310 | chat_scope: context.messageScope, |
| 311 | page_id: context.messagePageId, |
| 312 | run_model: context.runModel |
| 313 | }) |
| 314 | emitGenerateChunk(context.sessionId, { |
| 315 | type: 'assistant_message', |
| 316 | payload: { |
| 317 | id: messageId, |
| 318 | runId: context.runId, |
| 319 | content: content.trim(), |
| 320 | chatType: context.messageScope, |
| 321 | pageId: context.messagePageId |
| 322 | } |
| 323 | }) |
| 324 | } |
| 325 | } |
| 326 |