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