diff --git a/global.d.ts b/global.d.ts index da0c6fd..91431bb 100644 --- a/global.d.ts +++ b/global.d.ts @@ -81,6 +81,43 @@ declare global { declare interface Window { api: WindowApi; } + + type ThemeMode = 'dark' | 'light' | 'system'; + + // 弹窗类型定义 + interface CreateDialogProps { + winId?: string; + title?: string; + content: string; + confirmText?: string; + cancelText?: string; + isModal?: boolean; + onConfirm?: () => void; + onCancel?: () => void; + } + + interface CreateDialogueProps { + messages: DialogueMessageProps[]; + providerName: string; + selectedModel: string; + messageId: number; + conversationId: number; + } + + interface UniversalChunk { + isEnd: boolean; + result: string; + } + + interface DialogueBackStream { + messageId: number; + data: UniversalChunk & { isError?: boolean }; + } + + interface DialogueMessageProps { + role: DialogueMessageRole; + content: string; + } } declare module "@store/*"; @@ -100,4 +137,5 @@ declare module '@iconify/vue' { flip?: string rotate?: number }> -} \ No newline at end of file +} + diff --git a/package-lock.json b/package-lock.json index fd18193..07aeeef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,11 @@ "electron-log": "^5.4.3", "electron-squirrel-startup": "^1.0.1", "element-plus": "^2.12.0", + "js-base64": "^3.7.8", "js-cookie": "^3.0.5", "lodash-es": "^4.17.21", "log4js": "^6.9.1", + "openai": "^6.14.0", "pinia": "^2.3.1", "vue": "^3.5.22", "vue-i18n": "^11.1.9", @@ -7043,6 +7045,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -8532,6 +8540,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.14.0.tgz", + "integrity": "sha512-ZPD9MG5/sPpyGZ0idRoDK0P5MWEMuXe0Max/S55vuvoxqyEVkN94m9jSpE3YgNgz3WoESFvozs57dxWqAco31w==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/openapi-ts-request": { "version": "1.10.1", "resolved": "https://registry.npmmirror.com/openapi-ts-request/-/openapi-ts-request-1.10.1.tgz", diff --git a/package.json b/package.json index d124b1c..e3cba38 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,11 @@ "electron-log": "^5.4.3", "electron-squirrel-startup": "^1.0.1", "element-plus": "^2.12.0", + "js-base64": "^3.7.8", "js-cookie": "^3.0.5", "lodash-es": "^4.17.21", "log4js": "^6.9.1", + "openai": "^6.14.0", "pinia": "^2.3.1", "vue": "^3.5.22", "vue-i18n": "^11.1.9", diff --git a/src/common/constants.ts b/src/common/constants.ts index 56fc15e..297c68c 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -5,6 +5,7 @@ export enum IPC_EVENTS { WINDOW_MINIMIZE = 'window-minimize', WINDOW_MAXIMIZE = 'window-maximize', WINDOW_CLOSE = 'window-close', + IS_WINDOW_MAXIMIZED = 'is-window-maximized', APP_SET_FRAMELESS = 'app:set-frameless', TAB_CREATE = 'tab:create', TAB_LIST = 'tab:list', @@ -27,12 +28,29 @@ export enum IPC_EVENTS { CUSTOM_EVENT ='custom:event', TIME_UPDATE = 'time:update', RENDERER_IS_READY = 'renderer-ready', + SHOW_CONTEXT_MENU = 'show-context-menu', + START_A_DIALOGUE = 'start-a-dialogue', + + // 打开窗口 + OPEN_WINDOW = 'open-window', // 发送日志 LOG_DEBUG = 'log-debug', LOG_INFO = 'log-info', LOG_WARN = 'log-warn', LOG_ERROR = 'log-error', + + // 设置 + CONFIG_UPDATED = 'config-updated', + SET_CONFIG = 'set-config', + GET_CONFIG = 'get-config', + UPDATE_CONFIG = 'update-config', + + // 主题 + SET_THEME_MODE = 'set-theme-mode', + GET_THEME_MODE = 'get-theme-mode', + IS_DARK_THEME = 'is-dark-theme', + THEME_MODE_UPDATED = 'theme-mode-updated', } export const MAIN_WIN_SIZE = { @@ -42,7 +60,48 @@ export const MAIN_WIN_SIZE = { minHeight: 900, } as const +export enum WINDOW_NAMES { + MAIN = 'main', + SETTING = 'setting', + DIALOG = 'dialog', +} +export enum CONFIG_KEYS { + THEME_MODE = 'themeMode', + PRIMARY_COLOR = 'primaryColor', + LANGUAGE = 'language', + FONT_SIZE = 'fontSize', + MINIMIZE_TO_TRAY = 'minimizeToTray', + PROVIDER = 'provider', + DEFAULT_MODEL = 'defaultModel', +} +export enum MENU_IDS { + CONVERSATION_ITEM = 'conversation-item', + CONVERSATION_LIST = 'conversation-list', + MESSAGE_ITEM = 'message-item', +} +export enum CONVERSATION_ITEM_MENU_IDS { + PIN = 'pin', + RENAME = 'rename', + DEL = 'del', +} +export enum CONVERSATION_LIST_MENU_IDS { + NEW_CONVERSATION = 'newConversation', + SORT_BY = 'sortBy', + SORT_BY_CREATE_TIME = 'sortByCreateTime', + SORT_BY_UPDATE_TIME = 'sortByUpdateTime', + SORT_BY_NAME = 'sortByName', + SORT_BY_MODEL = 'sortByModel', + SORT_ASCENDING = 'sortAscending', + SORT_DESCENDING = 'sortDescending', + BATCH_OPERATIONS = 'batchOperations', +} + +export enum MESSAGE_ITEM_MENU_IDS { + COPY = 'copy', + DELETE = 'delete', + SELECT = 'select', +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..c9bfa6a --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,38 @@ +import { WINDOW_NAMES, CONFIG_KEYS } from './constants'; + +export type WindowNames = `${WINDOW_NAMES}`; +export type ConfigKeys = `${CONFIG_KEYS}`; + +export interface IConfig { + // 主题模式配置 + [CONFIG_KEYS.THEME_MODE]: ThemeMode; + // 高亮色 + [CONFIG_KEYS.PRIMARY_COLOR]: string; + // 语言 + [CONFIG_KEYS.LANGUAGE]: 'zh' | 'en'; + // 字体大小 + [CONFIG_KEYS.FONT_SIZE]: number; + // 关闭时最小化到托盘 + [CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean; + // provider 配置 JSON + [CONFIG_KEYS.PROVIDER]?: string; + // 默认模型 + [CONFIG_KEYS.DEFAULT_MODEL]?: string | null; +} + +export interface Provider { + id: number; + name: string; + visible?: boolean; + title?: string; + type?: 'OpenAI'; + openAISetting?: string; + createdAt: number; + updatedAt: number; + models: string[]; +} + +export interface OpenAISetting { + baseURL?: string; + apiKey?: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 0000000..1f27395 --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,94 @@ +import type { OpenAISetting } from './types' +import { encode, decode } from 'js-base64' +/** + * 防抖函数 + * @param fn 需要执行的函数 + * @param delay 延迟时间(毫秒) + * @returns 防抖处理后的函数 + */ +export function debounce any>(fn: T, delay: number): (...args: Parameters) => void { + let timer: NodeJS.Timeout | null = null; + return function (this: any, ...args: Parameters) { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; +} + +/** + * 节流函数 + * @param fn 需要执行的函数 + * @param interval 间隔时间(毫秒) + * @returns 节流处理后的函数 + */ +export function throttle any>(fn: T, interval: number): (...args: Parameters) => void { + let lastTime = 0; + return function (this: any, ...args: Parameters) { + const now = Date.now(); + if (now - lastTime >= interval) { + fn.apply(this, args); + lastTime = now; + } + }; +} + +export function cloneDeep(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => cloneDeep(item)) as T; + } + + const clone = Object.assign({}, obj); + for (const key in clone) { + if (Object.prototype.hasOwnProperty.call(clone, key)) { + clone[key] = cloneDeep(clone[key]); + } + } + return clone; +} + +export function simpleCloneDeep(obj: T): T { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (error) { + console.error('simpleCloneDeep failed:', error); + return obj; + } +} + +export function stringifyOpenAISetting(setting: OpenAISetting) { + try { + return encode(JSON.stringify(setting)); + } catch (error) { + console.error('stringifyOpenAISetting failed:', error); + return ''; + } +} + +export function parseOpenAISetting(setting: string): OpenAISetting { + try { + return JSON.parse(decode(setting)); + } catch (error) { + console.error('parseOpenAISetting failed:', error); + return {} as OpenAISetting; + } +} + +export function uniqueByKey>(arr: T[], key: keyof T): T[] { + const seen = new Map(); + + return arr.filter(item => { + const keyValue = item[key]; + if (seen.has(keyValue)) { + return false; + } + seen.set(keyValue, true); + return true; + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index eb809ae..68cd996 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,115 +1,47 @@ -import { app, BrowserWindow, ipcMain } from "electron"; -import path from "node:path"; -import started from "electron-squirrel-startup"; -import { logger } from '@modules/logger' -import "@modules/window-size"; +import { app, BrowserWindow } from 'electron' +import { setupWindows } from '@main/wins' +import started from 'electron-squirrel-startup' +import configManager from '@modules/config-service' +import logManager from '@modules/logger' +import { CONFIG_KEYS } from '@common/constants' +// Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { app.quit(); } -class AppMain { - private mainWindow: BrowserWindow | null = null - private readonly isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL +process.on('uncaughtException', (err) => { + logManager.error('uncaughtException', err); +}); - init() { - this.registerLifecycle() - this.registerAppIPC() - // this.registerLogIPC() - } +process.on('unhandledRejection', (reason, promise) => { + logManager.error('unhandledRejection', reason, promise); +}); - private createWindow(options?: { frameless?: boolean; route?: string }): BrowserWindow { - const frameless = !!options?.frameless - const win = new BrowserWindow({ - width: 1440, - height: 900, - autoHideMenuBar: true, - frame: frameless ? false : true, - // @ts-ignore - windowButtonVisibility: frameless ? false : true, - resizable: true, - maximizable: true, - minimizable: true, - titleBarStyle: 'hidden', - title: 'NIANXX', - webPreferences: { - devTools: this.isDev, - nodeIntegration: false, - contextIsolation: true, // 同时启动上下文隔离 - sandbox: true, // 启动沙箱模式 - preload: path.join(__dirname, "preload.js"), - }, - }) +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +// Some APIs can only be used after this event occurs. +app.whenReady().then(() => { + setupWindows(); - this.loadEntry(win, options?.route) - if (this.isDev) win.webContents.openDevTools() - this.mainWindow = win - return win - } - - private loadEntry(win: BrowserWindow, route?: string) { - // @ts-ignore - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - const target = route ? `${MAIN_WINDOW_VITE_DEV_SERVER_URL}${route}` : MAIN_WINDOW_VITE_DEV_SERVER_URL - win.loadURL(target) - } else { - win.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)) + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + setupWindows(); } + }); +}); - // 暴露安全 API 示例:通过 IPC 处理文件读取 - ipcMain.handle('read-file', async (event, filePath) => { - const fs = require('fs'); - return fs.promises.readFile(filePath, 'utf-8'); // 主进程处理敏感操作 - }); +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on('window-all-closed', () => { + if (process.platform !== 'darwin' && !configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY)) { + logManager.info('app closing due to all windows being closed'); + app.quit(); } +}); - private registerLifecycle() { - app.on("ready", () => { - this.createWindow({ frameless: false, route: '/login' }) - }) - app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit() - }) - app.on("activate", () => { - if (!BrowserWindow.getAllWindows().length) this.createWindow({ frameless: false, route: '/login' }) - }) - } - - private registerAppIPC() { - ipcMain.handle('app:set-frameless', async (event, route?: string) => { - const old = BrowserWindow.fromWebContents(event.sender) - const win = this.createWindow({ frameless: true, route }) - if (old && !old.isDestroyed()) old.close() - return true - }) - } - - private registerLogIPC() { - ipcMain.handle('log-to-main', (_e, logLevel: string, message: string) => { - switch(logLevel) { - case 'trace': - logger.trace(message) - break - case 'debug': - logger.debug(message) - break - case 'info': - logger.info(message) - break - case 'warn': - logger.warn(message) - break - case 'error': - logger.error(message) - break - default: - logger.info(message) - break - } - }) - } -} - -new AppMain().init() - - \ No newline at end of file +// In this file you can include the rest of your app's specific main process +// code. You can also put them in separate files and import them here. diff --git a/src/main/modules/config-service/index.ts b/src/main/modules/config-service/index.ts new file mode 100644 index 0000000..04e14be --- /dev/null +++ b/src/main/modules/config-service/index.ts @@ -0,0 +1,126 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import type { ConfigKeys, IConfig } from '@common/types' +import { CONFIG_KEYS, IPC_EVENTS } from '@common/constants' +import { debounce, simpleCloneDeep } from '@common/utils' +import * as fs from 'fs' +import * as path from 'path' + +import logManager from '@modules/logger' + +const DEFAULT_CONFIG: IConfig = { + [CONFIG_KEYS.THEME_MODE]: 'system', + [CONFIG_KEYS.PRIMARY_COLOR]: '#BB5BE7', + [CONFIG_KEYS.LANGUAGE]: 'zh', + [CONFIG_KEYS.FONT_SIZE]: 14, + [CONFIG_KEYS.MINIMIZE_TO_TRAY]: false, + [CONFIG_KEYS.PROVIDER]: '', + [CONFIG_KEYS.DEFAULT_MODEL]: null, +} + +export class ConfigService { + private static _instance: ConfigService; + private _config: IConfig; + private _configPath: string; + private _defaultConfig: IConfig = DEFAULT_CONFIG; + + private _listeners: Array<(config: IConfig) => void> = []; + + + private constructor() { + // 获取配置文件路径 + this._configPath = path.join(app.getPath('userData'), 'config.json'); + // 加载配置 + this._config = this._loadConfig(); + // 设置 IPC 事件 + this._setupIpcEvents(); + logManager.info('ConfigService initialized successfully.') + } + + private _setupIpcEvents() { + const duration = 200; + const handelUpdate = debounce((val) => this.update(val), duration); + + ipcMain.handle(IPC_EVENTS.GET_CONFIG, (_, key) => this.get(key)); + ipcMain.on(IPC_EVENTS.SET_CONFIG, (_, key, val) => this.set(key, val)); + ipcMain.on(IPC_EVENTS.UPDATE_CONFIG, (_, updates) => handelUpdate(updates)); + } + + public static getInstance(): ConfigService { + if (!this._instance) { + this._instance = new ConfigService(); + } + return this._instance; + } + + private _loadConfig(): IConfig { + try { + if (fs.existsSync(this._configPath)) { + const configContent = fs.readFileSync(this._configPath, 'utf-8'); + const config = { ...this._defaultConfig, ...JSON.parse(configContent) }; + logManager.info('Config loaded successfully from:', this._configPath); + return config; + } + } catch (error) { + logManager.error('Failed to load config:', error); + } + return { ...this._defaultConfig }; + } + + private _saveConfig(): void { + try { + // 确保目录存在 + fs.mkdirSync(path.dirname(this._configPath), { recursive: true }); + // 写入 + fs.writeFileSync(this._configPath, JSON.stringify(this._config, null, 2), 'utf-8'); + // 通知监听者 + this._notifyListeners(); + + logManager.info('Config saved successfully to:', this._configPath); + } catch (error) { + logManager.error('Failed to save config:', error); + } + } + + private _notifyListeners(): void { + BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._config)); + this._listeners.forEach(listener => listener({ ...this._config })); + } + + public getConfig(): IConfig { + return simpleCloneDeep(this._config); + } + + public get(key: ConfigKeys): T { + return this._config[key] as T + } + + public set(key: ConfigKeys, value: unknown, autoSave: boolean = true): void { + if (!(key in this._config)) return; + const oldValue = this._config[key]; + if (oldValue === value) return; + this._config[key] = value as never; + logManager.debug(`Config set: ${key} = ${value}`); + autoSave && this._saveConfig(); + } + + public update(updates: Partial, autoSave: boolean = true): void { + this._config = { ...this._config, ...updates }; + autoSave && this._saveConfig(); + } + + public resetToDefault(): void { + this._config = { ...this._defaultConfig }; + logManager.info('Config reset to default.'); + this._saveConfig(); + } + + public onConfigChange(listener: ((config: IConfig) => void)): () => void { + this._listeners.push(listener); + + return () => this._listeners = this._listeners.filter(l => l !== listener); + } + +} + +export const configManager = ConfigService.getInstance(); +export default configManager; diff --git a/src/main/modules/menu-service/index.ts b/src/main/modules/menu-service/index.ts new file mode 100644 index 0000000..79f39a0 --- /dev/null +++ b/src/main/modules/menu-service/index.ts @@ -0,0 +1,123 @@ +import { ipcMain, Menu, type MenuItemConstructorOptions } from 'electron'; +import { CONFIG_KEYS, IPC_EVENTS } from '@common/constants'; +import { cloneDeep } from '@common/utils'; +import { createTranslator } from '@main/utils' +import logManager from '@modules/logger' +import configManager from '@modules/config-service' + +let t: ReturnType = createTranslator(); + +class MenuService { + private static _instance: MenuService; + private _menuTemplates: Map = new Map(); + private _currentMenu?: Menu = void 0; + + private constructor() { + this._setupIpcListener(); + this._setupLanguageChangeListener(); + logManager.info('MenuService initialized successfully.'); + } + + private _setupIpcListener() { + ipcMain.handle(IPC_EVENTS.SHOW_CONTEXT_MENU, (_, menuId, dynamicOptions?: string) => new Promise((resolve) => this.showMenu(menuId, () => resolve(true), dynamicOptions))) + } + + private _setupLanguageChangeListener() { + configManager.onConfigChange((config)=>{ + if(!config[CONFIG_KEYS.LANGUAGE]) return; + + t = createTranslator() + }) + } + + public static getInstance() { + if (!this._instance) + this._instance = new MenuService(); + return this._instance; + } + + public register(menuId: string, template: MenuItemConstructorOptions[]) { + this._menuTemplates.set(menuId, template); + return menuId; + } + + public showMenu(menuId: string, onClose?: () => void, dynamicOptions?: string) { + if (this._currentMenu) return; + + const template = cloneDeep(this._menuTemplates.get(menuId)); + + if (!template) { + logManager.warn(`Menu ${menuId} not found.`); + onClose?.(); + return; + } + + let _dynamicOptions: Array & { id: string }> = []; + try { + _dynamicOptions = Array.isArray(dynamicOptions) ? dynamicOptions : JSON.parse(dynamicOptions ?? '[]'); + } catch (error) { + logManager.error(`Failed to parse dynamicOptions for menu ${menuId}: ${error}`); + } + + const translationItem = (item: MenuItemConstructorOptions): MenuItemConstructorOptions => { + if (item.submenu) { + return { + ...item, + label: t(item?.label) ?? void 0, + submenu: (item.submenu as MenuItemConstructorOptions[])?.map((item: MenuItemConstructorOptions) => translationItem(item)) + } + } + return { + ...item, + label: t(item?.label) ?? void 0 + } + } + const localizedTemplate = template.map(item => { + if (!Array.isArray(_dynamicOptions) || !_dynamicOptions.length) { + return translationItem(item); + } + + const dynamicItem = _dynamicOptions.find(_item => _item.id === item.id); + + if (dynamicItem) { + const mergedItem = { ...item, ...dynamicItem }; + return translationItem(mergedItem); + } + + if (item.submenu) { + return translationItem({ + ...item, + submenu: (item.submenu as MenuItemConstructorOptions[])?.map((__item: MenuItemConstructorOptions) => { + const dynamicItem = _dynamicOptions.find(_item => _item.id === __item.id); + return { ...__item, ...dynamicItem }; + }) + }) + } + + return translationItem(item); + }) + + const menu = Menu.buildFromTemplate(localizedTemplate); + + this._currentMenu = menu; + + menu.popup({ + callback: () => { + this._currentMenu = void 0; + onClose?.(); + } + }) + } + + public destroyMenu(menuId: string) { + this._menuTemplates.delete(menuId); + } + + public destroyed() { + this._menuTemplates.clear(); + this._currentMenu = void 0; + } +} + +export const menuManager = MenuService.getInstance(); +export default menuManager; diff --git a/src/main/modules/theme-service/index.ts b/src/main/modules/theme-service/index.ts new file mode 100644 index 0000000..a5290dd --- /dev/null +++ b/src/main/modules/theme-service/index.ts @@ -0,0 +1,61 @@ +import { BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { configManager } from '@modules/config-service' +import { logManager } from '@modules/logger' +import { IPC_EVENTS, CONFIG_KEYS } from '@common/constants' + +class ThemeService { + private static _instance: ThemeService; + private _isDark: boolean = nativeTheme.shouldUseDarkColors; + + constructor() { + const themeMode = configManager.get(CONFIG_KEYS.THEME_MODE); + if (themeMode) { + nativeTheme.themeSource = themeMode; + this._isDark = nativeTheme.shouldUseDarkColors; + } + this._setupIpcEvent(); + logManager.info('ThemeService initialized successfully.'); + } + + private _setupIpcEvent() { + ipcMain.handle(IPC_EVENTS.SET_THEME_MODE, (_e, mode: ThemeMode) => { + nativeTheme.themeSource = mode; + configManager.set(CONFIG_KEYS.THEME_MODE, mode); + return nativeTheme.shouldUseDarkColors; + }); + + ipcMain.handle(IPC_EVENTS.GET_THEME_MODE, () => { + return nativeTheme.themeSource; + }); + + ipcMain.handle(IPC_EVENTS.IS_DARK_THEME, () => { + return nativeTheme.shouldUseDarkColors; + }); + + nativeTheme.on('updated', () => { + this._isDark = nativeTheme.shouldUseDarkColors; + + BrowserWindow.getAllWindows().forEach(win => + win.webContents.send(IPC_EVENTS.THEME_MODE_UPDATED, this._isDark) + ); + }); + } + + public static getInstance() { + if (!this._instance) { + this._instance = new ThemeService(); + } + return this._instance; + } + + public get isDark() { + return this._isDark; + } + + public get themeMode() { + return nativeTheme.themeSource; + } +} + +export const themeManager = ThemeService.getInstance(); +export default themeManager; diff --git a/src/main/modules/tray-service/index.ts b/src/main/modules/tray-service/index.ts new file mode 100644 index 0000000..6b477f7 --- /dev/null +++ b/src/main/modules/tray-service/index.ts @@ -0,0 +1,100 @@ +import { Tray, Menu, ipcMain, app } from 'electron' +import { createTranslator, createLogo } from '@main/utils' +import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES, MAIN_WIN_SIZE } from '@common/constants' + +import logManager from '@modules/logger' +// TODO: shortcutManager +import windowManager from '@modules/window-service' +import configManager from '@modules/config-service' + +let t: ReturnType = createTranslator(); + +class TrayService { + private static _instance: TrayService; + private _tray: Tray | null = null; + private _removeLanguageListener?: () => void; + + private _setupLanguageChangeListener() { + this._removeLanguageListener = configManager.onConfigChange((config) => { + if (!config[CONFIG_KEYS.LANGUAGE]) return; + + // 切换语言后,重新创建翻译器 + t = createTranslator(); + + + if (this._tray) { + this._updateTray(); + } + }) + } + + private _updateTray() { + if (!this._tray) { + this._tray = new Tray(createLogo()); + } + + const showWindow = () => { + const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); + + if (mainWindow && !mainWindow?.isDestroyed() && mainWindow?.isVisible() && !mainWindow?.isFocused()) { + return mainWindow.focus(); + } + + if (mainWindow?.isMinimized()) { + return mainWindow?.restore(); + } + + if (mainWindow?.isVisible() && mainWindow?.isFocused()) return; + + windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); + } + + this._tray.setToolTip(t('tray.tooltip') ?? 'Diona Application'); + + // TODO: 依赖快捷键Service + this._tray.setContextMenu(Menu.buildFromTemplate([ + { label: t('tray.showWindow'), accelerator: 'CmdOrCtrl+N', click: showWindow }, + { type: 'separator' }, + { label: t('settings.title'), click: () => ipcMain.emit(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`) }, + { role: 'quit', label: t('tray.exit') } + ])); + + this._tray.removeAllListeners('click'); + this._tray.on('click', showWindow); + } + + private constructor() { + this._setupLanguageChangeListener(); + logManager.info('TrayService initialized successfully.'); + } + + public static getInstance() { + if (!this._instance) { + this._instance = new TrayService(); + } + return this._instance; + } + + public create() { + if (this._tray) return; + this._updateTray(); + app.on('quit', () => { + this.destroy(); + //TODO: 移除快捷键 + }) + } + + public destroy() { + this._tray?.destroy(); + this._tray = null; + //TODO: 移除快捷键 + if (this._removeLanguageListener) { + this._removeLanguageListener(); + this._removeLanguageListener = void 0; + } + } +} + +export const trayManager = TrayService.getInstance(); +export default trayManager; + diff --git a/src/main/modules/tray/index.ts b/src/main/modules/tray/index.ts deleted file mode 100644 index f87ca14..0000000 --- a/src/main/modules/tray/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 创建系统托盘 -import { Tray, Menu } from 'electron' -import path from 'path' - -const createTray = (app: Electron.App, win: Electron.BrowserWindow) => { - let tray = new Tray(path.join(__dirname, '../public/favicon.ico')) - - tray.setToolTip('示例平台') // 鼠标放在托盘图标上的提示信息 - - tray.on('click', (e) => { - if (e.shiftKey) { - app.quit() - } else { - win.show() - } - }) - - tray.setContextMenu( - Menu.buildFromTemplate([ - { - label: '退出', - click: () => { - // 先把用户的登录状态和用户的登录信息给清楚掉,再退出 - app.quit() - } - } - ]) - ) -} - -module.exports = createTray diff --git a/src/main/modules/window-service/index.ts b/src/main/modules/window-service/index.ts new file mode 100644 index 0000000..b50f650 --- /dev/null +++ b/src/main/modules/window-service/index.ts @@ -0,0 +1,300 @@ +import type { WindowNames } from '@common/types' + +import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES } from '@common/constants' +import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain, IpcMainInvokeEvent, WebContentsView, type IpcMainEvent } from 'electron' +import { debounce } from '@common/utils' +import { createLogo } from '@main/utils' + +import logManager from '@modules/logger' +import configManager from '@modules/config-service' +import themeManager from '@modules/theme-service' +import path from 'node:path'; + +interface WindowState { + instance: BrowserWindow | void; + isHidden: boolean; + onCreate: ((window: BrowserWindow) => void)[]; + onClosed: ((window: BrowserWindow) => void)[]; +} + +interface SizeOptions { + width: number; // 窗口宽度 + height: number; // 窗口高度 + maxWidth?: number; // 窗口最大宽度,可选 + maxHeight?: number; // 窗口最大高度,可选 + minWidth?: number; // 窗口最小宽度,可选 + minHeight?: number; // 窗口最小高度,可选 +} + +const SHARED_WINDOW_OPTIONS = { + titleBarStyle: 'hidden', + show: false, + title: 'Diona', + darkTheme: themeManager.isDark, + backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF', + webPreferences: { + nodeIntegration: false, // 禁用 Node.js 集成,提高安全性 + contextIsolation: true, // 启用上下文隔离,防止渲染进程访问主进程 API + sandbox: true, // 启用沙箱模式,进一步增强安全性 + backgroundThrottling: false, + preload: path.join(__dirname, 'preload.js'), + }, +} as BrowserWindowConstructorOptions; + +class WindowService { + private static _instance: WindowService; + private _logo = createLogo(); + + private _winStates: Record = { + main: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + setting: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + dialog: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + } + + private constructor() { + this._setupIpcEvents(); + logManager.info('WindowService initialized successfully.'); + } + + private _isReallyClose(windowName: WindowNames | void) { + if (windowName === WINDOW_NAMES.MAIN) return configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY) === false; + if (windowName === WINDOW_NAMES.SETTING) return false; + + return true; + } + + private _setupIpcEvents() { + const handleCloseWindow = (e: IpcMainEvent) => { + const target = BrowserWindow.fromWebContents(e.sender); + const winName = this.getName(target); + + this.close(target, this._isReallyClose(winName)); + } + + const handleMinimizeWindow = (e: IpcMainEvent) => { + BrowserWindow.fromWebContents(e.sender)?.minimize(); + } + + const handleMaximizeWindow = (e: IpcMainEvent) => { + this.toggleMax(BrowserWindow.fromWebContents(e.sender)); + } + + const handleIsWindowMaximized = (e: IpcMainInvokeEvent) => { + return BrowserWindow.fromWebContents(e.sender)?.isMaximized() ?? false; + } + + ipcMain.on(IPC_EVENTS.WINDOW_CLOSE, handleCloseWindow); + ipcMain.on(IPC_EVENTS.WINDOW_MINIMIZE, handleMinimizeWindow); + ipcMain.on(IPC_EVENTS.WINDOW_MAXIMIZE, handleMaximizeWindow); + ipcMain.handle(IPC_EVENTS.IS_WINDOW_MAXIMIZED, handleIsWindowMaximized); + } + + public static getInstance(): WindowService { + if (!this._instance) { + this._instance = new WindowService(); + } + return this._instance; + } + + public create(name: WindowNames, size: SizeOptions, moreOpts?: BrowserWindowConstructorOptions) { + if (this.get(name)) return; + const isHiddenWin = this._isHiddenWin(name); + let window = this._createWinInstance(name, { ...size, ...moreOpts }); + + !isHiddenWin && this + ._setupWinLifecycle(window, name) + ._loadWindowTemplate(window, name) + + this._listenWinReady({ + win: window, + isHiddenWin, + size + }) + + + if (!isHiddenWin) { + this._winStates[name].instance = window; + this._winStates[name].onCreate.forEach(callback => callback(window)); + } + + if (isHiddenWin) { + this._winStates[name].isHidden = false; + logManager.info(`Hidden window show: ${name}`) + } + + return window; + } + + private _setupWinLifecycle(window: BrowserWindow, name: WindowNames) { + const updateWinStatus = debounce(() => !window?.isDestroyed() + && window?.webContents?.send(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', window?.isMaximized()), 80); + window.once('closed', () => { + this._winStates[name].onClosed.forEach(callback => callback(window)); + window?.destroy(); + window?.removeListener('resize', updateWinStatus); + this._winStates[name].instance = void 0; + this._winStates[name].isHidden = false; + logManager.info(`Window closed: ${name}`); + }); + window.on('resize', updateWinStatus) + return this; + } + + private _listenWinReady(params: { + win: BrowserWindow, + isHiddenWin: boolean, + size: SizeOptions, + }) { + const onReady = () => { + params.win?.once('show', () => setTimeout(() => this._applySizeConstraints(params.win, params.size), 2)); + + params.win?.show(); + } + + if (!params.isHiddenWin) { + const loadingHandler = this._addLoadingView(params.win, params.size); + loadingHandler?.(onReady) + } else { + onReady(); + } + } + + private _addLoadingView(window: BrowserWindow, size: SizeOptions) { + let loadingView: WebContentsView | void = new WebContentsView(); + let rendererIsReady = false; + + window.contentView?.addChildView(loadingView); + loadingView.setBounds({ + x: 0, + y: 0, + width: size.width, + height: size.height, + }); + loadingView.webContents.loadFile(path.join(__dirname, 'loading.html')); + + const onRendererIsReady = (e: IpcMainEvent) => { + if ((e.sender !== window?.webContents) || rendererIsReady) return; + rendererIsReady = true; + window.contentView.removeChildView(loadingView as WebContentsView); + ipcMain.removeListener(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady); + loadingView = void 0; + } + ipcMain.on(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady); + + return (cb: () => void) => loadingView?.webContents.once('dom-ready', () => { + loadingView?.webContents.insertCSS(`body { + background-color: ${themeManager.isDark ? '#2C2C2C' : '#FFFFFF'} !important; + --stop-color-start: ${themeManager.isDark ? '#A0A0A0' : '#7F7F7F'} !important; + --stop-color-end: ${themeManager.isDark ? '#A0A0A0' : '#7F7F7F'} !important; + }`); + cb(); + }) + + } + + private _applySizeConstraints(win: BrowserWindow, size: SizeOptions) { + if (size.maxHeight && size.maxWidth) { + win.setMaximumSize(size.maxWidth, size.maxHeight); + } + if (size.minHeight && size.minWidth) { + win.setMinimumSize(size.minWidth, size.minHeight); + } + } + + private _loadWindowTemplate(window: BrowserWindow, name: WindowNames) { + // 检查是否存在开发服务器 URL,若存在则表示处于开发环境 + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + return window.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${'/html/' + (name === 'main' ? '' : name)}`); + } + window.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/html/${name === 'main' ? 'index' : name}.html`)); + } + + + private _handleCloseWindowState(target: BrowserWindow, really: boolean) { + const name = this.getName(target) as WindowNames; + + if (name) { + if (!really) this._winStates[name].isHidden = true; + else this._winStates[name].instance = void 0; + } + + setTimeout(() => { + target[really ? 'close' : 'hide']?.(); + this._checkAndCloseAllWinodws(); + }, 210) + } + + private _checkAndCloseAllWinodws() { + if (!this._winStates[WINDOW_NAMES.MAIN].instance || this._winStates[WINDOW_NAMES.MAIN].instance?.isDestroyed()) + return Object.values(this._winStates).forEach(win => win?.instance?.close()); + + const minimizeToTray = configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY); + if (!minimizeToTray && !this.get(WINDOW_NAMES.MAIN)?.isVisible()) + return Object.values(this._winStates).forEach(win => !win?.instance?.isVisible() && win?.instance?.close()); + } + + private _isHiddenWin(name: WindowNames) { + return this._winStates[name] && this._winStates[name].isHidden; + } + + private _createWinInstance(name: WindowNames, opts?: BrowserWindowConstructorOptions) { + return this._isHiddenWin(name) + ? this._winStates[name].instance as BrowserWindow + : new BrowserWindow({ + ...SHARED_WINDOW_OPTIONS, + icon: this._logo, + ...opts, + }); + } + + public focus(target: BrowserWindow | void | null) { + if (!target) return; + const name = this.getName(target); + if (target?.isMaximized()) { + target?.restore(); + logManager.debug(`Window ${name} restored and focused`); + } else { + logManager.debug(`Window ${name} focused`); + } + + target?.focus(); + } + + public close(target: BrowserWindow | void | null, really: boolean = true) { + if (!target) return; + + const name = this.getName(target); + logManager.info(`Close window: ${name}, really: ${really}`); + this._handleCloseWindowState(target, really); + } + + public toggleMax(target: BrowserWindow | void | null) { + if (!target) return; + target.isMaximized() ? target.unmaximize() : target.maximize(); + } + + public getName(target: BrowserWindow | null | void): WindowNames | void { + if (!target) return; + for (const [name, win] of Object.entries(this._winStates) as [WindowNames, { instance: BrowserWindow | void } | void][]) { + if (win?.instance === target) return name; + } + } + + public get(name: WindowNames) { + if (this._winStates[name].isHidden) return void 0; + return this._winStates[name].instance; + } + + public onWindowCreate(name: WindowNames, callback: (window: BrowserWindow) => void) { + this._winStates[name].onCreate.push(callback); + } + + public onWindowClosed(name: WindowNames, callback: (window: BrowserWindow) => void) { + this._winStates[name].onClosed.push(callback); + } + +} + +export const windowManager = WindowService.getInstance(); + +export default windowManager; diff --git a/src/main/modules/window-size/index.ts b/src/main/modules/window-size/index.ts deleted file mode 100644 index a5a4bec..0000000 --- a/src/main/modules/window-size/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ipcMain, BrowserWindow } from 'electron' - -// 最小化 -ipcMain.on('window-min', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - win?.minimize() -}) - -// 最大化 -ipcMain.on('window-max', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - if (win?.isMaximized()) { - win.unmaximize() - } else { - win?.maximize() - } -}) - -// 关闭 -ipcMain.on('window-close', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - win?.close() -}) diff --git a/src/main/providers/BaseProvider.ts b/src/main/providers/BaseProvider.ts new file mode 100644 index 0000000..c6a7afa --- /dev/null +++ b/src/main/providers/BaseProvider.ts @@ -0,0 +1,4 @@ + +export abstract class BaseProvider { + abstract chat(messages: DialogueMessageProps[], modelName: string): Promise> +} diff --git a/src/main/providers/OpenAIProvider.ts b/src/main/providers/OpenAIProvider.ts new file mode 100644 index 0000000..136f526 --- /dev/null +++ b/src/main/providers/OpenAIProvider.ts @@ -0,0 +1,58 @@ +import { BaseProvider } from "./BaseProvider"; + +import OpenAI from "openai"; +import logManager from "@modules/logger" + + +function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): UniversalChunk { + const choice = chunk.choices[0]; + return { + isEnd: choice?.finish_reason === 'stop', + result: choice?.delta?.content ?? '', + } +} + +export class OpenAIProvider extends BaseProvider { + private client: OpenAI; + + constructor(apiKey: string, baseURL: string) { + super(); + this.client = new OpenAI({ apiKey, baseURL }); + } + + async chat(messages: DialogueMessageProps[], model: string): Promise> { + const startTime = Date.now(); + + const lastMessage = messages[messages.length - 1]; + + logManager.logApiRequest('chat.completions.create', { + model, + lastMessage: lastMessage?.content?.substring(0, 100) + (lastMessage?.content?.length > 100 ? '...' : ''), + messageCount: messages.length, + }, 'POST'); + + try { + const chunks = await this.client.chat.completions.create({ + model, + messages, + stream: true, + }); + + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { success: true }, 200, responseTime); + // return chunk; + return { + async *[Symbol.asyncIterator]() { + for await (const chunk of chunks) { + yield _transformChunk(chunk); + } + } + } + } catch (error) { + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); + throw error; + } + } +} + diff --git a/src/main/providers/index.ts b/src/main/providers/index.ts new file mode 100644 index 0000000..bb06828 --- /dev/null +++ b/src/main/providers/index.ts @@ -0,0 +1,123 @@ +import type { Provider } from "@common/types" +import { OpenAIProvider } from "./OpenAIProvider" +import { parseOpenAISetting } from '@common/utils' +import { decode } from 'js-base64' +import { configManager } from '@modules/config-service' +import { logManager } from '@modules/logger' +import { CONFIG_KEYS } from "@common/constants" + +const providers = [ + { + id: 1, + name: 'bigmodel', + title: '智谱AI', + models: ['glm-4.5-flash'], + openAISetting: { + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: process.env.BIGMODEL_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 2, + name: 'deepseek', + title: '深度求索 (DeepSeek)', + models: ['deepseek-chat'], + openAISetting: { + baseURL: 'https://api.deepseek.com/v1', + apiKey: process.env.DEEPSEEK_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 3, + name: 'siliconflow', + title: '硅基流动', + models: ['Qwen/Qwen3-8B', 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B'], + openAISetting: { + baseURL: 'https://api.siliconflow.cn/v1', + apiKey: process.env.SILICONFLOW_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 4, + name: 'qianfan', + title: '百度千帆', + models: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k'], + openAISetting: { + baseURL: 'https://qianfan.baidubce.com/v2', + apiKey: process.env.QIANFAN_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, +]; + +interface _Provider extends Omit { + openAISetting?: { + apiKey: string, + baseURL: string, + }; +} + +const _parseProvider = () => { + let result: Provider[] = []; + let isBase64Parsed = false; + const providerConfig = configManager.get(CONFIG_KEYS.PROVIDER); + + const mapCallback = (provider: Provider) => ({ + ...provider, + openAISetting: typeof provider.openAISetting === 'string' + ? parseOpenAISetting(provider.openAISetting ?? '') + : provider.openAISetting, + }) + + try { + result = JSON.parse(decode(providerConfig)) as Provider[]; + isBase64Parsed = true; + } catch (error) { + logManager.error(`parse base64 provider failed: ${error}`); + } + + if (!isBase64Parsed) try { + result = JSON.parse(providerConfig) as Provider[] + } catch (error) { + logManager.error(`parse provider failed: ${error}`); + } + + if (!result.length) return; + + return result.map(mapCallback) as _Provider[] +} + +const getProviderConfig = () => { + try { + return _parseProvider(); + } catch (error) { + logManager.error(`get provider config failed: ${error}`); + return null; + } +} + +export function createProvider(name: string) { + const providers = getProviderConfig(); + + if (!providers) { + throw new Error('provider config not found'); + } + + for (const provider of providers) { + if (provider.name === name) { + if (!provider.openAISetting?.apiKey || !provider.openAISetting?.baseURL) { + throw new Error('apiKey or baseURL not found'); + } + // TODO: visible + + return new OpenAIProvider(provider.openAISetting.apiKey, provider.openAISetting.baseURL); + } + } +} diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts new file mode 100644 index 0000000..4957d9d --- /dev/null +++ b/src/main/utils/index.ts @@ -0,0 +1,36 @@ +import { CONFIG_KEYS } from '@common/constants' +import logManager from '@modules/logger' +import configManager from '@modules/config-service' +import path from 'node:path' + +import en from '@locales/en.json' +import zh from '@locales/zh.json' + +type MessageSchema = typeof zh; +const messages: Record = { en, zh } + +export function createTranslator() { + return (key?: string) => { + if (!key) return void 0; + try { + const keys = key?.split('.'); + let result: any = messages[configManager.get(CONFIG_KEYS.LANGUAGE)]; + for (const _key of keys) { + result = result[_key]; + } + return result as string; + } catch (e) { + logManager.error('failed to translate key:', key, e); + return key + } + } +} + +let logo: string | void = void 0; +export function createLogo() { + if (logo != null) { + return logo; + } + logo = path.join(__dirname, 'logo.ico'); + return logo; +} diff --git a/src/main/wins/dialog.ts b/src/main/wins/dialog.ts new file mode 100644 index 0000000..13914e2 --- /dev/null +++ b/src/main/wins/dialog.ts @@ -0,0 +1,49 @@ +import { IPC_EVENTS, WINDOW_NAMES } from '@common/constants' +import { BrowserWindow, ipcMain } from 'electron' +import { windowManager } from '@modules/window-service' + +export function setupDialogWindow() { + let dialogWindow: BrowserWindow | void; + let params: CreateDialogProps | void + let feedback: string | void + + ipcMain.handle(WINDOW_NAMES.DIALOG + 'get-params',(e)=>{ + if(BrowserWindow.fromWebContents(e.sender) !== dialogWindow) return + return { + winId: e.sender.id, + ...params + } + }); + + ['confirm','cancel'].forEach(_feedback => { + ipcMain.on(WINDOW_NAMES.DIALOG + _feedback,(e,winId:number)=> { + if(e.sender.id !== winId) return + feedback = _feedback; + windowManager.close(BrowserWindow.fromWebContents(e.sender)); + }); + }); + + ipcMain.handle(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.DIALOG}`, (e, _params) => { + params = _params; + dialogWindow = windowManager.create( + WINDOW_NAMES.DIALOG, + { + width: 350, height: 200, + minWidth: 350, minHeight: 200, + maxWidth: 400, maxHeight: 300, + }, + { + parent: BrowserWindow.fromWebContents(e.sender) as BrowserWindow, + resizable: false + } + ); + + return new Promise((resolve) => dialogWindow?.on('closed', () => { + resolve(feedback); + feedback = void 0; + })) + }) + +} + +export default setupDialogWindow diff --git a/src/main/wins/index.ts b/src/main/wins/index.ts new file mode 100644 index 0000000..452cb34 --- /dev/null +++ b/src/main/wins/index.ts @@ -0,0 +1,9 @@ +import { setupMainWindow } from './main'; +import { setupDialogWindow } from './dialog'; +import { setupSetttingWindow } from './setting'; + +export function setupWindows() { + setupMainWindow(); + setupSetttingWindow(); + setupDialogWindow(); +} diff --git a/src/main/wins/main.ts b/src/main/wins/main.ts new file mode 100644 index 0000000..be7d702 --- /dev/null +++ b/src/main/wins/main.ts @@ -0,0 +1,151 @@ +import type { BrowserWindow } from 'electron' +import { ipcMain } from 'electron'; +import { WINDOW_NAMES, MAIN_WIN_SIZE, IPC_EVENTS, MENU_IDS, CONVERSATION_ITEM_MENU_IDS, CONVERSATION_LIST_MENU_IDS, MESSAGE_ITEM_MENU_IDS, CONFIG_KEYS } from '@common/constants' +import { createProvider } from '../providers' +import { windowManager } from '@modules/window-service' +import { menuManager } from '@modules/menu-service' +import { logManager } from '@modules/logger' +import { configManager } from '@modules/config-service' +import { trayManager } from '@modules/tray-service' + +const handleTray = (minimizeToTray: boolean) => { + if (minimizeToTray) { + trayManager.create(); + return; + } + + trayManager.destroy(); +} + +const registerMenus = (window: BrowserWindow) => { + + const conversationItemMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_ITEM}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_ITEM}`, id); + } + + menuManager.register(MENU_IDS.CONVERSATION_ITEM, [ + { + id: CONVERSATION_ITEM_MENU_IDS.PIN, + label: 'menu.conversation.pinConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.PIN) + }, + { + id: CONVERSATION_ITEM_MENU_IDS.RENAME, + label: 'menu.conversation.renameConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.RENAME) + }, + { + id: CONVERSATION_ITEM_MENU_IDS.DEL, + label: 'menu.conversation.delConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.DEL) + }, + ]) + + const conversationListMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_LIST}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_LIST}`, id); + } + + menuManager.register(MENU_IDS.CONVERSATION_LIST, [ + { + id: CONVERSATION_LIST_MENU_IDS.NEW_CONVERSATION, + label: 'menu.conversation.newConversation', + click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.NEW_CONVERSATION) + }, + { type: 'separator' }, + { + id: CONVERSATION_LIST_MENU_IDS.SORT_BY, label: 'menu.conversation.sortBy', submenu: [ + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_CREATE_TIME, label: 'menu.conversation.sortByCreateTime', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_CREATE_TIME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_UPDATE_TIME, label: 'menu.conversation.sortByUpdateTime', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_UPDATE_TIME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_NAME, label: 'menu.conversation.sortByName', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_NAME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_MODEL, label: 'menu.conversation.sortByModel', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_MODEL) }, + { type: 'separator' }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_ASCENDING, label: 'menu.conversation.sortAscending', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_ASCENDING) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_DESCENDING, label: 'menu.conversation.sortDescending', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_DESCENDING) }, + ] + }, + { + id: CONVERSATION_LIST_MENU_IDS.BATCH_OPERATIONS, + label: 'menu.conversation.batchOperations', + click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.BATCH_OPERATIONS) + } + ]) + + const messageItemMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.MESSAGE_ITEM}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.MESSAGE_ITEM}`, id); + } + + menuManager.register(MENU_IDS.MESSAGE_ITEM, [ + { + id: MESSAGE_ITEM_MENU_IDS.COPY, + label: 'menu.message.copyMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.COPY) + }, + { + id: MESSAGE_ITEM_MENU_IDS.SELECT, + label: 'menu.message.selectMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.SELECT) + }, + { type: 'separator' }, + { + id: MESSAGE_ITEM_MENU_IDS.DELETE, + label: 'menu.message.deleteMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.DELETE) + }, + ]) +} + +export function setupMainWindow() { + windowManager.onWindowCreate(WINDOW_NAMES.MAIN, (mainWindow) => { + let minimizeToTray = configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY); + configManager.onConfigChange((config) => { + if (minimizeToTray === config[CONFIG_KEYS.MINIMIZE_TO_TRAY]) return; + minimizeToTray = config[CONFIG_KEYS.MINIMIZE_TO_TRAY]; + handleTray(minimizeToTray); + }); + handleTray(minimizeToTray); + registerMenus(mainWindow); + }); + windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); + + ipcMain.on(IPC_EVENTS.START_A_DIALOGUE, async (_event, props: CreateDialogueProps) => { + const { providerName, messages, messageId, selectedModel } = props; + const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); + + if (!mainWindow) { + throw new Error('mainWindow not found'); + } + + try { + + const provider = createProvider(providerName); + const chunks = await provider?.chat(messages, selectedModel); + + if (!chunks) { + throw new Error('chunks or stream not found'); + } + + for await (const chunk of chunks) { + const chunkContent = { + messageId, + data: chunk + } + mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, chunkContent); + } + + } catch (error) { + const errorContent = { + messageId, + data: { + isEnd: true, + isError: true, + result: error instanceof Error ? error.message : String(error), + } + } + + mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, errorContent); + } + }) +} diff --git a/src/main/wins/setting.ts b/src/main/wins/setting.ts new file mode 100644 index 0000000..19e89e3 --- /dev/null +++ b/src/main/wins/setting.ts @@ -0,0 +1,20 @@ +import { IPC_EVENTS, WINDOW_NAMES } from '@common/constants' +import { ipcMain } from 'electron' +import { windowManager } from '@modules/window-service' + +export function setupSetttingWindow() { + ipcMain.on(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`, () => { + const settingWindow = windowManager.get(WINDOW_NAMES.SETTING); + if (settingWindow && !settingWindow.isDestroyed()) + return windowManager.focus(settingWindow); + + windowManager.create(WINDOW_NAMES.SETTING, { + width: 800, + height: 600, + minHeight: 600, + minWidth: 800, + }); + }) +} + +export default setupSetttingWindow; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts new file mode 100644 index 0000000..221a302 --- /dev/null +++ b/src/renderer/i18n.ts @@ -0,0 +1,44 @@ +import { createI18n, I18n, type I18nOptions } from 'vue-i18n'; + +const languages = ['zh', 'en'] as const; +type LanguageType = (typeof languages)[number]; + +async function createI18nInstance() { + const options: I18nOptions = { + legacy: false, + locale: 'zh', + fallbackLocale: 'zh', + messages: { + zh: await import('@locales/zh.json').then(m => m.default), + en: await import('@locales/en.json').then(m => m.default), + } + } + + const i18n = createI18n(options); + + return i18n +} + + +export const i18n = await createI18nInstance(); + +export async function setLanguage(lang:LanguageType,_i18n?:I18n){ + const __i18n = _i18n ?? i18n; + + if(__i18n.mode === 'legacy'){ + __i18n.global.locale = lang; + return; + } + + (__i18n.global.locale as unknown as Ref).value = lang; +} + +export function getLanguage(){ + if(i18n.mode === 'legacy'){ + return i18n.global.locale; + } + + return (i18n.global.locale as unknown as Ref).value; +} + +export default i18n; diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 3d4e2a9..a9ec512 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -4,6 +4,7 @@ import router from "./router"; import App from "./App.vue"; import ElementPlus from 'element-plus' import locale from 'element-plus/es/locale/lang/zh-cn' +import i18n from './i18n' // import './permission' // 样式文件隔离 @@ -30,6 +31,7 @@ app.use(createPinia()); app.use(router); app.use(ElementPlus, { locale }) app.use(components) +app.use(i18n) // 挂载应用到 DOM app.mount("#app"); diff --git a/tsconfig.app.json b/tsconfig.app.json index 7ba8be3..92c8515 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -33,6 +33,7 @@ "@constant/*": ["src/renderer/constant/*"], "@utils/*": ["src/renderer/utils/*"], "@common/*": ["src/common/*"], + "@main/*": ["src/main/*"], "@modules/*": ["src/main/modules/*"], "@locales/*": ["locales/*"], "@hooks/*": ["src/renderer/hooks/*"],