返回 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 { 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
326 lines TYPESCRIPT