feat: 新增主进程功能

This commit is contained in:
DEV_DSW
2025-12-18 16:52:25 +08:00
parent 69a8e9472f
commit 6778c57a0e
25 changed files with 1503 additions and 161 deletions

View File

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

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

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

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

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

View File

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

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

View File

@@ -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()
})

View File

@@ -0,0 +1,4 @@
export abstract class BaseProvider {
abstract chat(messages: DialogueMessageProps[], modelName: string): Promise<AsyncIterable<UniversalChunk>>
}

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