feat: 新增主进程功能
This commit is contained in:
38
global.d.ts
vendored
38
global.d.ts
vendored
@@ -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/*";
|
||||
@@ -101,3 +138,4 @@ declare module '@iconify/vue' {
|
||||
rotate?: number
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
38
src/common/types.ts
Normal file
38
src/common/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
94
src/common/utils.ts
Normal file
94
src/common/utils.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { OpenAISetting } from './types'
|
||||
import { encode, decode } from 'js-base64'
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param fn 需要执行的函数
|
||||
* @param delay 延迟时间(毫秒)
|
||||
* @returns 防抖处理后的函数
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param fn 需要执行的函数
|
||||
* @param interval 间隔时间(毫秒)
|
||||
* @returns 节流处理后的函数
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(fn: T, interval: number): (...args: Parameters<T>) => void {
|
||||
let lastTime = 0;
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
if (now - lastTime >= interval) {
|
||||
fn.apply(this, args);
|
||||
lastTime = now;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDeep<T>(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<T>(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<T extends Record<string, any>>(arr: T[], key: keyof T): T[] {
|
||||
const seen = new Map<any, boolean>();
|
||||
|
||||
return arr.filter(item => {
|
||||
const keyValue = item[key];
|
||||
if (seen.has(keyValue)) {
|
||||
return false;
|
||||
}
|
||||
seen.set(keyValue, true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
138
src/main/main.ts
138
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()
|
||||
|
||||
|
||||
// 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.
|
||||
|
||||
126
src/main/modules/config-service/index.ts
Normal file
126
src/main/modules/config-service/index.ts
Normal file
@@ -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<T = any>(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<IConfig>, 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;
|
||||
123
src/main/modules/menu-service/index.ts
Normal file
123
src/main/modules/menu-service/index.ts
Normal file
@@ -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<typeof createTranslator> = createTranslator();
|
||||
|
||||
class MenuService {
|
||||
private static _instance: MenuService;
|
||||
private _menuTemplates: Map<string, MenuItemConstructorOptions[]> = 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<Partial<MenuItemConstructorOptions> & { 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;
|
||||
61
src/main/modules/theme-service/index.ts
Normal file
61
src/main/modules/theme-service/index.ts
Normal file
@@ -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;
|
||||
100
src/main/modules/tray-service/index.ts
Normal file
100
src/main/modules/tray-service/index.ts
Normal file
@@ -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<typeof createTranslator> = 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;
|
||||
|
||||
@@ -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
|
||||
300
src/main/modules/window-service/index.ts
Normal file
300
src/main/modules/window-service/index.ts
Normal file
@@ -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<WindowNames | string, WindowState> = {
|
||||
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;
|
||||
@@ -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()
|
||||
})
|
||||
4
src/main/providers/BaseProvider.ts
Normal file
4
src/main/providers/BaseProvider.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
export abstract class BaseProvider {
|
||||
abstract chat(messages: DialogueMessageProps[], modelName: string): Promise<AsyncIterable<UniversalChunk>>
|
||||
}
|
||||
58
src/main/providers/OpenAIProvider.ts
Normal file
58
src/main/providers/OpenAIProvider.ts
Normal file
@@ -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<AsyncIterable<UniversalChunk>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
123
src/main/providers/index.ts
Normal file
123
src/main/providers/index.ts
Normal file
@@ -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<Provider, 'openAISetting'> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/main/utils/index.ts
Normal file
36
src/main/utils/index.ts
Normal file
@@ -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<string, MessageSchema> = { 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;
|
||||
}
|
||||
49
src/main/wins/dialog.ts
Normal file
49
src/main/wins/dialog.ts
Normal file
@@ -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<string | void>((resolve) => dialogWindow?.on('closed', () => {
|
||||
resolve(feedback);
|
||||
feedback = void 0;
|
||||
}))
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export default setupDialogWindow
|
||||
9
src/main/wins/index.ts
Normal file
9
src/main/wins/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { setupMainWindow } from './main';
|
||||
import { setupDialogWindow } from './dialog';
|
||||
import { setupSetttingWindow } from './setting';
|
||||
|
||||
export function setupWindows() {
|
||||
setupMainWindow();
|
||||
setupSetttingWindow();
|
||||
setupDialogWindow();
|
||||
}
|
||||
151
src/main/wins/main.ts
Normal file
151
src/main/wins/main.ts
Normal file
@@ -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);
|
||||
}
|
||||
})
|
||||
}
|
||||
20
src/main/wins/setting.ts
Normal file
20
src/main/wins/setting.ts
Normal file
@@ -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;
|
||||
44
src/renderer/i18n.ts
Normal file
44
src/renderer/i18n.ts
Normal file
@@ -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<LanguageType>).value = lang;
|
||||
}
|
||||
|
||||
export function getLanguage(){
|
||||
if(i18n.mode === 'legacy'){
|
||||
return i18n.global.locale;
|
||||
}
|
||||
|
||||
return (i18n.global.locale as unknown as Ref<LanguageType>).value;
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
@@ -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");
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
Reference in New Issue
Block a user