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