返回 oh-my-ppt
pptx.ts
根目录 / src / main / styles / import / pptx.ts
1 import fs from 'fs'
2 import path from 'path'
3 import log from 'electron-log/main.js'
4 import { nanoid } from 'nanoid'
5 import { FilesystemBackend, createDeepAgent } from 'deepagents'
6 import { resolveModelTimeoutMs } from '@shared/model-timeout'
7 import { extractJsonBlock, extractModelText, resolveModel } from '../../agent-runtime/model'
8 import type { ModelRuntimeConfig } from '../../agent-runtime/model'
9 import { importPptxToEditableHtml } from '../../io/pptx-import'
10 import { buildStylePptxImportPrompt } from '../../agent-runtime/prompt'
11 import { logAgentToolEvents } from '../../utils/agent-tool-logger'
12 import type { StyleParseResult } from './file'
13
14 const MAX_PPTX_SIZE_MB = 500
15 const MAX_PPTX_SIZE = MAX_PPTX_SIZE_MB * 1024 * 1024
16 const MAX_IMPORT_PAGES = 40
17
18 export async function parseStylePptx(args: {
19 filePath: string
20 provider: string
21 apiKey: string
22 model: string
23 baseUrl: string
24 maxTokens?: number
25 modelRuntime?: ModelRuntimeConfig
26 modelTimeoutMs: number
27 tmpRootDir: string
28 }): Promise<StyleParseResult> {
29 const sourcePath = path.resolve(args.filePath)
30 const ext = path.extname(sourcePath).toLowerCase()
31 if (ext !== '.pptx') throw new Error('不支持的文件格式,仅支持 .pptx')
32 const stat = await fs.promises.stat(sourcePath)
33 if (!stat.isFile()) throw new Error(`路径不是文件:${sourcePath}`)
34 if (stat.size > MAX_PPTX_SIZE) {
35 throw new Error(
36 `文件过大(${(stat.size / 1024 / 1024).toFixed(1)}MB),PPTX 上限 ${MAX_PPTX_SIZE_MB}MB`
37 )
38 }
39
40 await fs.promises.mkdir(args.tmpRootDir, { recursive: true })
41 const taskDir = path.join(args.tmpRootDir, `${Date.now()}-${nanoid(8)}`)
42 await fs.promises.mkdir(taskDir, { recursive: true })
43
44 try {
45 const imported = await importPptxToEditableHtml({
46 filePath: sourcePath,
47 projectDir: taskDir,
48 title: path.basename(sourcePath, path.extname(sourcePath)),
49 maxPages: MAX_IMPORT_PAGES
50 })
51
52 const samplePages = selectSamplePagePaths(
53 imported.pages.map((page) => `/${path.basename(page.htmlPath)}`)
54 )
55 const response = await runStylePptxImportAgent({
56 provider: args.provider,
57 apiKey: args.apiKey,
58 model: args.model,
59 baseUrl: args.baseUrl,
60 maxTokens: args.maxTokens,
61 modelRuntime: args.modelRuntime,
62 modelTimeoutMs: args.modelTimeoutMs,
63 workspaceDir: taskDir,
64 prompt: buildStylePptxImportPrompt({
65 deckRootPath: '/',
66 indexPath: '/index.html',
67 samplePagePaths: samplePages
68 })
69 })
70
71 try {
72 return parseStyleImportResponse(response)
73 } catch (parseError) {
74 const reason = parseError instanceof Error ? parseError.message : String(parseError)
75 log.info('[styles:parsePptx] first parse failed, retrying with fix prompt', { reason })
76
77 const fixedResponse = await retryFixJson({
78 provider: args.provider,
79 apiKey: args.apiKey,
80 model: args.model,
81 baseUrl: args.baseUrl,
82 maxTokens: args.maxTokens,
83 modelRuntime: args.modelRuntime,
84 modelTimeoutMs: args.modelTimeoutMs,
85 brokenResponse: response,
86 parseError: reason
87 })
88 return parseStyleImportResponse(fixedResponse)
89 }
90 } finally {
91 await fs.promises.rm(taskDir, { recursive: true, force: true }).catch((error) => {
92 log.warn('[styles:parsePptx] cleanup failed', {
93 taskDir,
94 message: error instanceof Error ? error.message : String(error)
95 })
96 })
97 }
98 }
99
100 export function selectSamplePagePaths(pagePaths: string[]): string[] {
101 if (pagePaths.length <= 4) return pagePaths
102 const sorted = [...pagePaths].sort()
103 const first = sorted[0]
104 const last = sorted[sorted.length - 1]
105 const middle = sorted.slice(1, -1)
106 if (middle.length === 0) return [first, last]
107
108 // 等距抽样中间页:将中间页均分为 N 个桶,每桶取一页,保证从头到尾均匀覆盖,最多 20 页
109 const maxSamples = Math.min(
110 middle.length,
111 Math.max(2, Math.min(20, Math.ceil(Math.sqrt(pagePaths.length))))
112 )
113 const sampled: string[] = []
114 for (let i = 0; i < maxSamples; i++) {
115 const idx = Math.floor((i + 0.5) * middle.length / maxSamples)
116 sampled.push(middle[idx])
117 }
118
119 return [first, ...sampled, last]
120 }
121
122 async function runStylePptxImportAgent(args: {
123 provider: string
124 apiKey: string
125 model: string
126 baseUrl: string
127 maxTokens?: number
128 modelRuntime?: ModelRuntimeConfig
129 modelTimeoutMs: number
130 workspaceDir: string
131 prompt: string
132 }): Promise<string> {
133 const model = resolveModel(
134 args.provider,
135 args.apiKey,
136 args.model,
137 args.baseUrl,
138 0.2,
139 args.maxTokens,
140 args.modelRuntime
141 )
142 const agent = createDeepAgent({
143 model,
144 backend: new FilesystemBackend({
145 rootDir: args.workspaceDir,
146 virtualMode: true
147 }),
148 systemPrompt:
149 'You are a style-import parsing agent for PPTX-derived HTML files. You must use grep and read_file tools before generating the result. Return strict JSON only: label, description, category, aliases, styleCase, styleSkill.'
150 })
151
152 const stream = await agent.stream(
153 {
154 messages: [
155 {
156 role: 'user',
157 content: args.prompt
158 }
159 ]
160 },
161 {
162 streamMode: ['updates', 'messages'],
163 subgraphs: true,
164 signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'document'))
165 }
166 )
167
168 let messageBuffer = ''
169 let latestAssistantStateText = ''
170 const seenToolEvents = new Set<string>()
171 for await (const chunk of stream as AsyncIterable<unknown>) {
172 if (!Array.isArray(chunk) || chunk.length < 3) continue
173 const mode = chunk[1]
174 const data = chunk[2]
175
176 if (mode === 'updates') {
177 logAgentToolEvents(data, seenToolEvents, { tag: 'styles:parsePptx', source: 'updates' })
178 const assistantTexts = extractAssistantTextsFromState(data)
179 const longestText = assistantTexts.sort((a, b) => b.length - a.length)[0] || ''
180 if (longestText.length >= latestAssistantStateText.length) {
181 latestAssistantStateText = longestText
182 }
183 continue
184 }
185
186 if (mode !== 'messages' || !Array.isArray(data)) continue
187 logAgentToolEvents(data, seenToolEvents, { tag: 'styles:parsePptx', source: 'messages' })
188 for (const message of data as Array<Record<string, unknown>>) {
189 const content = extractModelText(message).trim()
190 if (content) messageBuffer += content
191 }
192 }
193
194 return latestAssistantStateText.length > messageBuffer.length ? latestAssistantStateText : messageBuffer
195 }
196
197 export function parseStyleImportResponse(response: unknown): StyleParseResult {
198 const text = extractModelText(response) || (typeof response === 'string' ? response : JSON.stringify(response))
199 const jsonText = extractJsonBlock(text).trim()
200 if (!jsonText) throw new Error('LLM 返回格式异常:未找到 JSON')
201
202 let parsed: Record<string, unknown>
203 try {
204 parsed = JSON.parse(jsonText)
205 } catch (parseError) {
206 const hint = jsonText.length > 200 ? `${jsonText.slice(0, 200)}...` : jsonText
207 const reason = parseError instanceof Error ? parseError.message : String(parseError)
208 log.warn('[styles:parsePptx] JSON parse failed', { reason, jsonPreview: hint })
209 throw new Error(`LLM 返回的 JSON 格式异常:${reason}`)
210 }
211
212 const label = String(parsed.label || '').trim()
213 const styleSkill = String(parsed.styleSkill || '').trim()
214 if (!label || !styleSkill) {
215 throw new Error('LLM 返回缺少必填字段(label / styleSkill)')
216 }
217
218 return {
219 label,
220 description: String(parsed.description || '').trim(),
221 category: String(parsed.category || '自定义').trim(),
222 aliases: Array.isArray(parsed.aliases)
223 ? parsed.aliases.map((item) => String(item || '').trim()).filter((item) => item.length > 0)
224 : [],
225 styleSkill,
226 styleCase: String(parsed.styleCase || '').trim()
227 }
228 }
229
230 export async function retryFixJson(args: {
231 provider: string
232 apiKey: string
233 model: string
234 baseUrl: string
235 maxTokens?: number
236 modelRuntime?: ModelRuntimeConfig
237 modelTimeoutMs: number
238 brokenResponse: string
239 parseError: string
240 }): Promise<string> {
241 const model = resolveModel(
242 args.provider,
243 args.apiKey,
244 args.model,
245 args.baseUrl,
246 0.2,
247 args.maxTokens,
248 args.modelRuntime
249 )
250 const result = await model.invoke([
251 {
252 role: 'user',
253 content: `你上次输出的 JSON 格式有误,解析报错:${args.parseError}
254
255 请修复 JSON 格式并重新输出完整的 JSON(用 \`\`\`json ... \`\`\` 包裹)。不要修改内容,只修格式。
256
257 原始输出:
258 ${args.brokenResponse}`
259 }
260 ], {
261 signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'document'))
262 })
263 return extractModelText(result)
264 }
265
266 export async function extractStyleFromExistingHtml(args: {
267 projectDir: string
268 pageHtmlPaths: string[]
269 sourceFilePath: string
270 provider: string
271 apiKey: string
272 model: string
273 baseUrl: string
274 maxTokens?: number
275 modelTimeoutMs: number
276 }): Promise<StyleParseResult> {
277 const samplePages = selectSamplePagePaths(
278 args.pageHtmlPaths.map((p) => `/${path.basename(p)}`)
279 )
280
281 const response = await runStylePptxImportAgent({
282 provider: args.provider,
283 apiKey: args.apiKey,
284 model: args.model,
285 baseUrl: args.baseUrl,
286 maxTokens: args.maxTokens,
287 modelTimeoutMs: args.modelTimeoutMs,
288 workspaceDir: args.projectDir,
289 prompt: buildStylePptxImportPrompt({
290 deckRootPath: '/',
291 indexPath: '/index.html',
292 samplePagePaths: samplePages
293 })
294 })
295
296 try {
297 return parseStyleImportResponse(response)
298 } catch (parseError) {
299 const reason = parseError instanceof Error ? parseError.message : String(parseError)
300 log.info('[styles:extractFromHtml] first parse failed, retrying with fix prompt', { reason })
301
302 const fixedResponse = await retryFixJson({
303 provider: args.provider,
304 apiKey: args.apiKey,
305 model: args.model,
306 baseUrl: args.baseUrl,
307 maxTokens: args.maxTokens,
308 modelTimeoutMs: args.modelTimeoutMs,
309 brokenResponse: response,
310 parseError: reason
311 })
312 return parseStyleImportResponse(fixedResponse)
313 }
314 }
315
316 function extractAssistantTextsFromState(data: unknown): string[] {
317 const texts: string[] = []
318 const seen = new Set<object>()
319 const getObject = (value: unknown): Record<string, unknown> | null =>
320 value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
321
322 const visit = (value: unknown): void => {
323 if (!value || typeof value !== 'object') return
324 if (seen.has(value as object)) return
325 seen.add(value as object)
326
327 if (Array.isArray(value)) {
328 value.forEach(visit)
329 return
330 }
331
332 const record = value as Record<string, unknown>
333 const role = String(record.role || '').toLowerCase()
334 const type = String(record.type || '').toLowerCase()
335 const constructorName = String(
336 getObject(record.lc_kwargs)?.type ?? getObject(record.kwargs)?.type ?? ''
337 ).toLowerCase()
338 const isAssistant =
339 role === 'assistant' || type === 'ai' || type === 'assistant' || constructorName === 'ai'
340 const isToolOrHuman =
341 role === 'tool' ||
342 role === 'user' ||
343 role === 'system' ||
344 type === 'tool' ||
345 type === 'human' ||
346 type === 'system'
347 if (!isAssistant || isToolOrHuman) {
348 for (const nested of Object.values(record)) {
349 if (nested && typeof nested === 'object') visit(nested)
350 }
351 return
352 }
353 const text = extractModelText(record).trim()
354 if (text) texts.push(text)
355
356 for (const nested of Object.values(record)) {
357 if (nested && typeof nested === 'object') visit(nested)
358 }
359 }
360
361 visit(data)
362 return texts
363 }
364
364 lines TYPESCRIPT