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