返回 oh-my-ppt
handlers.ts
根目录 / src / main / io / pptx-import / handlers.ts
1 import { ipcMain } from 'electron'
2 import log from 'electron-log/main.js'
3 import path from 'path'
4 import fs from 'fs'
5 import crypto from 'crypto'
6 import type { IpcContext } from '../../ipc/context'
7 import { importPptxToEditableHtml, type PptxImportProgressPayload } from './index'
8 import { extractStyleFromExistingHtml } from '../../styles/import/pptx'
9 import { createPptxChartRewriteHandler } from './chart-rewrite-agent'
10 import { createStyleSkill, resolveUsableStyleId } from '../../styles/catalog'
11 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../../config/model-config-utils'
12 import { buildDesignContractWithLLM } from '../../generation/agent-runner'
13 import { createPptxImportPostPersistProgress } from './progress'
14 import { customAlphabet } from 'nanoid'
15 import { recordHistoryOperationStrict } from '../../history/git-history-service'
16 import { createDefaultDesignContract } from '../../presentation/design-contract'
17 import { requireSlideSizePreset } from '@shared/slide-size'
18 import { createSessionMasterIfMissing } from '../../session/master-service'
19
20 const nanoidLower = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12)
21
22 type PptxImportPayload = {
23 filePath?: unknown
24 title?: unknown
25 styleId?: unknown
26 modelConfigId?: unknown
27 }
28
29 const MAX_PPTX_SIZE_MB = 500
30 const MAX_PPTX_SIZE = MAX_PPTX_SIZE_MB * 1024 * 1024
31
32 const parsePayload = (
33 payload: unknown
34 ): { filePath: string; title: string; styleId: string | null; modelConfigId?: string } => {
35 const record = payload && typeof payload === 'object' ? (payload as PptxImportPayload) : {}
36 const filePath = typeof record.filePath === 'string' ? record.filePath.trim() : ''
37 if (!filePath) throw new Error('PPTX 文件路径不能为空')
38 const title = typeof record.title === 'string' ? record.title.trim() : ''
39 const styleId = typeof record.styleId === 'string' && record.styleId.trim() ? record.styleId.trim() : null
40 const modelConfigId =
41 typeof record.modelConfigId === 'string' ? record.modelConfigId.trim() : undefined
42 return { filePath, title, styleId, modelConfigId }
43 }
44
45 export function registerPptxImportHandlers(ctx: IpcContext): void {
46 const { db, resolveStoragePath, ensureSessionAssets, resolveExistingFileRealPath } = ctx
47
48 ipcMain.handle('pptx:import', async (event, payload: unknown) => {
49 const parsedPayload = parsePayload(payload)
50 const sourcePath = await resolveExistingFileRealPath(parsedPayload.filePath)
51 const extension = path.extname(sourcePath).toLowerCase()
52 if (extension !== '.pptx') {
53 throw new Error('仅支持导入 .pptx 文件')
54 }
55 const stat = await fs.promises.stat(sourcePath)
56 if (stat.size > MAX_PPTX_SIZE) {
57 throw new Error(`PPTX 文件不能超过 ${MAX_PPTX_SIZE_MB}MB`)
58 }
59
60 const sessionId = crypto.randomUUID()
61 const storagePath = await resolveStoragePath()
62 const projectDir = path.join(storagePath, sessionId)
63 const originalFileName = path.basename(sourcePath)
64 const title =
65 parsedPayload.title || path.basename(originalFileName, path.extname(originalFileName)) || '导入的 PPTX'
66
67 const sendProgress = (progress: PptxImportProgressPayload): void => {
68 event.sender.send('pptx:import:progress', {
69 ...progress,
70 sessionId
71 })
72 }
73
74 log.info('[pptx:import] invoke', {
75 sessionId,
76 filePath: sourcePath,
77 size: stat.size
78 })
79
80 try {
81 await fs.promises.mkdir(projectDir, { recursive: true })
82 await ensureSessionAssets(projectDir)
83 await createSessionMasterIfMissing(projectDir)
84 let chartRewrite: ReturnType<typeof createPptxChartRewriteHandler> | undefined
85 try {
86 const activeModel = await resolveModelConfigForTask(ctx, {
87 modelConfigId: parsedPayload.modelConfigId,
88 purpose: 'pptx:import:chartRewrite'
89 })
90 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
91 chartRewrite = createPptxChartRewriteHandler({
92 provider: activeModel.provider,
93 apiKey: activeModel.apiKey,
94 model: activeModel.model,
95 baseUrl: activeModel.baseUrl,
96 maxTokens: activeModel.maxTokens,
97 modelRuntime: ctx.modelRuntime,
98 modelTimeoutMs: modelTimeouts.document
99 })
100 } catch (chartRewriteError) {
101 log.warn('[pptx:import] chart rewrite agent unavailable, import continues', {
102 sessionId,
103 message:
104 chartRewriteError instanceof Error
105 ? chartRewriteError.message
106 : String(chartRewriteError)
107 })
108 }
109 const imported = await importPptxToEditableHtml({
110 filePath: sourcePath,
111 projectDir,
112 title,
113 onProgress: sendProgress,
114 chartRewrite
115 })
116
117 sendProgress(createPptxImportPostPersistProgress('session-records', imported.pageCount))
118 const initialStyleId = resolveUsableStyleId(parsedPayload.styleId)
119
120 await db.createSession({
121 id: sessionId,
122 title: imported.title,
123 topic: imported.title,
124 styleId: initialStyleId,
125 pageCount: imported.pageCount,
126 slideSizeId: 'wide-16-9',
127 slideWidth: 1600,
128 slideHeight: 900,
129 visualEnabled: false,
130 imageModelConfigId: null,
131 provider: 'import',
132 model: 'pptx-import'
133 })
134 await db.updateSessionDesignContract(sessionId, createDefaultDesignContract())
135 const projectId = await db.createProject({
136 session_id: sessionId,
137 title: imported.title,
138 output_path: projectDir,
139 root_path: projectDir
140 })
141 const runId = await db.createGenerationRun({
142 sessionId,
143 mode: 'import',
144 totalPages: imported.pageCount,
145 modelConfigId: parsedPayload.modelConfigId,
146 metadata: {
147 source: 'pptx-import',
148 originalFileName,
149 modelConfigId: parsedPayload.modelConfigId
150 }
151 })
152 for (const page of imported.pages) {
153 await db.upsertGenerationPage({
154 runId,
155 sessionId,
156 pageId: page.pageId,
157 pageNumber: page.pageNumber,
158 title: page.title,
159 contentOutline: page.contentOutline,
160 htmlPath: page.htmlPath,
161 status: 'completed'
162 })
163 await db.upsertSessionPage({
164 id: crypto.randomUUID(),
165 sessionId,
166 legacyPageId: /^page-\d+$/i.test(page.pageId) ? page.pageId : null,
167 fileSlug: page.pageId,
168 pageNumber: page.pageNumber,
169 title: page.title,
170 htmlPath: page.htmlPath,
171 status: 'completed',
172 error: null
173 })
174 }
175 await db.updateGenerationRunStatus(runId, 'completed')
176 await db.updateSessionStatus(sessionId, 'completed')
177 await db.updateSessionMetadata(sessionId, {
178 source: 'pptx-import',
179 importedAt: Date.now(),
180 originalFileName,
181 indexPath: imported.indexPath,
182 warnings: imported.warnings.slice(0, 30)
183 })
184 await db.updateProjectStatus(projectId, 'draft')
185 await recordHistoryOperationStrict(db, {
186 sessionId,
187 projectDir,
188 type: 'import',
189 scope: 'session',
190 prompt: `导入 PPTX:${originalFileName}`,
191 metadata: {
192 runId,
193 source: 'pptx-import',
194 originalFileName,
195 pageCount: imported.pageCount
196 }
197 })
198
199 // --- Auto style extraction (non-blocking) ---
200 try {
201 const activeModel = await resolveModelConfigForTask(ctx, {
202 modelConfigId: parsedPayload.modelConfigId,
203 purpose: 'pptx:import'
204 })
205 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
206 sendProgress(createPptxImportPostPersistProgress('style-extraction', imported.pageCount))
207 const styleResult = await extractStyleFromExistingHtml({
208 projectDir,
209 pageHtmlPaths: imported.pages.map((p) => path.basename(p.htmlPath)),
210 sourceFilePath: sourcePath,
211 provider: activeModel.provider,
212 apiKey: activeModel.apiKey,
213 model: activeModel.model,
214 baseUrl: activeModel.baseUrl,
215 maxTokens: activeModel.maxTokens,
216 modelTimeoutMs: modelTimeouts.document
217 })
218
219 const styleId = `style-${nanoidLower()}`
220 await createStyleSkill({
221 id: styleId,
222 label: styleResult.label,
223 description: styleResult.description,
224 category: styleResult.category,
225 aliases: styleResult.aliases,
226 prompt: styleResult.styleSkill,
227 styleCase: styleResult.styleCase
228 })
229 await db.updateSessionStyleId(sessionId, styleId)
230 log.info('[pptx:import] auto style extracted', { sessionId, styleId })
231
232 // Generate design contract from the extracted styleSkill
233 sendProgress(createPptxImportPostPersistProgress('design-contract', imported.pageCount))
234 const designContract = await buildDesignContractWithLLM({
235 provider: activeModel.provider,
236 apiKey: activeModel.apiKey,
237 model: activeModel.model,
238 baseUrl: activeModel.baseUrl,
239 maxTokens: activeModel.maxTokens,
240 modelRuntime: ctx.modelRuntime,
241 styleId,
242 styleSkillPrompt: styleResult.styleSkill,
243 modelTimeoutMs: modelTimeouts.document,
244 totalPages: imported.pageCount,
245 slideSize: requireSlideSizePreset('wide-16-9'),
246 topic: title
247 })
248 sendProgress(
249 createPptxImportPostPersistProgress('design-contract-persist', imported.pageCount)
250 )
251 await db.updateSessionDesignContract(sessionId, designContract)
252 log.info('[pptx:import] design contract generated', { sessionId })
253 } catch (styleError) {
254 log.warn('[pptx:import] auto style extraction failed, import continues', {
255 sessionId,
256 message: styleError instanceof Error ? styleError.message : String(styleError)
257 })
258 sendProgress(createPptxImportPostPersistProgress('style-skipped', imported.pageCount))
259 }
260
261 sendProgress(createPptxImportPostPersistProgress('completed', imported.pageCount))
262
263 log.info('[pptx:import] completed', {
264 sessionId,
265 pageCount: imported.pageCount,
266 warningCount: imported.warnings.length,
267 projectDir
268 })
269
270 return {
271 sessionId,
272 pageCount: imported.pageCount,
273 warnings: imported.warnings
274 }
275 } catch (error) {
276 const message = error instanceof Error ? error.message : String(error)
277 await db.deleteSession(sessionId).catch((cleanupError) => {
278 log.warn('[pptx:import] cleanup db failed', {
279 sessionId,
280 message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
281 })
282 })
283 await fs.promises.rm(projectDir, { recursive: true, force: true }).catch((cleanupError) => {
284 log.warn('[pptx:import] cleanup project dir failed', {
285 sessionId,
286 projectDir,
287 message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
288 })
289 })
290 log.error('[pptx:import] failed', {
291 sessionId,
292 filePath: sourcePath,
293 message
294 })
295 throw error
296 }
297 })
298 }
299
299 lines TYPESCRIPT