返回 oh-my-ppt
handlers.ts
根目录 / src / main / styles / handlers.ts
1 import { BrowserWindow, dialog, ipcMain } from 'electron'
2 import fs from 'fs'
3 import path from 'path'
4 import log from 'electron-log/main.js'
5 import { customAlphabet } from 'nanoid'
6 import {
7 listStyleCatalog,
8 getStyleDetail,
9 createStyleSkill,
10 updateStyleSkill,
11 hasStyleSkill,
12 deleteStyleSkill,
13 exportStylePackageZip,
14 importStylePackageDirectory,
15 importStylePackageZip
16 } from './catalog'
17 import type { IpcContext } from '../ipc/context'
18 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
19 import { parseStyleFile } from './import/file'
20 import { parseStyleImage } from './import/image'
21 import { parseStylePptx } from './import/pptx'
22 import { isSupportedImageMimeType, normalizeImageMimeType } from '@shared/image-mime'
23 import { getInstalledStylesPath } from './style-runtime'
24 import {
25 enqueueHtmlThumbnail,
26 getFreshHtmlThumbnailPath
27 } from '../io/thumbnails/html-thumbnail-service'
28
29 const nanoidLower = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12)
30 const MAX_STYLE_IMAGE_SIZE_BYTES = 5 * 1024 * 1024
31
32 function resolvePreviewPath(row: {
33 id: string
34 style: string
35 source: string
36 packageDir?: string | null
37 }): string | null {
38 const installedRoot = getInstalledStylesPath()
39 if (!installedRoot) return null
40 const dir = row.packageDir
41 ? path.join(installedRoot, row.packageDir)
42 : row.source === 'builtin'
43 ? path.join(installedRoot, 'system', row.style)
44 : path.join(installedRoot, 'user', row.id)
45 const htmlPath = path.join(dir, 'preview.html')
46 return fs.existsSync(htmlPath) ? htmlPath : null
47 }
48
49 type StyleBasePayload = {
50 label: string
51 description: string
52 category: string
53 aliases: string[]
54 prompt: string
55 styleCase: string
56 }
57
58 type StylePayload = StyleBasePayload & {
59 id: string
60 }
61
62 function parseAliases(value: string): string[] {
63 try {
64 const parsed = JSON.parse(value || '[]')
65 return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []
66 } catch {
67 return []
68 }
69 }
70
71 export function registerStyleHandlers(ctx: IpcContext): void {
72 const { db } = ctx
73 const completeStylePackageImport = async (result: {
74 id: string
75 source: 'custom' | 'override'
76 }): Promise<{ success: true; cancelled: false; id: string; source: 'custom' | 'override' }> => {
77 const importedStyle = await db.getStyleRow(result.id)
78 const previewPath = importedStyle ? resolvePreviewPath(importedStyle) : null
79 if (previewPath) {
80 await enqueueHtmlThumbnail(
81 { resourceType: 'style', resourceId: result.id, sourcePath: previewPath },
82 { force: true }
83 )
84 }
85 return { success: true, cancelled: false, ...result }
86 }
87
88 ipcMain.handle('styles:get', async () => {
89 log.info('[styles:get] requested')
90 const styles = listStyleCatalog()
91 const categories: Record<
92 string,
93 Array<{
94 id: string
95 label: string
96 description: string
97 source?: 'builtin' | 'custom' | 'override'
98 editable?: boolean
99 styleCase?: string
100 }>
101 > = {}
102 for (const style of styles) {
103 const category = style.category
104 if (!categories[category]) categories[category] = []
105 categories[category].push({
106 id: style.id,
107 label: style.label,
108 description: style.description,
109 source: style.source,
110 editable: style.editable,
111 styleCase: style.styleCase
112 })
113 }
114 const defaultStyle =
115 styles.find((item) => item.styleKey === 'minimal-white')?.id ?? styles[0]?.id ?? ''
116 return { categories, defaultStyle }
117 })
118
119 ipcMain.handle('styles:getDetail', async (_event, styleId: string) => {
120 return getStyleDetail(styleId)
121 })
122
123 ipcMain.handle('styles:list', async (_event, payload?: { sessionId?: string }) => {
124 const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : ''
125 const rows = (await db.listStyleRows()).filter((row) => row.active !== false)
126 rows.sort(
127 (a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id)
128 )
129 const items = await Promise.all(rows.map(async (row) => {
130 const previewPath = resolvePreviewPath(row)
131 return {
132 id: row.id,
133 styleKey: row.style,
134 label: row.styleName,
135 name: {
136 zh: row.styleNameZh || row.styleName,
137 en: row.styleNameEn || ''
138 },
139 description: row.description,
140 aliases: parseAliases(row.aliases),
141 category: row.category || (row.source === 'builtin' ? '内置' : '自定义'),
142 source: row.source,
143 editable: row.source !== 'builtin',
144 version: row.version,
145 styleCase: row.styleCase,
146 packageDir: row.packageDir || '',
147 favoriteAt: row.favoriteAt ?? null,
148 previewPath,
149 thumbnailPath: previewPath
150 ? await getFreshHtmlThumbnailPath({
151 resourceType: 'style',
152 resourceId: row.id,
153 sourcePath: previewPath
154 })
155 : null,
156 createdAt: row.createdAt,
157 updatedAt: row.updatedAt
158 }
159 }))
160
161 if (sessionId) {
162 const snapshot = await db.getSessionStyleSnapshot(sessionId)
163 if (snapshot && !items.some((item) => item.id === snapshot.styleId)) {
164 const previewPath = resolvePreviewPath({
165 id: snapshot.styleId,
166 style: snapshot.styleKey,
167 source: snapshot.source,
168 packageDir: snapshot.packageDir
169 })
170 items.unshift({
171 id: snapshot.styleId,
172 styleKey: snapshot.styleKey,
173 label: snapshot.styleName,
174 name: {
175 zh: snapshot.styleNameZh || snapshot.styleName,
176 en: snapshot.styleNameEn || ''
177 },
178 description: snapshot.description,
179 aliases: parseAliases(snapshot.aliases),
180 category: snapshot.category || (snapshot.source === 'builtin' ? '内置' : '自定义'),
181 source: snapshot.source,
182 editable: false,
183 version: snapshot.version,
184 styleCase: snapshot.styleCase,
185 packageDir: snapshot.packageDir || '',
186 favoriteAt: null,
187 previewPath,
188 thumbnailPath: previewPath
189 ? await getFreshHtmlThumbnailPath({
190 resourceType: 'style',
191 resourceId: snapshot.styleId,
192 sourcePath: previewPath
193 })
194 : null,
195 createdAt: snapshot.createdAt,
196 updatedAt: snapshot.createdAt
197 })
198 }
199 }
200 return {
201 items
202 }
203 })
204
205 ipcMain.handle('styles:setFavorite', async (_event, payload) => {
206 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
207 const styleId = String(record.styleId || '').trim()
208 if (!styleId) return { success: false, styleId: '', favoriteAt: null }
209 const nextFavoriteAt = record.favorite ? Math.floor(Date.now() / 1000) : null
210 try {
211 const favoriteAt = await db.setStyleFavorite(styleId, nextFavoriteAt)
212 return { success: true, styleId, favoriteAt }
213 } catch {
214 return { success: false, styleId, favoriteAt: null }
215 }
216 })
217
218 const parseBasePayload = (payload: unknown): StyleBasePayload => {
219 const record =
220 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
221 const label = String(record.label || '').trim()
222 const description = String(record.description || '').trim()
223 const category = String(record.category || '').trim()
224 const styleSkill = String(record.styleSkill || '').trim()
225 const aliases = Array.isArray(record.aliases)
226 ? record.aliases
227 .map((alias: unknown) => String(alias || '').trim())
228 .filter((alias: string) => alias.length > 0)
229 : []
230 if (!label) {
231 throw new Error('保存风格失败:label 必填。')
232 }
233 if (!styleSkill) {
234 throw new Error('保存风格失败:styleSkill 不能为空。')
235 }
236 return {
237 label,
238 description,
239 category,
240 aliases,
241 prompt: styleSkill,
242 styleCase: String(record.styleCase || '').trim()
243 }
244 }
245
246 const parseCreatePayload = (payload: unknown): StyleBasePayload => {
247 log.info('[styles:create] payload requested')
248 return parseBasePayload(payload)
249 }
250
251 const parseUpdatePayload = (payload: unknown): StylePayload => {
252 const record =
253 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
254 const id = String(record.id || '').trim()
255 if (!id) {
256 throw new Error('保存风格失败:id 必填。')
257 }
258 log.info('[styles:update] payload requested', { styleId: id })
259 return {
260 ...parseBasePayload(payload),
261 id
262 }
263 }
264
265 ipcMain.handle('styles:parseFile', async (_event, payload) => {
266 const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : ''
267 if (!filePath) throw new Error('文件路径为空')
268 const activeModel = await resolveModelConfigForTask(ctx, {
269 modelConfigId: payload?.modelConfigId,
270 purpose: 'styles:parseFile'
271 })
272 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
273 const styleImportDir = path.join(await ctx.resolveStoragePath(), 'style-import')
274 await fs.promises.mkdir(styleImportDir, { recursive: true })
275 return await parseStyleFile({
276 filePath,
277 provider: activeModel.provider,
278 apiKey: activeModel.apiKey,
279 model: activeModel.model,
280 baseUrl: activeModel.baseUrl,
281 maxTokens: activeModel.maxTokens,
282 modelRuntime: ctx.modelRuntime,
283 modelTimeoutMs: modelTimeouts.document,
284 workspaceDir: styleImportDir
285 })
286 })
287
288 ipcMain.handle('styles:parsePptx', async (_event, payload) => {
289 const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : ''
290 if (!filePath) throw new Error('文件路径为空')
291 const activeModel = await resolveModelConfigForTask(ctx, {
292 modelConfigId: payload?.modelConfigId,
293 purpose: 'styles:parsePptx'
294 })
295 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
296 const tmpRootDir = path.join(await ctx.resolveStoragePath(), 'tmpStyle')
297 await fs.promises.mkdir(tmpRootDir, { recursive: true })
298 return await parseStylePptx({
299 filePath,
300 provider: activeModel.provider,
301 apiKey: activeModel.apiKey,
302 model: activeModel.model,
303 baseUrl: activeModel.baseUrl,
304 maxTokens: activeModel.maxTokens,
305 modelRuntime: ctx.modelRuntime,
306 modelTimeoutMs: modelTimeouts.document,
307 tmpRootDir
308 })
309 })
310
311 ipcMain.handle('styles:parseImage', async (_event, payload) => {
312 const imageBase64 = typeof payload?.imageBase64 === 'string' ? payload.imageBase64.trim() : ''
313 const rawMimeType = typeof payload?.mimeType === 'string' ? payload.mimeType : ''
314 const mimeType = normalizeImageMimeType(rawMimeType)
315 if (!imageBase64) throw new Error('图片数据为空')
316 if (!isSupportedImageMimeType(rawMimeType)) {
317 throw new Error(`不支持的图片格式:${mimeType || 'unknown'}`)
318 }
319 let imageBuffer: Buffer
320 try {
321 imageBuffer = Buffer.from(imageBase64, 'base64')
322 } catch {
323 throw new Error('图片数据格式无效')
324 }
325 if (!imageBuffer.length) {
326 throw new Error('图片数据为空')
327 }
328 if (imageBuffer.length > MAX_STYLE_IMAGE_SIZE_BYTES) {
329 throw new Error(
330 `图片过大(${(imageBuffer.length / 1024 / 1024).toFixed(1)}MB),图片上限 5MB`
331 )
332 }
333
334 const activeModel = await resolveModelConfigForTask(ctx, {
335 modelConfigId: payload?.modelConfigId,
336 purpose: 'styles:parseImage'
337 })
338 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
339 return await parseStyleImage({
340 imageBase64,
341 mimeType,
342 provider: activeModel.provider,
343 apiKey: activeModel.apiKey,
344 model: activeModel.model,
345 baseUrl: activeModel.baseUrl,
346 maxTokens: activeModel.maxTokens,
347 modelRuntime: ctx.modelRuntime,
348 modelTimeoutMs: modelTimeouts.document
349 })
350 })
351
352 ipcMain.handle('styles:importPackageZip', async (event) => {
353 const ownerWindow = BrowserWindow.fromWebContents(event.sender)
354 const openResult = ownerWindow
355 ? await dialog.showOpenDialog(ownerWindow, {
356 title: '导入风格包',
357 buttonLabel: '导入',
358 properties: ['openFile'],
359 filters: [
360 { name: 'Style ZIP', extensions: ['zip'] },
361 { name: '所有文件', extensions: ['*'] }
362 ]
363 })
364 : await dialog.showOpenDialog({
365 title: '导入风格包',
366 buttonLabel: '导入',
367 properties: ['openFile'],
368 filters: [
369 { name: 'Style ZIP', extensions: ['zip'] },
370 { name: '所有文件', extensions: ['*'] }
371 ]
372 })
373 if (openResult.canceled || openResult.filePaths.length === 0) {
374 return { success: false, cancelled: true, id: '', source: 'custom' as const }
375 }
376 const result = await importStylePackageZip(openResult.filePaths[0])
377 return completeStylePackageImport(result)
378 })
379
380 ipcMain.handle('styles:importPackageDirectory', async (event) => {
381 const ownerWindow = BrowserWindow.fromWebContents(event.sender)
382 const openResult = ownerWindow
383 ? await dialog.showOpenDialog(ownerWindow, {
384 title: '导入风格文件夹',
385 buttonLabel: '导入',
386 properties: ['openDirectory']
387 })
388 : await dialog.showOpenDialog({
389 title: '导入风格文件夹',
390 buttonLabel: '导入',
391 properties: ['openDirectory']
392 })
393 if (openResult.canceled || openResult.filePaths.length === 0) {
394 return { success: false, cancelled: true, id: '', source: 'custom' as const }
395 }
396 const result = await importStylePackageDirectory(openResult.filePaths[0])
397 return completeStylePackageImport(result)
398 })
399
400 ipcMain.handle('styles:exportPackageZip', async (event, payload) => {
401 const styleId = typeof payload?.styleId === 'string' ? payload.styleId.trim() : ''
402 if (!styleId) throw new Error('styleId 为空')
403 const detail = getStyleDetail(styleId)
404 const safeName = (detail.styleKey || detail.id).replace(/[^a-z0-9-]/gi, '-').toLowerCase()
405 const ownerWindow = BrowserWindow.fromWebContents(event.sender)
406 const saveResult = ownerWindow
407 ? await dialog.showSaveDialog(ownerWindow, {
408 title: '导出风格包',
409 defaultPath: safeName + '.zip',
410 filters: [{ name: 'Style ZIP', extensions: ['zip'] }]
411 })
412 : await dialog.showSaveDialog({
413 title: '导出风格包',
414 defaultPath: safeName + '.zip',
415 filters: [{ name: 'Style ZIP', extensions: ['zip'] }]
416 })
417 if (saveResult.canceled || !saveResult.filePath) {
418 return { success: false, canceled: true }
419 }
420 const outputPath = saveResult.filePath.toLowerCase().endsWith('.zip')
421 ? saveResult.filePath
422 : saveResult.filePath + '.zip'
423 const result = await exportStylePackageZip(styleId, outputPath)
424 return { success: true, canceled: false, ...result }
425 })
426
427 ipcMain.handle('styles:create', async (_event, payload) => {
428 const parsed = parseCreatePayload(payload)
429 let id = `style-${nanoidLower()}`
430 while (hasStyleSkill(id)) {
431 id = `style-${nanoidLower()}`
432 }
433 const result = await createStyleSkill({
434 ...parsed,
435 id
436 })
437 return { success: true, ...result }
438 })
439
440 ipcMain.handle('styles:update', async (_event, payload) => {
441 const parsed = parseUpdatePayload(payload)
442 const result = await updateStyleSkill(parsed)
443 return { success: true, ...result }
444 })
445
446 ipcMain.handle('styles:delete', async (_event, styleId: string) => {
447 const id = String(styleId || '').trim()
448 if (!id) return { success: false, deleted: false }
449 const result = await deleteStyleSkill(id)
450 return { success: result.deleted, deleted: result.deleted }
451 })
452 }
453
453 lines TYPESCRIPT