| 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 |