返回 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 provider: 'import',
130 model: 'pptx-import'
131 })
132 await db.updateSessionDesignContract(sessionId, createDefaultDesignContract())
133 const projectId = await db.createProject({
134 session_id: sessionId,
135 title: imported.title,
136 output_path: projectDir,
137 root_path: projectDir
138 })
139 const runId = await db.createGenerationRun({
140 sessionId,
141 mode: 'import',
142 totalPages: imported.pageCount,
143 modelConfigId: parsedPayload.modelConfigId,
144 metadata: {
145 source: 'pptx-import',
146 originalFileName,
147 modelConfigId: parsedPayload.modelConfigId
148 }
149 })
150 for (const page of imported.pages) {
151 await db.upsertGenerationPage({
152 runId,
153 sessionId,
154 pageId: page.pageId,
155 pageNumber: page.pageNumber,
156 title: page.title,
157 contentOutline: page.contentOutline,
158 htmlPath: page.htmlPath,
159 status: 'completed'
160 })
161 await db.upsertSessionPage({
162 id: crypto.randomUUID(),
163 sessionId,
164 legacyPageId: /^page-\d+$/i.test(page.pageId) ? page.pageId : null,
165 fileSlug: page.pageId,
166 pageNumber: page.pageNumber,
167 title: page.title,
168 htmlPath: page.htmlPath,
169 status: 'completed',
170 error: null
171 })
172 }
173 await db.updateGenerationRunStatus(runId, 'completed')
174 await db.updateSessionStatus(sessionId, 'completed')
175 await db.updateSessionMetadata(sessionId, {
176 source: 'pptx-import',
177 importedAt: Date.now(),
178 originalFileName,
179 indexPath: imported.indexPath,
180 warnings: imported.warnings.slice(0, 30)
181 })
182 await db.updateProjectStatus(projectId, 'draft')
183 await recordHistoryOperationStrict(db, {
184 sessionId,
185 projectDir,
186 type: 'import',
187 scope: 'session',
188 prompt: `导入 PPTX:${originalFileName}`,
189 metadata: {
190 runId,
191 source: 'pptx-import',
192 originalFileName,
193 pageCount: imported.pageCount
194 }
195 })
196
197 // --- Auto style extraction (non-blocking) ---
198 try {
199 const activeModel = await resolveModelConfigForTask(ctx, {
200 modelConfigId: parsedPayload.modelConfigId,
201 purpose: 'pptx:import'
202 })
203 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
204 sendProgress(createPptxImportPostPersistProgress('style-extraction', imported.pageCount))
205 const styleResult = await extractStyleFromExistingHtml({
206 projectDir,
207 pageHtmlPaths: imported.pages.map((p) => path.basename(p.htmlPath)),
208 sourceFilePath: sourcePath,
209 provider: activeModel.provider,
210 apiKey: activeModel.apiKey,
211 model: activeModel.model,
212 baseUrl: activeModel.baseUrl,
213 maxTokens: activeModel.maxTokens,
214 modelTimeoutMs: modelTimeouts.document
215 })
216
217 const styleId = `style-${nanoidLower()}`
218 await createStyleSkill({
219 id: styleId,
220 label: styleResult.label,
221 description: styleResult.description,
222 category: styleResult.category,
223 aliases: styleResult.aliases,
224 prompt: styleResult.styleSkill,
225 styleCase: styleResult.styleCase
226 })
227 await db.updateSessionStyleId(sessionId, styleId)
228 log.info('[pptx:import] auto style extracted', { sessionId, styleId })
229
230 // Generate design contract from the extracted styleSkill
231 sendProgress(createPptxImportPostPersistProgress('design-contract', imported.pageCount))
232 const designContract = await buildDesignContractWithLLM({
233 provider: activeModel.provider,
234 apiKey: activeModel.apiKey,
235 model: activeModel.model,
236 baseUrl: activeModel.baseUrl,
237 maxTokens: activeModel.maxTokens,
238 modelRuntime: ctx.modelRuntime,
239 styleId,
240 styleSkillPrompt: styleResult.styleSkill,
241 modelTimeoutMs: modelTimeouts.document,
242 totalPages: imported.pageCount,
243 slideSize: requireSlideSizePreset('wide-16-9'),
244 topic: title
245 })
246 sendProgress(
247 createPptxImportPostPersistProgress('design-contract-persist', imported.pageCount)
248 )
249 await db.updateSessionDesignContract(sessionId, designContract)
250 log.info('[pptx:import] design contract generated', { sessionId })
251 } catch (styleError) {
252 log.warn('[pptx:import] auto style extraction failed, import continues', {
253 sessionId,
254 message: styleError instanceof Error ? styleError.message : String(styleError)
255 })
256 sendProgress(createPptxImportPostPersistProgress('style-skipped', imported.pageCount))
257 }
258
259 sendProgress(createPptxImportPostPersistProgress('completed', imported.pageCount))
260
261 log.info('[pptx:import] completed', {
262 sessionId,
263 pageCount: imported.pageCount,
264 warningCount: imported.warnings.length,
265 projectDir
266 })
267
268 return {
269 sessionId,
270 pageCount: imported.pageCount,
271 warnings: imported.warnings
272 }
273 } catch (error) {
274 const message = error instanceof Error ? error.message : String(error)
275 await db.deleteSession(sessionId).catch((cleanupError) => {
276 log.warn('[pptx:import] cleanup db failed', {
277 sessionId,
278 message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
279 })
280 })
281 await fs.promises.rm(projectDir, { recursive: true, force: true }).catch((cleanupError) => {
282 log.warn('[pptx:import] cleanup project dir failed', {
283 sessionId,
284 projectDir,
285 message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
286 })
287 })
288 log.error('[pptx:import] failed', {
289 sessionId,
290 filePath: sourcePath,
291 message
292 })
293 throw error
294 }
295 })
296 }
297
297 lines TYPESCRIPT