diff --git a/env.d.ts b/env.d.ts deleted file mode 100644 index 4acc8d0..0000000 --- a/env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module "@store/*"; -declare module "@modules/*"; -declare module "@utils/*"; -declare module "@assets/images/*"; -declare module "@constant/*"; -declare module "@remixicon/vue"; -declare module "vue-router"; \ No newline at end of file diff --git a/global.d.ts b/global.d.ts index f4dacf2..ca97b9b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -32,6 +32,10 @@ declare global { params: [time: string] return: void } + [IPC_EVENTS.RENDERER_IS_READY]: { + params: [] + return: void + } [IPC_EVENTS.CUSTOM_EVENT]: { params: [message: string] return: void @@ -49,11 +53,12 @@ declare global { external: { open: (url: string) => void }, - window: { - minimize: () => void, - maximize: () => void, - close: () => void - }, + minimizeWindow: () => void, + maximizeWindow: () => void, + closeWindow: () => void, + onWindowMaximized: (callback: (isMaximized: boolean) => void) => void, + isWindowMaximized: () => Promise, + viewIsReady: () => void app: { setFrameless: (route?: string) => void }, @@ -69,10 +74,82 @@ declare global { on: (event: 'tab-updated' | 'tab-created' | 'tab-closed' | 'tab-switched', handler: (payload: any) => void) => void }, readFile: (filePath: string) => Promise<{success: boolean, data?: string, error?: string}>, - logToMain: (logLevel: string, message: string) => void, + logger: { + debug: (message: string, ...meta?: any[]) => void; + info: (message: string, ...meta?: any[]) => void; + warn: (message: string, ...meta?: any[]) => void; + error: (message: string, ...meta?: any[]) => void; + }, } declare interface Window { api: WindowApi; } + + type ThemeMode = 'dark' | 'light' | 'system'; + + // form 表单数据类型声明 + interface LoginForm { + username: string; + password: string; + randomStr: string; + code: string; + grant_type: string; + scope: string; + } + + // 弹窗类型定义 + 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/*"; +declare module "@service/*"; +declare module "@utils/*"; +declare module "@assets/images/*"; +declare module "@constant/*"; +declare module "@remixicon/vue"; +declare module "vue-router"; +declare module '@iconify/vue' { + import { DefineComponent } from 'vue' + export const Icon: DefineComponent<{ + icon: string + width?: string | number + height?: string | number + color?: string + flip?: string + rotate?: number + }> +} + diff --git a/html/dialog.html b/html/dialog.html new file mode 100644 index 0000000..f871326 --- /dev/null +++ b/html/dialog.html @@ -0,0 +1,16 @@ + + + + + NIANXX + + + + +
+ + + diff --git a/index.html b/html/index.html similarity index 75% rename from index.html rename to html/index.html index 65ac6d5..f141c3e 100644 --- a/index.html +++ b/html/index.html @@ -6,11 +6,11 @@
- + diff --git a/html/login.html b/html/login.html new file mode 100644 index 0000000..a08a57f --- /dev/null +++ b/html/login.html @@ -0,0 +1,16 @@ + + + + + NIANXX + + + + +
+ + + diff --git a/html/setting.html b/html/setting.html new file mode 100644 index 0000000..23e9991 --- /dev/null +++ b/html/setting.html @@ -0,0 +1,16 @@ + + + + + NIANXX + + + + +
+ + + diff --git a/locales/en.json b/locales/en.json index 1797133..70680f3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,3 +1,143 @@ { - + "window": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close" + }, + "main": { + "welcome": { + "helloMessage": "Hello, I'm Diona" + }, + "conversation": { + "placeholder": "Type a message...", + "newConversation": "New Conversation", + "selectModel": "Please select model", + "createConversation": "Create Conversation", + "searchPlaceholder": "Search conversations...", + "goSettings": "Go to", + "settings": "Settings Window", + "addModel": "to add a model", + "dialog": { + "title": "Confirm Deletion", + "content": "Are you sure you want to delete this conversation?", + "content_1": "Are you sure you want to delete the selected conversations? This action cannot be undone." + }, + "operations": { + "pin": "Pin Selected", + "del": "Delete Selected", + "selectAll": "Select All", + "cancel": "Cancel" + } + }, + "sidebar": { + "conversations": "Conversations", + "settings": "Settings", + "help": "Help" + }, + "message": { + "dialog": { + "title": "Confirm Deletion", + "messageDelete": "Are you sure you want to delete this message?", + "batchDelete": "Are you sure you want to delete the selected messages?", + "copySuccess": "Copied successfully" + }, + "batchActions": { + "deleteSelected": "Delete Selected" + }, + "rendering": "Thinking...", + "stoppedGeneration": "(Stopped generating)", + "sending": "Sending", + "stopGeneration": "Stop generating", + "send": "Send" + } + }, + "dialog": { + "cancel": "Cancel", + "confirm": "Confirm" + }, + "settings": { + "title": "Settings", + "base": "Basic Settings", + "provider": { + "modelConfig": "Model Configuration" + }, + "theme": { + "label": "Theme Settings", + "dark": "Dark Theme", + "light": "Light Theme", + "system": "System Theme", + "primaryColor": "Primary Color" + }, + "appearance": { + "fontSize": "Font Size", + "fontSizeOptions": { + "10": "Tiny (10px)", + "12": "Small (12px)", + "14": "Normal (14px)", + "16": "Medium (16px)", + "18": "Large (18px)", + "20": "Larger (20px)", + "24": "Extra Large (24px)" + } + }, + "behavior": { + "minimizeToTray": "Minimize to tray when closed" + }, + "language": { + "label": "Language" + }, + "providers": { + "defaultModel": "Default Model", + "apiKey": "API Key", + "apiUrl": "API URL" + } + }, + "menu": { + "conversation": { + "newConversation": "New Conversation", + "sortBy": "Sort By", + "sortByCreateTime": "Sort by Creation Time", + "sortByUpdateTime": "Sort by Update Time", + "sortByName": "Sort by Name", + "sortByModel": "Sort by Model", + "sortAscending": "Ascending", + "sortDescending": "Descending", + "pinConversation": "Pin Conversation", + "unpinConversation": "Unpin Conversation", + "renameConversation": "Rename Conversation", + "delConversation": "Delete Conversation", + "batchOperations": "Batch Operations" + }, + "message": { + "copyMessage": "Copy Message", + "deleteMessage": "Delete Message", + "selectMessage": "Select Message" + } + }, + "tray": { + "tooltip": "Diona Application", + "showWindow": "Show Window", + "exit": "Exit" + }, + "timeAgo": { + "justNow": "Just now", + "minutes": "{count} minutes ago", + "hours": "{count} hours ago", + "days": "{count} days ago", + "months": "{count} months ago", + "years": "{count} years ago", + "weekday": { + "sun": "Sunday", + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday" + } + }, + "app": { + "title": "Diona Application" + } } diff --git a/locales/zh.json b/locales/zh.json index 1797133..78a9c7a 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -1,3 +1,143 @@ { - + "window": { + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", + "close": "关闭" + }, + "main": { + "welcome": { + "helloMessage": "你好,我是迪奥娜" + }, + "conversation": { + "placeholder": "输入消息...", + "newConversation": "新对话", + "selectModel": "请选择模型", + "createConversation": "创建对话", + "searchPlaceholder": "搜索对话...", + "goSettings": "快去", + "settings": "设置窗口", + "addModel": "添加模型", + "dialog": { + "title": "确认删除", + "content": "确定要删除这个对话吗?", + "content_1": "确定要删除选中的对话吗?此操作不可撤销。" + }, + "operations": { + "pin": "置顶所选", + "del": "删除所选", + "selectAll": "全选", + "cancel": "取消" + } + }, + "sidebar": { + "conversations": "对话", + "settings": "设置", + "help": "帮助" + }, + "message": { + "dialog": { + "title": "确认删除", + "messageDelete": "确认删除该条消息?", + "batchDelete": "确认删除选中的消息?", + "copySuccess": "复制成功" + }, + "batchActions": { + "deleteSelected": "删除选中项" + }, + "rendering": "思考中...", + "stoppedGeneration": "(已停止生成)", + "sending": "发送中", + "stopGeneration": "停止生成", + "send": "发送" + } + }, + "dialog": { + "cancel": "取消", + "confirm": "确认" + }, + "settings": { + "title": "设置", + "base": "基础设置", + "provider": { + "modelConfig": "模型配置" + }, + "providers": { + "defaultModel": "默认模型", + "apiKey": "API密钥", + "apiUrl": "API地址" + }, + "theme": { + "label": "主题设置", + "dark": "深色主题", + "light": "浅色主题", + "system": "跟随系统", + "primaryColor": "主题颜色" + }, + "appearance": { + "fontSize": "字体大小", + "fontSizeOptions": { + "10": "极小 (10px)", + "12": "小 (12px)", + "14": "正常 (14px)", + "16": "中 (16px)", + "18": "大 (18px)", + "20": "较大 (20px)", + "24": "超大 (24px)" + } + }, + "behavior": { + "minimizeToTray": "关闭时最小化到托盘" + }, + "language": { + "label": "语言设置" + } + }, + "menu": { + "conversation": { + "newConversation": "新建对话", + "sortBy": "排序方式", + "sortByCreateTime": "按创建时间排序", + "sortByUpdateTime": "按更新时间排序", + "sortByName": "按名称排序", + "sortByModel": "按模型排序", + "sortAscending": "递增", + "sortDescending": "递减", + "pinConversation": "置顶对话", + "unpinConversation": "取消置顶", + "renameConversation": "重命名对话", + "delConversation": "删除对话", + "batchOperations": "批量操作" + }, + "message": { + "copyMessage": "复制消息", + "deleteMessage": "删除消息", + "selectMessage": "选择消息" + } + }, + "tray": { + "tooltip": "迪奥娜", + "showWindow": "显示窗口", + "exit": "退出" + }, + "timeAgo": { + "justNow": "刚刚", + "minutes": "{count}分钟前", + "hours": "{count}小时前", + "days": "{count}天前", + "months": "{count}个月前", + "years": "{count}年前", + "weekday": { + "sun": "星期日", + "mon": "星期一", + "tue": "星期二", + "wed": "星期三", + "thu": "星期四", + "fri": "星期五", + "sat": "星期六" + } + }, + "app": { + "title": "迪奥娜" + } } diff --git a/package-lock.json b/package-lock.json index 0c430e6..b91a42e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@iconify-json/material-symbols": "^1.2.50", + "@iconify/vue": "^5.0.0", "@remixicon/vue": "^4.7.0", "@types/js-cookie": "^3.0.6", "@vueuse/core": "^14.1.0", @@ -22,9 +24,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", @@ -1668,6 +1672,36 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/@iconify-json/material-symbols": { + "version": "1.2.50", + "resolved": "https://registry.npmmirror.com/@iconify-json/material-symbols/-/material-symbols-1.2.50.tgz", + "integrity": "sha512-71tjHR70h46LHtBFab3fAd2V/wPTO7JMV5lKnRn3IcF303LaFgAlO0BZeTJDcmCv9d0snRZmnoLZAJVD7/eisw==", + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/vue": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/vue/-/vue-5.0.0.tgz", + "integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "vue": ">=3" + } + }, "node_modules/@inquirer/checkbox": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", @@ -7466,6 +7500,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/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", @@ -9037,6 +9077,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.14.0.tgz", + "integrity": "sha512-ZPD9MG5/sPpyGZ0idRoDK0P5MWEMuXe0Max/S55vuvoxqyEVkN94m9jSpE3YgNgz3WoESFvozs57dxWqAco31w==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/openapi-ts-request": { "version": "1.10.1", "resolved": "https://registry.npmmirror.com/openapi-ts-request/-/openapi-ts-request-1.10.1.tgz", diff --git a/package.json b/package.json index 3914fdb..4643c11 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "vite": "^7.1.9" }, "dependencies": { + "@iconify-json/material-symbols": "^1.2.50", + "@iconify/vue": "^5.0.0", "@remixicon/vue": "^4.7.0", "@types/js-cookie": "^3.0.6", "@vueuse/core": "^14.1.0", @@ -59,9 +61,11 @@ "electron-log": "^5.4.3", "electron-squirrel-startup": "^1.0.1", "element-plus": "^2.12.0", + "js-base64": "^3.7.8", "js-cookie": "^3.0.5", "lodash-es": "^4.17.21", "log4js": "^6.9.1", + "openai": "^6.14.0", "pinia": "^2.3.1", "vue": "^3.5.22", "vue-i18n": "^11.1.9", diff --git a/public/loading.html b/public/loading.html new file mode 100644 index 0000000..c8a3222 --- /dev/null +++ b/public/loading.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo.ico b/public/logo.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/public/logo.ico differ diff --git a/src/common/constants.ts b/src/common/constants.ts index aaa7ee3..297c68c 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -5,6 +5,7 @@ export enum IPC_EVENTS { WINDOW_MINIMIZE = 'window-minimize', WINDOW_MAXIMIZE = 'window-maximize', WINDOW_CLOSE = 'window-close', + IS_WINDOW_MAXIMIZED = 'is-window-maximized', APP_SET_FRAMELESS = 'app:set-frameless', TAB_CREATE = 'tab:create', TAB_LIST = 'tab:list', @@ -25,7 +26,31 @@ export enum IPC_EVENTS { FILE_WRITE = 'file:write', GET_WINDOW_ID='get-window-id', CUSTOM_EVENT ='custom:event', - TIME_UPDATE = 'time:update' + 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 = { @@ -35,7 +60,48 @@ export const MAIN_WIN_SIZE = { minHeight: 900, } as const +export enum WINDOW_NAMES { + MAIN = 'main', + SETTING = 'setting', + DIALOG = 'dialog', +} +export enum CONFIG_KEYS { + THEME_MODE = 'themeMode', + PRIMARY_COLOR = 'primaryColor', + LANGUAGE = 'language', + FONT_SIZE = 'fontSize', + MINIMIZE_TO_TRAY = 'minimizeToTray', + PROVIDER = 'provider', + DEFAULT_MODEL = 'defaultModel', +} +export enum MENU_IDS { + CONVERSATION_ITEM = 'conversation-item', + CONVERSATION_LIST = 'conversation-list', + MESSAGE_ITEM = 'message-item', +} +export enum CONVERSATION_ITEM_MENU_IDS { + PIN = 'pin', + RENAME = 'rename', + DEL = 'del', +} +export enum CONVERSATION_LIST_MENU_IDS { + NEW_CONVERSATION = 'newConversation', + SORT_BY = 'sortBy', + SORT_BY_CREATE_TIME = 'sortByCreateTime', + SORT_BY_UPDATE_TIME = 'sortByUpdateTime', + SORT_BY_NAME = 'sortByName', + SORT_BY_MODEL = 'sortByModel', + SORT_ASCENDING = 'sortAscending', + SORT_DESCENDING = 'sortDescending', + BATCH_OPERATIONS = 'batchOperations', +} + +export enum MESSAGE_ITEM_MENU_IDS { + COPY = 'copy', + DELETE = 'delete', + SELECT = 'select', +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..c9bfa6a --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,38 @@ +import { WINDOW_NAMES, CONFIG_KEYS } from './constants'; + +export type WindowNames = `${WINDOW_NAMES}`; +export type ConfigKeys = `${CONFIG_KEYS}`; + +export interface IConfig { + // 主题模式配置 + [CONFIG_KEYS.THEME_MODE]: ThemeMode; + // 高亮色 + [CONFIG_KEYS.PRIMARY_COLOR]: string; + // 语言 + [CONFIG_KEYS.LANGUAGE]: 'zh' | 'en'; + // 字体大小 + [CONFIG_KEYS.FONT_SIZE]: number; + // 关闭时最小化到托盘 + [CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean; + // provider 配置 JSON + [CONFIG_KEYS.PROVIDER]?: string; + // 默认模型 + [CONFIG_KEYS.DEFAULT_MODEL]?: string | null; +} + +export interface Provider { + id: number; + name: string; + visible?: boolean; + title?: string; + type?: 'OpenAI'; + openAISetting?: string; + createdAt: number; + updatedAt: number; + models: string[]; +} + +export interface OpenAISetting { + baseURL?: string; + apiKey?: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 0000000..1f27395 --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,94 @@ +import type { OpenAISetting } from './types' +import { encode, decode } from 'js-base64' +/** + * 防抖函数 + * @param fn 需要执行的函数 + * @param delay 延迟时间(毫秒) + * @returns 防抖处理后的函数 + */ +export function debounce any>(fn: T, delay: number): (...args: Parameters) => void { + let timer: NodeJS.Timeout | null = null; + return function (this: any, ...args: Parameters) { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; +} + +/** + * 节流函数 + * @param fn 需要执行的函数 + * @param interval 间隔时间(毫秒) + * @returns 节流处理后的函数 + */ +export function throttle any>(fn: T, interval: number): (...args: Parameters) => void { + let lastTime = 0; + return function (this: any, ...args: Parameters) { + const now = Date.now(); + if (now - lastTime >= interval) { + fn.apply(this, args); + lastTime = now; + } + }; +} + +export function cloneDeep(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => cloneDeep(item)) as T; + } + + const clone = Object.assign({}, obj); + for (const key in clone) { + if (Object.prototype.hasOwnProperty.call(clone, key)) { + clone[key] = cloneDeep(clone[key]); + } + } + return clone; +} + +export function simpleCloneDeep(obj: T): T { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (error) { + console.error('simpleCloneDeep failed:', error); + return obj; + } +} + +export function stringifyOpenAISetting(setting: OpenAISetting) { + try { + return encode(JSON.stringify(setting)); + } catch (error) { + console.error('stringifyOpenAISetting failed:', error); + return ''; + } +} + +export function parseOpenAISetting(setting: string): OpenAISetting { + try { + return JSON.parse(decode(setting)); + } catch (error) { + console.error('parseOpenAISetting failed:', error); + return {} as OpenAISetting; + } +} + +export function uniqueByKey>(arr: T[], key: keyof T): T[] { + const seen = new Map(); + + return arr.filter(item => { + const keyValue = item[key]; + if (seen.has(keyValue)) { + return false; + } + seen.set(keyValue, true); + return true; + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index 5fb1cf2..0c297dd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,113 +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 '@main/service/config-service' +import logManager from '@main/service/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, - webPreferences: { - devTools: this.isDev, - nodeIntegration: false, - contextIsolation: true, // 同时启动上下文隔离 - sandbox: true, // 启动沙箱模式 - preload: path.join(__dirname, "preload.js"), - }, - }) +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +// Some APIs can only be used after this event occurs. +app.whenReady().then(() => { + setupWindows(); - this.loadEntry(win, options?.route) - if (this.isDev) win.webContents.openDevTools() - this.mainWindow = win - return win - } - - private loadEntry(win: BrowserWindow, route?: string) { - // @ts-ignore - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - const target = route ? `${MAIN_WINDOW_VITE_DEV_SERVER_URL}${route}` : MAIN_WINDOW_VITE_DEV_SERVER_URL - win.loadURL(target) - } else { - win.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)) + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + setupWindows(); } + }); +}); - // 暴露安全 API 示例:通过 IPC 处理文件读取 - ipcMain.handle('read-file', async (event, filePath) => { - const fs = require('fs'); - return fs.promises.readFile(filePath, 'utf-8'); // 主进程处理敏感操作 - }); +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on('window-all-closed', () => { + if (process.platform !== 'darwin' && !configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY)) { + logManager.info('app closing due to all windows being closed'); + app.quit(); } +}); - private registerLifecycle() { - app.on("ready", () => { - this.createWindow({ frameless: false, route: '/login' }) - }) - app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit() - }) - app.on("activate", () => { - if (!BrowserWindow.getAllWindows().length) this.createWindow({ frameless: false, route: '/login' }) - }) - } - - private registerAppIPC() { - ipcMain.handle('app:set-frameless', async (event, route?: string) => { - const old = BrowserWindow.fromWebContents(event.sender) - const win = this.createWindow({ frameless: true, route }) - if (old && !old.isDestroyed()) old.close() - return true - }) - } - - private registerLogIPC() { - ipcMain.handle('log-to-main', (_e, logLevel: string, message: string) => { - switch(logLevel) { - case 'trace': - logger.trace(message) - break - case 'debug': - logger.debug(message) - break - case 'info': - logger.info(message) - break - case 'warn': - logger.warn(message) - break - case 'error': - logger.error(message) - break - default: - logger.info(message) - break - } - }) - } -} - -new AppMain().init() - - \ No newline at end of file +// In this file you can include the rest of your app's specific main process +// code. You can also put them in separate files and import them here. diff --git a/src/main/modules/logger/index.ts b/src/main/modules/logger/index.ts deleted file mode 100644 index 9eb1c1d..0000000 --- a/src/main/modules/logger/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as log4js from 'log4js'; - -log4js.configure({ - appenders: { - out: { - type: 'stdout' - }, - app: { - type: 'file', - filename: 'logs/app.log', - backups: 3, - compress: false, - encoding: 'utf-8', - layout: { - type: 'pattern', - pattern: '[%d{yyyy-MM-dd hh:mm:ss.SSS}] [%p] %m' - }, - keepFileExt: true - } - }, - categories: { - default: { - appenders: ['out', 'app'], - level: 'debug' - } - } -}); - -export const logger = log4js.getLogger(); \ No newline at end of file diff --git a/src/main/modules/tray/index.ts b/src/main/modules/tray/index.ts deleted file mode 100644 index f87ca14..0000000 --- a/src/main/modules/tray/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 创建系统托盘 -import { Tray, Menu } from 'electron' -import path from 'path' - -const createTray = (app: Electron.App, win: Electron.BrowserWindow) => { - let tray = new Tray(path.join(__dirname, '../public/favicon.ico')) - - tray.setToolTip('示例平台') // 鼠标放在托盘图标上的提示信息 - - tray.on('click', (e) => { - if (e.shiftKey) { - app.quit() - } else { - win.show() - } - }) - - tray.setContextMenu( - Menu.buildFromTemplate([ - { - label: '退出', - click: () => { - // 先把用户的登录状态和用户的登录信息给清楚掉,再退出 - app.quit() - } - } - ]) - ) -} - -module.exports = createTray diff --git a/src/main/modules/window-size/index.ts b/src/main/modules/window-size/index.ts deleted file mode 100644 index a5a4bec..0000000 --- a/src/main/modules/window-size/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ipcMain, BrowserWindow } from 'electron' - -// 最小化 -ipcMain.on('window-min', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - win?.minimize() -}) - -// 最大化 -ipcMain.on('window-max', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - if (win?.isMaximized()) { - win.unmaximize() - } else { - win?.maximize() - } -}) - -// 关闭 -ipcMain.on('window-close', (event) => { - const webContent = event.sender - const win = BrowserWindow.fromWebContents(webContent) - win?.close() -}) diff --git a/src/main/providers/BaseProvider.ts b/src/main/providers/BaseProvider.ts new file mode 100644 index 0000000..c6a7afa --- /dev/null +++ b/src/main/providers/BaseProvider.ts @@ -0,0 +1,4 @@ + +export abstract class BaseProvider { + abstract chat(messages: DialogueMessageProps[], modelName: string): Promise> +} diff --git a/src/main/providers/OpenAIProvider.ts b/src/main/providers/OpenAIProvider.ts new file mode 100644 index 0000000..32ce054 --- /dev/null +++ b/src/main/providers/OpenAIProvider.ts @@ -0,0 +1,58 @@ +import { BaseProvider } from "./BaseProvider"; + +import OpenAI from "openai"; +import logManager from "@main/service/logger" + + +function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): UniversalChunk { + const choice = chunk.choices[0]; + return { + isEnd: choice?.finish_reason === 'stop', + result: choice?.delta?.content ?? '', + } +} + +export class OpenAIProvider extends BaseProvider { + private client: OpenAI; + + constructor(apiKey: string, baseURL: string) { + super(); + this.client = new OpenAI({ apiKey, baseURL }); + } + + async chat(messages: DialogueMessageProps[], model: string): Promise> { + const startTime = Date.now(); + + const lastMessage = messages[messages.length - 1]; + + logManager.logApiRequest('chat.completions.create', { + model, + lastMessage: lastMessage?.content?.substring(0, 100) + (lastMessage?.content?.length > 100 ? '...' : ''), + messageCount: messages.length, + }, 'POST'); + + try { + const chunks = await this.client.chat.completions.create({ + model, + messages, + stream: true, + }); + + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { success: true }, 200, responseTime); + // return chunk; + return { + async *[Symbol.asyncIterator]() { + for await (const chunk of chunks) { + yield _transformChunk(chunk); + } + } + } + } catch (error) { + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); + throw error; + } + } +} + diff --git a/src/main/providers/index.ts b/src/main/providers/index.ts new file mode 100644 index 0000000..f390fef --- /dev/null +++ b/src/main/providers/index.ts @@ -0,0 +1,123 @@ +import type { Provider } from "@common/types" +import { OpenAIProvider } from "./OpenAIProvider" +import { parseOpenAISetting } from '@common/utils' +import { decode } from 'js-base64' +import { configManager } from '@main/service/config-service' +import { logManager } from '@main/service/logger' +import { CONFIG_KEYS } from "@common/constants" + +const providers = [ + { + id: 1, + name: 'bigmodel', + title: '智谱AI', + models: ['glm-4.5-flash'], + openAISetting: { + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: process.env.BIGMODEL_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 2, + name: 'deepseek', + title: '深度求索 (DeepSeek)', + models: ['deepseek-chat'], + openAISetting: { + baseURL: 'https://api.deepseek.com/v1', + apiKey: process.env.DEEPSEEK_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 3, + name: 'siliconflow', + title: '硅基流动', + models: ['Qwen/Qwen3-8B', 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B'], + openAISetting: { + baseURL: 'https://api.siliconflow.cn/v1', + apiKey: process.env.SILICONFLOW_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, + { + id: 4, + name: 'qianfan', + title: '百度千帆', + models: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k'], + openAISetting: { + baseURL: 'https://qianfan.baidubce.com/v2', + apiKey: process.env.QIANFAN_API_KEY || '', + }, + createdAt: new Date().getTime(), + updatedAt: new Date().getTime() + }, +]; + +interface _Provider extends Omit { + openAISetting?: { + apiKey: string, + baseURL: string, + }; +} + +const _parseProvider = () => { + let result: Provider[] = []; + let isBase64Parsed = false; + const providerConfig = configManager.get(CONFIG_KEYS.PROVIDER); + + const mapCallback = (provider: Provider) => ({ + ...provider, + openAISetting: typeof provider.openAISetting === 'string' + ? parseOpenAISetting(provider.openAISetting ?? '') + : provider.openAISetting, + }) + + try { + result = JSON.parse(decode(providerConfig)) as Provider[]; + isBase64Parsed = true; + } catch (error) { + logManager.error(`parse base64 provider failed: ${error}`); + } + + if (!isBase64Parsed) try { + result = JSON.parse(providerConfig) as Provider[] + } catch (error) { + logManager.error(`parse provider failed: ${error}`); + } + + if (!result.length) return; + + return result.map(mapCallback) as _Provider[] +} + +const getProviderConfig = () => { + try { + return _parseProvider(); + } catch (error) { + logManager.error(`get provider config failed: ${error}`); + return null; + } +} + +export function createProvider(name: string) { + const providers = getProviderConfig(); + + if (!providers) { + throw new Error('provider config not found'); + } + + for (const provider of providers) { + if (provider.name === name) { + if (!provider.openAISetting?.apiKey || !provider.openAISetting?.baseURL) { + throw new Error('apiKey or baseURL not found'); + } + // TODO: visible + + return new OpenAIProvider(provider.openAISetting.apiKey, provider.openAISetting.baseURL); + } + } +} diff --git a/src/main/service/config-service/index.ts b/src/main/service/config-service/index.ts new file mode 100644 index 0000000..37aa0d5 --- /dev/null +++ b/src/main/service/config-service/index.ts @@ -0,0 +1,126 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import type { ConfigKeys, IConfig } from '@common/types' +import { CONFIG_KEYS, IPC_EVENTS } from '@common/constants' +import { debounce, simpleCloneDeep } from '@common/utils' +import * as fs from 'fs' +import * as path from 'path' + +import logManager from '@main/service/logger' + +const DEFAULT_CONFIG: IConfig = { + [CONFIG_KEYS.THEME_MODE]: 'system', + [CONFIG_KEYS.PRIMARY_COLOR]: '#BB5BE7', + [CONFIG_KEYS.LANGUAGE]: 'zh', + [CONFIG_KEYS.FONT_SIZE]: 14, + [CONFIG_KEYS.MINIMIZE_TO_TRAY]: false, + [CONFIG_KEYS.PROVIDER]: '', + [CONFIG_KEYS.DEFAULT_MODEL]: null, +} + +export class ConfigService { + private static _instance: ConfigService; + private _config: IConfig; + private _configPath: string; + private _defaultConfig: IConfig = DEFAULT_CONFIG; + + private _listeners: Array<(config: IConfig) => void> = []; + + + private constructor() { + // 获取配置文件路径 + this._configPath = path.join(app.getPath('userData'), 'config.json'); + // 加载配置 + this._config = this._loadConfig(); + // 设置 IPC 事件 + this._setupIpcEvents(); + logManager.info('ConfigService initialized successfully.') + } + + private _setupIpcEvents() { + const duration = 200; + const handelUpdate = debounce((val) => this.update(val), duration); + + ipcMain.handle(IPC_EVENTS.GET_CONFIG, (_, key) => this.get(key)); + ipcMain.on(IPC_EVENTS.SET_CONFIG, (_, key, val) => this.set(key, val)); + ipcMain.on(IPC_EVENTS.UPDATE_CONFIG, (_, updates) => handelUpdate(updates)); + } + + public static getInstance(): ConfigService { + if (!this._instance) { + this._instance = new ConfigService(); + } + return this._instance; + } + + private _loadConfig(): IConfig { + try { + if (fs.existsSync(this._configPath)) { + const configContent = fs.readFileSync(this._configPath, 'utf-8'); + const config = { ...this._defaultConfig, ...JSON.parse(configContent) }; + logManager.info('Config loaded successfully from:', this._configPath); + return config; + } + } catch (error) { + logManager.error('Failed to load config:', error); + } + return { ...this._defaultConfig }; + } + + private _saveConfig(): void { + try { + // 确保目录存在 + fs.mkdirSync(path.dirname(this._configPath), { recursive: true }); + // 写入 + fs.writeFileSync(this._configPath, JSON.stringify(this._config, null, 2), 'utf-8'); + // 通知监听者 + this._notifyListeners(); + + logManager.info('Config saved successfully to:', this._configPath); + } catch (error) { + logManager.error('Failed to save config:', error); + } + } + + private _notifyListeners(): void { + BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._config)); + this._listeners.forEach(listener => listener({ ...this._config })); + } + + public getConfig(): IConfig { + return simpleCloneDeep(this._config); + } + + public get(key: ConfigKeys): T { + return this._config[key] as T + } + + public set(key: ConfigKeys, value: unknown, autoSave: boolean = true): void { + if (!(key in this._config)) return; + const oldValue = this._config[key]; + if (oldValue === value) return; + this._config[key] = value as never; + logManager.debug(`Config set: ${key} = ${value}`); + autoSave && this._saveConfig(); + } + + public update(updates: Partial, autoSave: boolean = true): void { + this._config = { ...this._config, ...updates }; + autoSave && this._saveConfig(); + } + + public resetToDefault(): void { + this._config = { ...this._defaultConfig }; + logManager.info('Config reset to default.'); + this._saveConfig(); + } + + public onConfigChange(listener: ((config: IConfig) => void)): () => void { + this._listeners.push(listener); + + return () => this._listeners = this._listeners.filter(l => l !== listener); + } + +} + +export const configManager = ConfigService.getInstance(); +export default configManager; diff --git a/src/main/modules/ipc/index.ts b/src/main/service/ipc/index.ts similarity index 100% rename from src/main/modules/ipc/index.ts rename to src/main/service/ipc/index.ts diff --git a/src/main/service/logger/index.ts b/src/main/service/logger/index.ts new file mode 100644 index 0000000..e604c86 --- /dev/null +++ b/src/main/service/logger/index.ts @@ -0,0 +1,180 @@ +import { IPC_EVENTS } from '@common/constants'; +import { promisify } from 'util'; +import { app, ipcMain } from 'electron'; +import log from 'electron-log'; +import * as path from 'path'; +import * as fs from 'fs'; + +// 转换为Promise形式的fs方法 +const readdirAsync = promisify(fs.readdir); +const statAsync = promisify(fs.stat); +const unlinkAsync = promisify(fs.unlink); + +class LogService { + private static _instance: LogService; + + // 日志保留天数,默认7天 + private LOG_RETENTION_DAYS = 7; + + // 清理间隔,默认24小时(毫秒) + private readonly CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; + + private constructor() { + const logPath = path.join(app.getPath('userData'), 'logs'); + // c:users/{username}/AppData/Roaming/{appName}/logs + + // 创建日志目录 + try { + if (!fs.existsSync(logPath)) { + fs.mkdirSync(logPath, { recursive: true }); + } + } catch (err) { + this.error('Failed to create log directory:', err); + } + + // 配置electron-log + log.transports.file.resolvePathFn = () => { + // 使用当前日期作为日志文件名,格式为 YYYY-MM-DD.log + const today = new Date(); + const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + return path.join(logPath, `${formattedDate}.log`); + }; + + // 配置日志格式 + log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'; + + // 配置日志文件大小限制,默认10MB + log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB + + // 配置控制台日志级别,开发环境可以设置为debug,生产环境可以设置为info + log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'info'; + + // 配置文件日志级别 + log.transports.file.level = 'debug'; + + // 设置IPC事件 + this._setupIpcEvents(); + // 重写console方法 + this._rewriteConsole(); + + + this.info('LogService initialized successfully.'); + this._cleanupOldLogs(); + // 定时清理旧日志 + setInterval(() => this._cleanupOldLogs(), this.CLEANUP_INTERVAL_MS); + } + + private _setupIpcEvents() { + ipcMain.on(IPC_EVENTS.LOG_DEBUG, (_e, message: string, ...meta: any[]) => this.debug(message, ...meta)); + ipcMain.on(IPC_EVENTS.LOG_INFO, (_e, message: string, ...meta: any[]) => this.info(message, ...meta)); + ipcMain.on(IPC_EVENTS.LOG_WARN, (_e, message: string, ...meta: any[]) => this.warn(message, ...meta)); + ipcMain.on(IPC_EVENTS.LOG_ERROR, (_e, message: string, ...meta: any[]) => this.error(message, ...meta)); + } + + private _rewriteConsole() { + console.debug = log.debug; + console.log = log.info; + console.info = log.info; + console.warn = log.warn; + console.error = log.error; + } + + private async _cleanupOldLogs() { + try { + const logPath = path.join(app.getPath('userData'), 'logs'); + + if (!fs.existsSync(logPath)) return; + + const now = new Date(); + const expirationDate = new Date(now.getTime() - this.LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + + const files = await readdirAsync(logPath); + + let deletedCount = 0; + + for (const file of files) { + if (!file.endsWith('.log')) continue; + const filePath = path.join(logPath, file); + try { + const stats = await statAsync(filePath); + if (stats.isFile() && (stats.birthtime < expirationDate)) { + await unlinkAsync(filePath); + deletedCount++; + } + } catch (error) { + this.error(`Failed to delete old log file ${filePath}:`, error); + } + } + if (deletedCount > 0) { + this.info(`Successfully cleaned up ${deletedCount} old log files.`); + } + + } catch (err) { + this.error('Failed to cleanup old logs:', err); + } + } + + public static getInstance(): LogService { + if (!this._instance) { + this._instance = new LogService(); + } + return this._instance; + } + + /** + * 记录调试信息 + * @param {string} message - 日志消息 + * @param {any[]} meta - 附加的元数据 + */ + public debug(message: string, ...meta: any[]): void { + log.debug(message, ...meta); + } + + /** + * 记录一般信息 + * @param {string} message - 日志消息 + * @param {any[]} meta - 附加的元数据 + */ + public info(message: string, ...meta: any[]): void { + log.info(message, ...meta); + } + + /** + * 记录警告信息 + * @param {string} message - 日志消息 + * @param {any[]} meta - 附加的元数据 + */ + public warn(message: string, ...meta: any[]): void { + log.warn(message, ...meta); + } + + /** + * 记录错误信息 + * @param {string} message - 日志消息 + * @param {any[]} meta - 附加的元数据,通常是错误对象 + */ + public error(message: string, ...meta: any[]): void { + log.error(message, ...meta); + } + + + public logApiRequest(endpoint: string, data: any = {}, method: string = 'POST'): void { + this.info(`API Request: ${endpoint}, Method: ${method}, Request: ${JSON.stringify(data)}`); + } + + public logApiResponse(endpoint: string, response: any = {}, statusCode: number = 200, responseTime: number = 0): void { + if (statusCode >= 400) { + this.error(`API Error Response: ${endpoint}, Status: ${statusCode}, Response Time: ${responseTime}ms, Response: ${JSON.stringify(response)}`); + } else { + this.debug(`API Response: ${endpoint}, Status: ${statusCode}, Response Time: ${responseTime}ms, Response: ${JSON.stringify(response)}`); + } + } + + public logUserOperation(operation: string, userId: string = 'unknown', details: any = {}): void { + this.info(`User Operation: ${operation} by ${userId}, Details: ${JSON.stringify(details)}`); + } + +} + +export const logManager = LogService.getInstance(); +export default logManager; diff --git a/src/main/service/menu-service/index.ts b/src/main/service/menu-service/index.ts new file mode 100644 index 0000000..78a5f95 --- /dev/null +++ b/src/main/service/menu-service/index.ts @@ -0,0 +1,123 @@ +import { ipcMain, Menu, type MenuItemConstructorOptions } from 'electron'; +import { CONFIG_KEYS, IPC_EVENTS } from '@common/constants'; +import { cloneDeep } from '@common/utils'; +import { createTranslator } from '@main/utils' +import logManager from '@main/service/logger' +import configManager from '@main/service/config-service' + +let t: ReturnType = createTranslator(); + +class MenuService { + private static _instance: MenuService; + private _menuTemplates: Map = new Map(); + private _currentMenu?: Menu = void 0; + + private constructor() { + this._setupIpcListener(); + this._setupLanguageChangeListener(); + logManager.info('MenuService initialized successfully.'); + } + + private _setupIpcListener() { + ipcMain.handle(IPC_EVENTS.SHOW_CONTEXT_MENU, (_, menuId, dynamicOptions?: string) => new Promise((resolve) => this.showMenu(menuId, () => resolve(true), dynamicOptions))) + } + + private _setupLanguageChangeListener() { + configManager.onConfigChange((config)=>{ + if(!config[CONFIG_KEYS.LANGUAGE]) return; + + t = createTranslator() + }) + } + + public static getInstance() { + if (!this._instance) + this._instance = new MenuService(); + return this._instance; + } + + public register(menuId: string, template: MenuItemConstructorOptions[]) { + this._menuTemplates.set(menuId, template); + return menuId; + } + + public showMenu(menuId: string, onClose?: () => void, dynamicOptions?: string) { + if (this._currentMenu) return; + + const template = cloneDeep(this._menuTemplates.get(menuId)); + + if (!template) { + logManager.warn(`Menu ${menuId} not found.`); + onClose?.(); + return; + } + + let _dynamicOptions: Array & { id: string }> = []; + try { + _dynamicOptions = Array.isArray(dynamicOptions) ? dynamicOptions : JSON.parse(dynamicOptions ?? '[]'); + } catch (error) { + logManager.error(`Failed to parse dynamicOptions for menu ${menuId}: ${error}`); + } + + const translationItem = (item: MenuItemConstructorOptions): MenuItemConstructorOptions => { + if (item.submenu) { + return { + ...item, + label: t(item?.label) ?? void 0, + submenu: (item.submenu as MenuItemConstructorOptions[])?.map((item: MenuItemConstructorOptions) => translationItem(item)) + } + } + return { + ...item, + label: t(item?.label) ?? void 0 + } + } + const localizedTemplate = template.map(item => { + if (!Array.isArray(_dynamicOptions) || !_dynamicOptions.length) { + return translationItem(item); + } + + const dynamicItem = _dynamicOptions.find(_item => _item.id === item.id); + + if (dynamicItem) { + const mergedItem = { ...item, ...dynamicItem }; + return translationItem(mergedItem); + } + + if (item.submenu) { + return translationItem({ + ...item, + submenu: (item.submenu as MenuItemConstructorOptions[])?.map((__item: MenuItemConstructorOptions) => { + const dynamicItem = _dynamicOptions.find(_item => _item.id === __item.id); + return { ...__item, ...dynamicItem }; + }) + }) + } + + return translationItem(item); + }) + + const menu = Menu.buildFromTemplate(localizedTemplate); + + this._currentMenu = menu; + + menu.popup({ + callback: () => { + this._currentMenu = void 0; + onClose?.(); + } + }) + } + + public destroyMenu(menuId: string) { + this._menuTemplates.delete(menuId); + } + + public destroyed() { + this._menuTemplates.clear(); + this._currentMenu = void 0; + } +} + +export const menuManager = MenuService.getInstance(); +export default menuManager; diff --git a/src/main/modules/tab-manager/index.ts b/src/main/service/tab-manager/index.ts similarity index 100% rename from src/main/modules/tab-manager/index.ts rename to src/main/service/tab-manager/index.ts diff --git a/src/main/service/theme-service/index.ts b/src/main/service/theme-service/index.ts new file mode 100644 index 0000000..254708c --- /dev/null +++ b/src/main/service/theme-service/index.ts @@ -0,0 +1,61 @@ +import { BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { configManager } from '@main/service/config-service' +import { logManager } from '@main/service/logger' +import { IPC_EVENTS, CONFIG_KEYS } from '@common/constants' + +class ThemeService { + private static _instance: ThemeService; + private _isDark: boolean = nativeTheme.shouldUseDarkColors; + + constructor() { + const themeMode = configManager.get(CONFIG_KEYS.THEME_MODE); + if (themeMode) { + nativeTheme.themeSource = themeMode; + this._isDark = nativeTheme.shouldUseDarkColors; + } + this._setupIpcEvent(); + logManager.info('ThemeService initialized successfully.'); + } + + private _setupIpcEvent() { + ipcMain.handle(IPC_EVENTS.SET_THEME_MODE, (_e, mode: ThemeMode) => { + nativeTheme.themeSource = mode; + configManager.set(CONFIG_KEYS.THEME_MODE, mode); + return nativeTheme.shouldUseDarkColors; + }); + + ipcMain.handle(IPC_EVENTS.GET_THEME_MODE, () => { + return nativeTheme.themeSource; + }); + + ipcMain.handle(IPC_EVENTS.IS_DARK_THEME, () => { + return nativeTheme.shouldUseDarkColors; + }); + + nativeTheme.on('updated', () => { + this._isDark = nativeTheme.shouldUseDarkColors; + + BrowserWindow.getAllWindows().forEach(win => + win.webContents.send(IPC_EVENTS.THEME_MODE_UPDATED, this._isDark) + ); + }); + } + + public static getInstance() { + if (!this._instance) { + this._instance = new ThemeService(); + } + return this._instance; + } + + public get isDark() { + return this._isDark; + } + + public get themeMode() { + return nativeTheme.themeSource; + } +} + +export const themeManager = ThemeService.getInstance(); +export default themeManager; diff --git a/src/main/service/tray-service/index.ts b/src/main/service/tray-service/index.ts new file mode 100644 index 0000000..3560194 --- /dev/null +++ b/src/main/service/tray-service/index.ts @@ -0,0 +1,100 @@ +import { Tray, Menu, ipcMain, app } from 'electron' +import { createTranslator, createLogo } from '@main/utils' +import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES, MAIN_WIN_SIZE } from '@common/constants' + +import logManager from '@main/service/logger' +// TODO: shortcutManager +import windowManager from '@main/service/window-service' +import configManager from '@main/service/config-service' + +let t: ReturnType = createTranslator(); + +class TrayService { + private static _instance: TrayService; + private _tray: Tray | null = null; + private _removeLanguageListener?: () => void; + + private _setupLanguageChangeListener() { + this._removeLanguageListener = configManager.onConfigChange((config) => { + if (!config[CONFIG_KEYS.LANGUAGE]) return; + + // 切换语言后,重新创建翻译器 + t = createTranslator(); + + + if (this._tray) { + this._updateTray(); + } + }) + } + + private _updateTray() { + if (!this._tray) { + this._tray = new Tray(createLogo()); + } + + const showWindow = () => { + const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); + + if (mainWindow && !mainWindow?.isDestroyed() && mainWindow?.isVisible() && !mainWindow?.isFocused()) { + return mainWindow.focus(); + } + + if (mainWindow?.isMinimized()) { + return mainWindow?.restore(); + } + + if (mainWindow?.isVisible() && mainWindow?.isFocused()) return; + + windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); + } + + this._tray.setToolTip(t('tray.tooltip') ?? 'Diona Application'); + + // TODO: 依赖快捷键Service + this._tray.setContextMenu(Menu.buildFromTemplate([ + { label: t('tray.showWindow'), accelerator: 'CmdOrCtrl+N', click: showWindow }, + { type: 'separator' }, + { label: t('settings.title'), click: () => ipcMain.emit(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`) }, + { role: 'quit', label: t('tray.exit') } + ])); + + this._tray.removeAllListeners('click'); + this._tray.on('click', showWindow); + } + + private constructor() { + this._setupLanguageChangeListener(); + logManager.info('TrayService initialized successfully.'); + } + + public static getInstance() { + if (!this._instance) { + this._instance = new TrayService(); + } + return this._instance; + } + + public create() { + if (this._tray) return; + this._updateTray(); + app.on('quit', () => { + this.destroy(); + //TODO: 移除快捷键 + }) + } + + public destroy() { + this._tray?.destroy(); + this._tray = null; + //TODO: 移除快捷键 + if (this._removeLanguageListener) { + this._removeLanguageListener(); + this._removeLanguageListener = void 0; + } + } +} + +export const trayManager = TrayService.getInstance(); +export default trayManager; + diff --git a/src/main/service/window-service/index.ts b/src/main/service/window-service/index.ts new file mode 100644 index 0000000..3160ff1 --- /dev/null +++ b/src/main/service/window-service/index.ts @@ -0,0 +1,310 @@ +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 '@main/service/logger' +import configManager from '@main/service/config-service' +import themeManager from '@main/service/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: 'NIANXX', + 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 readonly isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL + + private _winStates: Record = { + main: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + setting: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + dialog: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + login: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] }, + loading: { 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 }); + + if (this.isDev) window.webContents.openDevTools() + + !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, + }); + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + loadingView.webContents.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/loading.html`); + } else { + loadingView.webContents.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/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' ? 'login' : name}.html`)); + } + + + private _handleCloseWindowState(target: BrowserWindow, really: boolean) { + const name = this.getName(target) as WindowNames; + + if (name) { + if (!really) this._winStates[name].isHidden = true; + else this._winStates[name].instance = void 0; + } + + setTimeout(() => { + target[really ? 'close' : 'hide']?.(); + this._checkAndCloseAllWinodws(); + }, 210) + } + + private _checkAndCloseAllWinodws() { + if (!this._winStates[WINDOW_NAMES.MAIN].instance || this._winStates[WINDOW_NAMES.MAIN].instance?.isDestroyed()) + return Object.values(this._winStates).forEach(win => win?.instance?.close()); + + const minimizeToTray = configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY); + if (!minimizeToTray && !this.get(WINDOW_NAMES.MAIN)?.isVisible()) + return Object.values(this._winStates).forEach(win => !win?.instance?.isVisible() && win?.instance?.close()); + } + + private _isHiddenWin(name: WindowNames) { + return this._winStates[name] && this._winStates[name].isHidden; + } + + private _createWinInstance(name: WindowNames, opts?: BrowserWindowConstructorOptions) { + return this._isHiddenWin(name) + ? this._winStates[name].instance as BrowserWindow + : new BrowserWindow({ + ...SHARED_WINDOW_OPTIONS, + icon: this._logo, + ...opts, + }); + } + + public focus(target: BrowserWindow | void | null) { + if (!target) return; + const name = this.getName(target); + if (target?.isMaximized()) { + target?.restore(); + logManager.debug(`Window ${name} restored and focused`); + } else { + logManager.debug(`Window ${name} focused`); + } + + target?.focus(); + } + + public close(target: BrowserWindow | void | null, really: boolean = true) { + if (!target) return; + + const name = this.getName(target); + logManager.info(`Close window: ${name}, really: ${really}`); + this._handleCloseWindowState(target, really); + } + + public toggleMax(target: BrowserWindow | void | null) { + if (!target) return; + target.isMaximized() ? target.unmaximize() : target.maximize(); + } + + public getName(target: BrowserWindow | null | void): WindowNames | void { + if (!target) return; + for (const [name, win] of Object.entries(this._winStates) as [WindowNames, { instance: BrowserWindow | void } | void][]) { + if (win?.instance === target) return name; + } + } + + public get(name: WindowNames) { + if (this._winStates[name].isHidden) return void 0; + return this._winStates[name].instance; + } + + public onWindowCreate(name: WindowNames, callback: (window: BrowserWindow) => void) { + this._winStates[name].onCreate.push(callback); + } + + public onWindowClosed(name: WindowNames, callback: (window: BrowserWindow) => void) { + this._winStates[name].onClosed.push(callback); + } + +} + +export const windowManager = WindowService.getInstance(); + +export default windowManager; diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts new file mode 100644 index 0000000..2deafd9 --- /dev/null +++ b/src/main/utils/index.ts @@ -0,0 +1,36 @@ +import { CONFIG_KEYS } from '@common/constants' +import logManager from '@main/service/logger' +import configManager from '@main/service/config-service' +import path from 'node:path' + +import en from '@locales/en.json' +import zh from '@locales/zh.json' + +type MessageSchema = typeof zh; +const messages: Record = { en, zh } + +export function createTranslator() { + return (key?: string) => { + if (!key) return void 0; + try { + const keys = key?.split('.'); + let result: any = messages[configManager.get(CONFIG_KEYS.LANGUAGE)]; + for (const _key of keys) { + result = result[_key]; + } + return result as string; + } catch (e) { + logManager.error('failed to translate key:', key, e); + return key + } + } +} + +let logo: string | void = void 0; +export function createLogo() { + if (logo != null) { + return logo; + } + logo = path.join(__dirname, 'logo.ico'); + return logo; +} diff --git a/src/main/wins/dialog.ts b/src/main/wins/dialog.ts new file mode 100644 index 0000000..471517e --- /dev/null +++ b/src/main/wins/dialog.ts @@ -0,0 +1,49 @@ +import { IPC_EVENTS, WINDOW_NAMES } from '@common/constants' +import { BrowserWindow, ipcMain } from 'electron' +import { windowManager } from '@main/service/window-service' + +export function setupDialogWindow() { + let dialogWindow: BrowserWindow | void; + let params: CreateDialogProps | void + let feedback: string | void + + ipcMain.handle(WINDOW_NAMES.DIALOG + 'get-params',(e)=>{ + if(BrowserWindow.fromWebContents(e.sender) !== dialogWindow) return + return { + winId: e.sender.id, + ...params + } + }); + + ['confirm','cancel'].forEach(_feedback => { + ipcMain.on(WINDOW_NAMES.DIALOG + _feedback,(e,winId:number)=> { + if(e.sender.id !== winId) return + feedback = _feedback; + windowManager.close(BrowserWindow.fromWebContents(e.sender)); + }); + }); + + ipcMain.handle(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.DIALOG}`, (e, _params) => { + params = _params; + dialogWindow = windowManager.create( + WINDOW_NAMES.DIALOG, + { + width: 350, height: 200, + minWidth: 350, minHeight: 200, + maxWidth: 400, maxHeight: 300, + }, + { + parent: BrowserWindow.fromWebContents(e.sender) as BrowserWindow, + resizable: false + } + ); + + return new Promise((resolve) => dialogWindow?.on('closed', () => { + resolve(feedback); + feedback = void 0; + })) + }) + +} + +export default setupDialogWindow diff --git a/src/main/wins/index.ts b/src/main/wins/index.ts new file mode 100644 index 0000000..452cb34 --- /dev/null +++ b/src/main/wins/index.ts @@ -0,0 +1,9 @@ +import { setupMainWindow } from './main'; +import { setupDialogWindow } from './dialog'; +import { setupSetttingWindow } from './setting'; + +export function setupWindows() { + setupMainWindow(); + setupSetttingWindow(); + setupDialogWindow(); +} diff --git a/src/main/wins/main.ts b/src/main/wins/main.ts new file mode 100644 index 0000000..a7f2b84 --- /dev/null +++ b/src/main/wins/main.ts @@ -0,0 +1,151 @@ +import type { BrowserWindow } from 'electron' +import { ipcMain } from 'electron'; +import { WINDOW_NAMES, MAIN_WIN_SIZE, IPC_EVENTS, MENU_IDS, CONVERSATION_ITEM_MENU_IDS, CONVERSATION_LIST_MENU_IDS, MESSAGE_ITEM_MENU_IDS, CONFIG_KEYS } from '@common/constants' +import { createProvider } from '../providers' +import { windowManager } from '@main/service/window-service' +import { menuManager } from '@main/service/menu-service' +import { logManager } from '@main/service/logger' +import { configManager } from '@main/service/config-service' +import { trayManager } from '@main/service/tray-service' + +const handleTray = (minimizeToTray: boolean) => { + if (minimizeToTray) { + trayManager.create(); + return; + } + + trayManager.destroy(); +} + +const registerMenus = (window: BrowserWindow) => { + + const conversationItemMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_ITEM}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_ITEM}`, id); + } + + menuManager.register(MENU_IDS.CONVERSATION_ITEM, [ + { + id: CONVERSATION_ITEM_MENU_IDS.PIN, + label: 'menu.conversation.pinConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.PIN) + }, + { + id: CONVERSATION_ITEM_MENU_IDS.RENAME, + label: 'menu.conversation.renameConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.RENAME) + }, + { + id: CONVERSATION_ITEM_MENU_IDS.DEL, + label: 'menu.conversation.delConversation', + click: () => conversationItemMenuItemClick(CONVERSATION_ITEM_MENU_IDS.DEL) + }, + ]) + + const conversationListMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_LIST}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.CONVERSATION_LIST}`, id); + } + + menuManager.register(MENU_IDS.CONVERSATION_LIST, [ + { + id: CONVERSATION_LIST_MENU_IDS.NEW_CONVERSATION, + label: 'menu.conversation.newConversation', + click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.NEW_CONVERSATION) + }, + { type: 'separator' }, + { + id: CONVERSATION_LIST_MENU_IDS.SORT_BY, label: 'menu.conversation.sortBy', submenu: [ + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_CREATE_TIME, label: 'menu.conversation.sortByCreateTime', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_CREATE_TIME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_UPDATE_TIME, label: 'menu.conversation.sortByUpdateTime', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_UPDATE_TIME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_NAME, label: 'menu.conversation.sortByName', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_NAME) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_BY_MODEL, label: 'menu.conversation.sortByModel', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_BY_MODEL) }, + { type: 'separator' }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_ASCENDING, label: 'menu.conversation.sortAscending', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_ASCENDING) }, + { id: CONVERSATION_LIST_MENU_IDS.SORT_DESCENDING, label: 'menu.conversation.sortDescending', type: 'radio', checked: false, click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.SORT_DESCENDING) }, + ] + }, + { + id: CONVERSATION_LIST_MENU_IDS.BATCH_OPERATIONS, + label: 'menu.conversation.batchOperations', + click: () => conversationListMenuItemClick(CONVERSATION_LIST_MENU_IDS.BATCH_OPERATIONS) + } + ]) + + const messageItemMenuItemClick = (id: string) => { + logManager.logUserOperation(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.MESSAGE_ITEM}-${id}`) + window.webContents.send(`${IPC_EVENTS.SHOW_CONTEXT_MENU}:${MENU_IDS.MESSAGE_ITEM}`, id); + } + + menuManager.register(MENU_IDS.MESSAGE_ITEM, [ + { + id: MESSAGE_ITEM_MENU_IDS.COPY, + label: 'menu.message.copyMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.COPY) + }, + { + id: MESSAGE_ITEM_MENU_IDS.SELECT, + label: 'menu.message.selectMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.SELECT) + }, + { type: 'separator' }, + { + id: MESSAGE_ITEM_MENU_IDS.DELETE, + label: 'menu.message.deleteMessage', + click: () => messageItemMenuItemClick(MESSAGE_ITEM_MENU_IDS.DELETE) + }, + ]) +} + +export function setupMainWindow() { + windowManager.onWindowCreate(WINDOW_NAMES.MAIN, (mainWindow) => { + let minimizeToTray = configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY); + configManager.onConfigChange((config) => { + if (minimizeToTray === config[CONFIG_KEYS.MINIMIZE_TO_TRAY]) return; + minimizeToTray = config[CONFIG_KEYS.MINIMIZE_TO_TRAY]; + handleTray(minimizeToTray); + }); + handleTray(minimizeToTray); + registerMenus(mainWindow); + }); + windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); + + ipcMain.on(IPC_EVENTS.START_A_DIALOGUE, async (_event, props: CreateDialogueProps) => { + const { providerName, messages, messageId, selectedModel } = props; + const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); + + if (!mainWindow) { + throw new Error('mainWindow not found'); + } + + try { + + const provider = createProvider(providerName); + const chunks = await provider?.chat(messages, selectedModel); + + if (!chunks) { + throw new Error('chunks or stream not found'); + } + + for await (const chunk of chunks) { + const chunkContent = { + messageId, + data: chunk + } + mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, chunkContent); + } + + } catch (error) { + const errorContent = { + messageId, + data: { + isEnd: true, + isError: true, + result: error instanceof Error ? error.message : String(error), + } + } + + mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, errorContent); + } + }) +} diff --git a/src/main/wins/setting.ts b/src/main/wins/setting.ts new file mode 100644 index 0000000..2d1dee9 --- /dev/null +++ b/src/main/wins/setting.ts @@ -0,0 +1,20 @@ +import { IPC_EVENTS, WINDOW_NAMES } from '@common/constants' +import { ipcMain } from 'electron' +import { windowManager } from '@main/service/window-service' + +export function setupSetttingWindow() { + ipcMain.on(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`, () => { + const settingWindow = windowManager.get(WINDOW_NAMES.SETTING); + if (settingWindow && !settingWindow.isDestroyed()) + return windowManager.focus(settingWindow); + + windowManager.create(WINDOW_NAMES.SETTING, { + width: 800, + height: 600, + minHeight: 600, + minWidth: 800, + }); + }) +} + +export default setupSetttingWindow; diff --git a/src/preload.ts b/src/preload.ts index 31d298a..e4d87eb 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -8,11 +8,12 @@ const api: WindowApi = { open: (url: string) => ipcRenderer.invoke('external-open', url) }, - window: { - minimize: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE), - maximize: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE), - close: () => ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE) - }, + closeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE), + minimizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE), + maximizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE), + onWindowMaximized: (callback: (isMaximized: boolean) => void) => ipcRenderer.on(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', (_, isMaximized) => callback(isMaximized)), + isWindowMaximized: () => ipcRenderer.invoke(IPC_EVENTS.IS_WINDOW_MAXIMIZED), + viewIsReady: () => ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY), app: { setFrameless: (route?: string) => ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route) @@ -63,7 +64,12 @@ const api: WindowApi = { getCurrentWindowId: () => ipcRenderer.sendSync(IPC_EVENTS.GET_WINDOW_ID), // 发送日志 - logToMain: (logLevel: string, message: string) => ipcRenderer.send(IPC_EVENTS.LOG_TO_MAIN, logLevel, message), + logger: { + debug: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_DEBUG, message, ...meta), + info: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_INFO, message, ...meta), + warn: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_WARN, message, ...meta), + error: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_ERROR, message, ...meta), + } } contextBridge.exposeInMainWorld('api', api) \ No newline at end of file diff --git a/src/renderer/auto-imports.d.ts b/src/renderer/auto-imports.d.ts index a0380aa..57d61b8 100644 --- a/src/renderer/auto-imports.d.ts +++ b/src/renderer/auto-imports.d.ts @@ -192,6 +192,7 @@ declare global { const useFullscreen: typeof import('@vueuse/core').useFullscreen const useGamepad: typeof import('@vueuse/core').useGamepad const useGeolocation: typeof import('@vueuse/core').useGeolocation + const useI18n: typeof import('vue-i18n').useI18n const useId: typeof import('vue').useId const useIdle: typeof import('@vueuse/core').useIdle const useImage: typeof import('@vueuse/core').useImage diff --git a/src/renderer/components/HeaderBar/index.vue b/src/renderer/components/HeaderBar/index.vue new file mode 100644 index 0000000..9dcbee0 --- /dev/null +++ b/src/renderer/components/HeaderBar/index.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/renderer/components/NativeTooltip/index.vue b/src/renderer/components/NativeTooltip/index.vue new file mode 100644 index 0000000..42a9b93 --- /dev/null +++ b/src/renderer/components/NativeTooltip/index.vue @@ -0,0 +1,43 @@ + + + \ No newline at end of file diff --git a/src/renderer/components/TitleBar/index.vue b/src/renderer/components/TitleBar/index.vue deleted file mode 100644 index e69de29..0000000 diff --git a/src/renderer/hooks/useWinManager.ts b/src/renderer/hooks/useWinManager.ts index e69de29..5d67d6d 100644 --- a/src/renderer/hooks/useWinManager.ts +++ b/src/renderer/hooks/useWinManager.ts @@ -0,0 +1,31 @@ +export function useWinManager() { + const isMaximized = ref(false) + + function closeWindow() { + window.api.closeWindow(); + } + + function minimizeWindow() { + window.api.minimizeWindow(); + } + + function maximizeWindow() { + window.api.maximizeWindow(); + } + + onMounted(async () => { + await nextTick(); + window.api.viewIsReady(); + isMaximized.value = await window.api.isWindowMaximized(); + window.api.onWindowMaximized((_isMaximized: boolean) => isMaximized.value = _isMaximized); + }) + + return { + isMaximized, + closeWindow, + minimizeWindow, + maximizeWindow + } +}; + +export default useWinManager; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts new file mode 100644 index 0000000..221a302 --- /dev/null +++ b/src/renderer/i18n.ts @@ -0,0 +1,44 @@ +import { createI18n, I18n, type I18nOptions } from 'vue-i18n'; + +const languages = ['zh', 'en'] as const; +type LanguageType = (typeof languages)[number]; + +async function createI18nInstance() { + const options: I18nOptions = { + legacy: false, + locale: 'zh', + fallbackLocale: 'zh', + messages: { + zh: await import('@locales/zh.json').then(m => m.default), + en: await import('@locales/en.json').then(m => m.default), + } + } + + const i18n = createI18n(options); + + return i18n +} + + +export const i18n = await createI18nInstance(); + +export async function setLanguage(lang:LanguageType,_i18n?:I18n){ + const __i18n = _i18n ?? i18n; + + if(__i18n.mode === 'legacy'){ + __i18n.global.locale = lang; + return; + } + + (__i18n.global.locale as unknown as Ref).value = lang; +} + +export function getLanguage(){ + if(i18n.mode === 'legacy'){ + return i18n.global.locale; + } + + return (i18n.global.locale as unknown as Ref).value; +} + +export default i18n; diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 25dc64a..ed0ca32 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,25 +1,37 @@ -import { createApp } from "vue"; -import { createPinia } from "pinia"; -import router from "./router"; -import App from "./App.vue"; +import { createApp, type Plugin } from "vue" +import { createPinia } from "pinia" +import errorHandler from "@utils/errorHandler" +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' // 样式文件隔离 import "./styles/index.css"; import 'element-plus/dist/index.css' +// 引入全局组件 +import HeaderBar from '@components/HeaderBar/index.vue' +import DragRegion from '@components/DragRegion/index.vue' + +const components: Plugin = (app) => { + app.component('HeaderBar', HeaderBar); + app.component('DragRegion', DragRegion); +} + // 创建 Vue 应用实例 const app = createApp(App); +const pinia = createPinia(); // 使用 Pinia 状态管理 -app.use(createPinia()); - - -// 使用 Vue Router +app.use(pinia); app.use(router); app.use(ElementPlus, { locale }) +app.use(components) +app.use(i18n) +app.use(errorHandler) // 挂载应用到 DOM app.mount("#app"); diff --git a/src/renderer/permission.ts b/src/renderer/permission.ts index 055d4d4..f5b11b5 100644 --- a/src/renderer/permission.ts +++ b/src/renderer/permission.ts @@ -15,7 +15,7 @@ router.beforeEach((to: any, _from: any, next: any) => { } else if (isWhiteList(to.path)) { next() } else { - + next() } } else { // no token diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 1c11122..43d79b4 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -1,83 +1,26 @@ -import { createRouter, createWebHistory } from "vue-router"; -import Layout from '@renderer/layout/index.vue' +/* + * @Author: kongbeiwu lishaohua-520@qq.com + * @Date: 2025-12-22 01:28:13 + * @LastEditors: kongbeiwu lishaohua-520@qq.com + * @LastEditTime: 2025-12-22 01:31:48 + * @FilePath: /project/zn-ai/src/renderer/router/index.ts + * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE + */ +import { createRouter, createMemoryHistory } from "vue-router"; const routes = [ - { - path: "/login", - name: "Login", - component: () => import("@renderer/views/login/index.vue"), - }, - { - path: "/browser", - name: "Browser", - component: () => import("@renderer/browser/BrowserLayout.vue"), - meta: { requiresAuth: true }, - }, { path: "/", - component: Layout, - children: [ - { - path: "home", - component: () => import("@renderer/views/home/index.vue"), - name: "Home", - meta: { requiresAuth: true }, - }, - { - path: "stock", - name: "Stock", - component: () => import("@renderer/views/stock/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "rate", - name: "Rate", - component: () => import("@renderer/views/rate/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "knowledge", - name: "Knowledge", - component: () => import("@renderer/views/knowledge/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "order", - name: "Order", - component: () => import("@renderer/views/order/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "more", - name: "More", - component: () => import("@renderer/views/more/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "setting", - name: "Setting", - component: () => import("@renderer/views/setting/index.vue"), - meta: { requiresAuth: true }, - }, - { - path: "/dashboard", - name: "Dashboard", - component: () => import("@renderer/views/dashboard/index.vue"), - meta: { requiresAuth: true }, - }, - ] - }, - { - path: "/about", - name: "About", - component: () => import("@renderer/views/about/index.vue"), - }, + component: () => import("@renderer/views/login/index.vue"), + name: "Login", + meta: { requiresAuth: false }, + } ]; const router = createRouter({ - history: createWebHistory(), + history: createMemoryHistory(), routes, - scrollBehavior(to: any, from: any, savedPosition: any) { + scrollBehavior(_to: any, _from: any, savedPosition: any) { if (savedPosition) { return savedPosition } @@ -86,24 +29,24 @@ const router = createRouter({ }, }); -router.beforeEach((to: any, from: any, next: any) => { - const token = localStorage.getItem("token"); - if (to.meta && (to.meta as any).requiresAuth && !token) { - next({ path: "/login" }); - return; - } +// router.beforeEach((to: any, from: any, next: any) => { +// const token = localStorage.getItem("token"); +// if (to.meta && (to.meta as any).requiresAuth && !token) { +// next({ path: "/login" }); +// return; +// } - if (token && to.path === "/login") { - next({ path: "/home" }); - return; - } +// if (token && to.path === "/login") { +// next({ path: "/home" }); +// return; +// } - if (token && to.path === "/") { - next({ path: "/home" }); - return; - } +// if (token && to.path === "/") { +// next({ path: "/home" }); +// return; +// } - next(); -}); +// next(); +// }); export default router; diff --git a/src/renderer/store/counter.ts b/src/renderer/store/counter.ts deleted file mode 100644 index 062c960..0000000 --- a/src/renderer/store/counter.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - - const doubleCount = computed(() => count.value * 2) - - function increment() { - count.value++ - } - - function decrement() { - count.value-- - } - - function reset() { - count.value = 0 - } - - return { count, doubleCount, increment, decrement, reset } -}) \ No newline at end of file diff --git a/src/renderer/store/userinfo.ts b/src/renderer/store/userinfo.ts new file mode 100644 index 0000000..da830d4 --- /dev/null +++ b/src/renderer/store/userinfo.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { authOauth2TokenUsingPost } from "@renderer/api" +import { getToken, setToken, removeToken } from '@utils/auth' + +export const useUserStore = defineStore('userInfo', { + state: () => ({ + token: getToken(), + }), + + actions: { + /** + * 登录方法 + * @function login + * @async + * @param {Object} data - 登录数据 + * @returns {Promise} + */ + async login(data: LoginForm) { + data.grant_type = 'password'; + data.scope = 'server'; + + return new Promise((resolve, reject) => { + authOauth2TokenUsingPost({body: {...data, clientId: ''}}) + .then((res: any) => { + // 存储token 信息 + setToken(res.access_token) + resolve(res) + }) + .catch((err) => { + reject(err); + }); + }); + }, + + // 退出系统 + logOut() { + return new Promise((resolve, reject) => { + // logout(this.token).then(() => { + // this.token = '' + // this.roles = [] + // this.permissions = [] + // removeToken() + // resolve() + // }).catch(error => { + // reject(error) + // }) + }) + } + } +}) \ No newline at end of file diff --git a/src/renderer/utils/errorHandler.ts b/src/renderer/utils/errorHandler.ts new file mode 100644 index 0000000..1aebca0 --- /dev/null +++ b/src/renderer/utils/errorHandler.ts @@ -0,0 +1,19 @@ +import type { Plugin } from 'vue' +import logger from './logger' + +export const errorHandler: Plugin = (app) => { + app.config.errorHandler = (err, instance, info) => { + // 过滤掉无法序列化的 Vue 实例对象 + logger.error('Vue error:', err, info); + }; + + window.onerror = (message, source, lineno, colno, error) => { + logger.error('Window error:', message, source, lineno, colno, error); + }; + + window.onunhandledrejection = (event) => { + logger.error('Unhandled Promise Rejection:', event); + }; +}; + +export default errorHandler; diff --git a/src/renderer/utils/logger.ts b/src/renderer/utils/logger.ts new file mode 100644 index 0000000..7262313 --- /dev/null +++ b/src/renderer/utils/logger.ts @@ -0,0 +1,74 @@ +const safeStringify = (arg: any) => { + try { + // 处理 Error 对象 + if (arg instanceof Error) { + return { + message: arg.message, + stack: arg.stack, + name: arg.name + } + } + // 简单值直接返回 + if (typeof arg !== 'object' || arg === null) { + return arg + } + // 处理 Vue 响应式对象(Proxy) + if (arg?.__v_isRef || arg?.__v_isReactive || arg?.__v_isReadonly) { + // 尝试解包 Proxy/Ref + try { + const raw = JSON.parse(JSON.stringify(arg)) + return raw + } catch (e) { + return '[Vue Reactive Object]' + } + } + // 尝试深拷贝,如果失败则说明包含不可序列化对象 + const raw = JSON.parse(JSON.stringify(arg)) + return raw + } catch (e) { + // 序列化失败,返回字符串描述 + return String(arg) + } +} + +// 缓存原始 console 方法,防止递归调用和保持控制台输出 +const originalConsole = { + debug: console.debug.bind(console), + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +} + +const createSafeLogger = (originalLogger: any) => { + return { + debug: (message: string, ...meta: any[]) => { + originalConsole.debug(message, ...meta) + originalLogger.debug(String(message), ...meta.map(safeStringify)) + }, + info: (message: string, ...meta: any[]) => { + originalConsole.info(message, ...meta) + originalLogger.info(String(message), ...meta.map(safeStringify)) + }, + warn: (message: string, ...meta: any[]) => { + originalConsole.warn(message, ...meta) + originalLogger.warn(String(message), ...meta.map(safeStringify)) + }, + error: (message: string, ...meta: any[]) => { + originalConsole.error(message, ...meta) + originalLogger.error(String(message), ...meta.map(safeStringify)) + }, + } +} + +export const logger = window.api.logger ? createSafeLogger(window.api.logger) : console + +if (window.api.logger) { + console.debug = logger.debug; + console.log = logger.info; + console.info = logger.info; + console.warn = logger.warn; + console.error = logger.error; +} + +export default logger; diff --git a/src/renderer/views/dialog/index.ts b/src/renderer/views/dialog/index.ts new file mode 100644 index 0000000..a075b40 --- /dev/null +++ b/src/renderer/views/dialog/index.ts @@ -0,0 +1,15 @@ +import '@renderer/styles/index.css' + +import errorHandler from '@utils/errorHandler' +import i18n from '@renderer/i18n' +import HeaderBar from '@renderer/components/HeaderBar/index.vue' +import DragRegion from '@renderer/components/DragRegion/index.vue' + +import Dialog from './index.vue' + +createApp(Dialog) + .use(i18n) + .use(errorHandler) + .component('HeaderBar', HeaderBar) + .component('DragRegion', DragRegion) + .mount('#app') diff --git a/src/renderer/views/dialog/index.vue b/src/renderer/views/dialog/index.vue new file mode 100644 index 0000000..6f0fa1b --- /dev/null +++ b/src/renderer/views/dialog/index.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/renderer/views/login/index.ts b/src/renderer/views/login/index.ts new file mode 100644 index 0000000..5f12f73 --- /dev/null +++ b/src/renderer/views/login/index.ts @@ -0,0 +1,16 @@ +import '@renderer/styles/index.css' + +import errorHandler from '@utils/errorHandler' +import i18n from '@renderer/i18n' +import HeaderBar from '@renderer/components/HeaderBar/index.vue' +import DragRegion from '@renderer/components/DragRegion/index.vue' + +import Login from './index.vue' + +createApp(Login) + .use(i18n) + .use(createPinia()) + .use(errorHandler) + .component('HeaderBar', HeaderBar) + .component('DragRegion', DragRegion) + .mount('#app') diff --git a/src/renderer/views/login/index.vue b/src/renderer/views/login/index.vue index 4ccd9ca..56190c0 100644 --- a/src/renderer/views/login/index.vue +++ b/src/renderer/views/login/index.vue @@ -1,102 +1,98 @@