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