diff --git a/electron/main/common/conf.ts b/electron/main/common/cache-conf.ts similarity index 100% rename from electron/main/common/conf.ts rename to electron/main/common/cache-conf.ts diff --git a/electron/main/common/fs-utils.ts b/electron/main/common/fs-utils.ts index 27b80ad9081b3b4653075ab6a73e18cb922144d5..5269ce00053747212dd67e9c97e778326c3964fb 100644 --- a/electron/main/common/fs-utils.ts +++ b/electron/main/common/fs-utils.ts @@ -8,7 +8,6 @@ // PURPOSE. // See the Mulan PSL v2 for more details. import fs from 'node:fs'; -import path from 'node:path'; /** * 创建目录(如果不存在) @@ -51,22 +50,3 @@ export function getUserDefinedConf(dir: string): Record { return {}; } } - -/** - * 检查配置文件是否存在,不存在则创建默认内容 - * @param filePath 配置文件路径 - * @param defaultContent 默认内容对象 - */ -export function ensureConfigFile(filePath: string, defaultContent: object) { - try { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, JSON.stringify(defaultContent, null, 4)); - } - } catch { - // ignore - } -} diff --git a/electron/main/common/ipc.ts b/electron/main/common/ipc.ts index 9f179126d015ec59cbddb1d8c7d6018888ef3bda..360a3289fbfbbb2ff6233f91d767aa5985770310 100644 --- a/electron/main/common/ipc.ts +++ b/electron/main/common/ipc.ts @@ -9,6 +9,10 @@ // See the Mulan PSL v2 for more details. import { ipcMain, BrowserWindow } from 'electron'; import { toggleTheme, setSystemTheme } from './theme'; +import { getConfigManager, DesktopConfig } from './config'; +import { completeWelcomeFlow, showWelcomeWindow } from '../window/welcome'; +import https from 'https'; +import http from 'http'; /** * 注册所有IPC监听器 @@ -16,6 +20,8 @@ import { toggleTheme, setSystemTheme } from './theme'; export function registerIpcListeners(): void { registerThemeListeners(); registerWindowControlListeners(); + registerConfigListeners(); + registerWelcomeListeners(); } /** @@ -68,3 +74,170 @@ function registerWindowControlListeners(): void { return false; }); } + +/** + * 注册配置管理相关的IPC监听器 + */ +function registerConfigListeners(): void { + const configManager = getConfigManager(); + + // 获取配置 + ipcMain.handle('copilot:get-config', () => { + try { + return configManager.readConfig(); + } catch (error) { + console.error('Failed to get config:', error); + return null; + } + }); + + // 更新配置 + ipcMain.handle( + 'copilot:update-config', + (event, updates: Partial) => { + try { + return configManager.updateConfig(updates); + } catch (error) { + console.error('Failed to update config:', error); + return null; + } + }, + ); + + // 重置配置 + ipcMain.handle('copilot:reset-config', () => { + try { + configManager.resetConfig(); + return configManager.readConfig(); + } catch (error) { + console.error('Failed to reset config:', error); + return null; + } + }); + + // 获取代理URL + ipcMain.handle('copilot:get-proxy-url', () => { + try { + return configManager.getConfigValue('base_url') || ''; + } catch (error) { + console.error('Failed to get proxy URL:', error); + return ''; + } + }); + + // 设置代理URL + ipcMain.handle('copilot:set-proxy-url', (event, url: string) => { + try { + configManager.setConfigValue('base_url', url); + return true; + } catch (error) { + console.error('Failed to set proxy URL:', error); + return false; + } + }); + + // 验证服务器连接 + ipcMain.handle('copilot:validate-server', async (event, url: string) => { + try { + return await validateServerConnection(url); + } catch (error) { + console.error('Failed to validate server:', error); + return { + isValid: false, + error: + error instanceof Error ? error.message : '验证服务器时发生未知错误', + }; + } + }); +} + +/** + * 注册欢迎界面相关的IPC监听器 + */ +function registerWelcomeListeners(): void { + // 显示欢迎界面 + ipcMain.handle('copilot:show-welcome', () => { + try { + showWelcomeWindow(); + return true; + } catch (error) { + console.error('Failed to show welcome window:', error); + return false; + } + }); + + // 完成欢迎流程 + ipcMain.handle('copilot:complete-welcome', async () => { + try { + await completeWelcomeFlow(); + return true; + } catch (error) { + console.error('Failed to complete welcome flow:', error); + return false; + } + }); +} + +/** + * 验证服务器连接性 + */ +async function validateServerConnection(url: string): Promise<{ + isValid: boolean; + error?: string; + status?: number; + responseTime?: number; +}> { + try { + const startTime = Date.now(); + const parsedUrl = new URL(url); + const isHttps = parsedUrl.protocol === 'https:'; + const requestModule = isHttps ? https : http; + + return new Promise((resolve) => { + const timeout = 10000; // 10秒超时 + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: '/', + method: 'HEAD', + timeout, + // 对于HTTPS,忽略证书错误(开发环境) + rejectUnauthorized: false, + }; + + const req = requestModule.request(options, (res) => { + const responseTime = Date.now() - startTime; + req.destroy(); + + resolve({ + isValid: res.statusCode ? res.statusCode < 500 : false, + status: res.statusCode, + responseTime, + }); + }); + + req.on('error', (error: Error) => { + resolve({ + isValid: false, + error: error.message, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + isValid: false, + error: '连接超时', + }); + }); + + req.end(); + }); + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : '无效的URL格式', + }; + } +} diff --git a/electron/main/common/locale.ts b/electron/main/common/locale.ts index 8504981418c170e95c12f62d169babcbe2dfba5d..a2d6daa99b739b11a014a09b2e91c464c8aa50f3 100644 --- a/electron/main/common/locale.ts +++ b/electron/main/common/locale.ts @@ -9,7 +9,7 @@ // See the Mulan PSL v2 for more details. import { app } from 'electron'; import type { INLSConfiguration } from './nls'; -import { updateConf } from './conf'; +import { updateConf } from './cache-conf'; /** * 处理中文语言环境 diff --git a/electron/main/common/theme.ts b/electron/main/common/theme.ts index 0b1256849398416a369cd1693b4de85707fc1355..006e1ca564ebbf882b6823aaa2c3f8166c840ec2 100644 --- a/electron/main/common/theme.ts +++ b/electron/main/common/theme.ts @@ -8,7 +8,7 @@ // PURPOSE. // See the Mulan PSL v2 for more details. import { nativeTheme } from 'electron'; -import { updateConf } from './conf'; +import { updateConf } from './cache-conf'; export type ThemeType = 'system' | 'light' | 'dark'; diff --git a/electron/main/index.ts b/electron/main/index.ts index 926bff1779c860a4b6474ccb19dff5c7194b8f81..e053208d96db39f35013cec2b9866022fe4a3ec7 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -8,13 +8,14 @@ // PURPOSE. // See the Mulan PSL v2 for more details. import { app, session, ipcMain, Menu, BrowserWindow } from 'electron'; -import { createDefaultWindow, createChatWindow, createTray } from './window'; -import { cachePath, commonCacheConfPath } from './common/conf'; import { - mkdirpIgnoreError, - getUserDefinedConf, - ensureConfigFile, -} from './common/fs-utils'; + createDefaultWindow, + createChatWindow, + createTray, + checkAndShowWelcomeIfNeeded, +} from './window'; +import { cachePath, commonCacheConfPath } from './common/cache-conf'; +import { mkdirpIgnoreError, getUserDefinedConf } from './common/fs-utils'; import { getOsLocale, resolveNlsConfiguration } from './common/locale'; import { resolveThemeConfiguration, setApplicationTheme } from './common/theme'; import { productObj } from './common/product'; @@ -25,8 +26,6 @@ import { } from './common/shortcuts'; import { buildAppMenu } from './common/menu'; import { registerIpcListeners } from './common/ipc'; -import * as path from 'node:path'; -import { homedir } from 'node:os'; // 允许本地部署时使用无效证书,仅在 Electron 主进程下生效 if (process.versions.electron) { @@ -42,30 +41,6 @@ let isQuitting = false; // 获取系统语言环境 const osLocale = getOsLocale(); -// 启动时确保 smart-shell.json 存在 -const configDir = path.join( - process.platform === 'linux' - ? path.join( - process.env['XDG_CONFIG_HOME'] || path.join(homedir(), '.config'), - ) - : path.join(homedir(), '.config'), - 'eulercopilot', -); -const configPath = path.join(configDir, 'smart-shell.json'); -const defaultConfig = { - backend: 'openai', - openai: { - base_url: '', - model: '', - api_key: '', - }, - eulercopilot: { - base_url: 'https://www.eulercopilot.local', - api_key: '', - }, -}; -ensureConfigFile(configPath, defaultConfig); - // 应用初始化 app.once('ready', () => { onReady(); @@ -112,6 +87,28 @@ app.on('activate', () => { * 应用准备好时的处理函数 */ async function onReady() { + try { + // 检查配置文件是否存在,如果不存在则显示欢迎界面 + const shouldShowWelcome = checkAndShowWelcomeIfNeeded(); + + if (shouldShowWelcome) { + console.log('First time startup, showing welcome window'); + // 如果是首次启动,显示欢迎界面后等待用户完成配置 + // 用户完成配置时会触发 continueAppStartup 函数 + return; + } + + // 继续正常的应用启动流程 + await continueAppStartup(); + } catch (error) { + console.error('Application startup error:', error); + } +} + +/** + * 继续应用启动(在配置完成后调用) + */ +export async function continueAppStartup() { try { // 初始化缓存目录和配置 await mkdirpIgnoreError(cachePath); @@ -155,7 +152,7 @@ async function onReady() { ); } } catch (error) { - console.error('Application startup error:', error); + console.error('Continue app startup error:', error); } } @@ -210,45 +207,3 @@ async function startup() { chatWindow.hide(); }); } - -// 定义配置类型,避免使用 any -interface Config { - backend: string; - openai: { - base_url: string; - model: string; - api_key: string; - }; - eulercopilot: { - base_url: string; - api_key: string; - }; -} - -// 注册获取代理URL的IPC -ipcMain.handle('copilot:get-proxy-url', async () => { - try { - // 兼容Linux/macOS,配置文件路径 - const configDir = path.join( - process.platform === 'linux' - ? path.join( - process.env['XDG_CONFIG_HOME'] || path.join(homedir(), '.config'), - ) - : path.join(homedir(), '.config'), - 'eulercopilot', - ); - const configPath = path.join(configDir, 'smart-shell.json'); - const confRaw = getUserDefinedConf(configPath); - const conf = confRaw as unknown as Config | undefined; - if ( - conf && - conf.eulercopilot && - typeof conf.eulercopilot.base_url === 'string' - ) { - return conf.eulercopilot.base_url || ''; - } - return ''; - } catch { - return ''; - } -}); diff --git a/electron/main/window/create.ts b/electron/main/window/create.ts index 5fdafeed8d29ec567257c4f44d4090c4c86a406d..728e8f12c5b348bbf8c0cd3445ae17e0d3f27102 100644 --- a/electron/main/window/create.ts +++ b/electron/main/window/create.ts @@ -11,7 +11,7 @@ import path from 'node:path'; import * as electron from 'electron'; import { BrowserWindow, app, ipcMain, Menu } from 'electron'; import { options as allWindow } from './options'; -import { updateConf } from '../common/conf'; +import { updateConf } from '../common/cache-conf'; import { isLinux } from '../common/platform'; // 存储所有创建的窗口实例,用于全局访问 diff --git a/electron/main/window/index.ts b/electron/main/window/index.ts index b7773d63f389fb870c652544dd9157291a697817..db024dc5116442702ca07677fdcecbb19a654717 100644 --- a/electron/main/window/index.ts +++ b/electron/main/window/index.ts @@ -11,9 +11,27 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import { createDefaultWindow, createChatWindow } from './create'; import { createTray } from './tray'; // 导入createTray函数 +import { + createWelcomeWindow, + showWelcomeWindow, + hideWelcomeWindow, + closeWelcomeWindow, + checkAndShowWelcomeIfNeeded, + completeWelcomeFlow, +} from './welcome'; // 重新导出以便在index.ts中使用 -export { createDefaultWindow, createChatWindow, createTray }; +export { + createDefaultWindow, + createChatWindow, + createTray, + createWelcomeWindow, + showWelcomeWindow, + hideWelcomeWindow, + closeWelcomeWindow, + checkAndShowWelcomeIfNeeded, + completeWelcomeFlow, +}; // 存储所有窗口引用 const allWindows: BrowserWindow[] = []; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c6165756904d4dc1037f5be671b542f88e643218..4181467b008546f883ee5bba181251e3ea0eed43 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -54,13 +54,89 @@ const globals = { validateIPC(channel); ipcRenderer.removeAllListeners(channel); }, + }, + + // 配置管理 API + config: { + // 获取完整配置 + get: async (): Promise => { + return await ipcRenderer.invoke('copilot:get-config'); + }, + + // 更新配置(部分更新) + update: async (updates: Record): Promise => { + return await ipcRenderer.invoke('copilot:update-config', updates); + }, + + // 重置配置 + reset: async (): Promise => { + return await ipcRenderer.invoke('copilot:reset-config'); + }, - // 动态获取代理URL + // 设置代理 URL + setProxyUrl: async (url: string): Promise => { + return await ipcRenderer.invoke('copilot:set-proxy-url', url); + }, + + // 获取代理 URL getProxyUrl: async (): Promise => { return await ipcRenderer.invoke('copilot:get-proxy-url'); }, }, + // 欢迎界面 API + welcome: { + // 显示欢迎界面 + show: async (): Promise => { + return await ipcRenderer.invoke('copilot:show-welcome'); + }, + + // 完成欢迎流程 + complete: async (): Promise => { + return await ipcRenderer.invoke('copilot:complete-welcome'); + }, + }, + + // 窗口控制 API + window: { + // 窗口控制 + control: async ( + command: 'minimize' | 'maximize' | 'close', + ): Promise => { + return await ipcRenderer.invoke('copilot:window-control', command); + }, + + // 检查窗口是否最大化 + isMaximized: async (): Promise => { + return await ipcRenderer.invoke('copilot:window-is-maximized'); + }, + + // 监听窗口最大化状态变化 + onMaximizedChange: (callback: (isMaximized: boolean) => void): void => { + ipcRenderer.on('window-maximized-change', (event, isMaximized) => + callback(isMaximized), + ); + }, + + // 移除窗口最大化状态变化监听 + offMaximizedChange: (): void => { + ipcRenderer.removeAllListeners('window-maximized-change'); + }, + }, + + // 主题 API + theme: { + // 切换主题 + toggle: async (): Promise => { + return await ipcRenderer.invoke('copilot:toggle'); + }, + + // 设置系统主题 + setSystem: async (): Promise => { + return await ipcRenderer.invoke('copilot:system'); + }, + }, + process: { get platform() { return process.platform; diff --git a/scripts/build-preload.ts b/scripts/build-preload.ts index 6b3765628ef9acc5a93771679053dea0ccdb3ed4..0b05d66ebb5e4c5723badcfad8f80934b3850160 100644 --- a/scripts/build-preload.ts +++ b/scripts/build-preload.ts @@ -1,39 +1,183 @@ -import type { OutputOptions } from 'rollup' -import { watch, rollup } from 'rollup' -import minimist from 'minimist' -import chalk from 'chalk' -import ora from 'ora' -import options from './rollup.config' - -const argv = minimist(process.argv.slice(2)) -const opt = options({ proc: 'preload', env: argv.env }) -const TAG = '[build-preload.ts]' -const spinner = ora(`${TAG} Electron preload build...`) - -;(async () => { - if (argv.watch) { - const watcher = watch(opt) - watcher.on('change', (filename) => { - const log = chalk.yellow(`change -- ${filename}`) - console.log(TAG, log) - - /** - * @todo Hot reload render process !!! - */ - }) +import type { OutputOptions } from 'rollup'; +import { watch, rollup } from 'rollup'; +import minimist from 'minimist'; +import chalk from 'chalk'; +import ora from 'ora'; +import path from 'path'; +import type { RollupOptions } from 'rollup'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; +import commonjs from '@rollup/plugin-commonjs'; +import replace from '@rollup/plugin-replace'; +import alias from '@rollup/plugin-alias'; +import json from '@rollup/plugin-json'; +import copy from 'rollup-plugin-copy'; +import { builtins, getEnv } from './utils'; + +const argv = minimist(process.argv.slice(2)); +const TAG = '[build-preload.ts]'; + +interface PreloadEntry { + name: string; + input: string; + output: string; +} + +// 定义所有 preload 入口点 +const preloadEntries: PreloadEntry[] = [ + { + name: 'main', + input: path.join(__dirname, '../electron/preload/index.ts'), + output: path.join(__dirname, '../dist/preload/index.js'), + }, + { + name: 'welcome', + input: path.join(__dirname, '../electron/preload/welcome.ts'), + output: path.join(__dirname, '../dist/preload/welcome.js'), + }, +]; + +function createRollupConfig(entry: PreloadEntry): RollupOptions { + const compilationInclude = ['electron/**/*.ts']; + + const plugins: any[] = [ + nodeResolve({ + extensions: ['.ts', '.js', 'json'], + }), + commonjs({ + include: compilationInclude, + }), + json(), + typescript({ + sourceMap: false, + noEmitOnError: true, + include: compilationInclude, + }), + alias({ + entries: { + '@': path.join(__dirname, '../src'), + }, + }), + replace({ + ...Object.entries({ ...getEnv(), NODE_ENV: argv.env }).reduce( + (acc, [k, v]) => + Object.assign(acc, { [`process.env.${k}`]: JSON.stringify(v) }), + {}, + ), + preventAssignment: true, + }), + ]; + + // 只在主 preload 构建时复制静态资源 + if (entry.name === 'main') { + plugins.push( + copy({ + targets: [ + { src: 'build/trayTemplate.png', dest: 'dist' }, + { src: 'build/tray.png', dest: 'dist' }, + { src: 'electron/welcome', dest: 'dist' }, // 复制 welcome 文件夹 + ], + }), + ); } - else { - spinner.start() - try { - const build = await rollup(opt) - await build.write(opt.output as OutputOptions) - spinner.succeed() - process.exit() + + return { + input: entry.input, + output: { + file: entry.output, + format: 'cjs', + sourcemap: false, + }, + plugins, + external: [...builtins(), 'electron'], + onwarn: (warning) => { + if (warning.code !== 'CIRCULAR_DEPENDENCY') { + console.error(`(!) ${warning.message}`); + } + }, + }; +} + +async function buildEntry(entry: PreloadEntry) { + const spinner = ora(`${TAG} Building ${entry.name} preload...`); + + try { + spinner.start(); + const config = createRollupConfig(entry); + const build = await rollup(config); + await build.write(config.output as OutputOptions); + spinner.succeed(`${TAG} ${entry.name} preload built successfully`); + await build.close(); + } catch (error) { + console.log( + `\n${TAG} ${chalk.red(`${entry.name} preload 构建报错`)}\n`, + error, + '\n', + ); + spinner.fail(`${TAG} ${entry.name} preload build failed`); + throw error; + } +} + +async function watchEntry(entry: PreloadEntry) { + const config = createRollupConfig(entry); + const watcher = watch(config); + + watcher.on('event', (event) => { + if (event.code === 'START') { + console.log(`${TAG} ${chalk.blue(`${entry.name} preload building...`)}`); + } else if (event.code === 'END') { + console.log( + `${TAG} ${chalk.green(`${entry.name} preload built successfully`)}`, + ); + } else if (event.code === 'ERROR') { + console.log( + `${TAG} ${chalk.red(`${entry.name} preload build error:`)}`, + event.error, + ); } - catch (error) { - console.log(`\n${TAG} ${chalk.red('构建报错')}\n`, error, '\n') - spinner.fail() - process.exit(1) + }); + + watcher.on('change', (filename) => { + const log = chalk.yellow(`change -- ${filename}`); + console.log(`${TAG} [${entry.name}]`, log); + }); + + return watcher; +} + +(async () => { + if (argv.watch) { + console.log( + `${TAG} ${chalk.blue('Starting watch mode for all preload scripts...')}`, + ); + + const watchers = await Promise.all( + preloadEntries.map((entry) => watchEntry(entry)), + ); + + // 处理 Ctrl+C 退出 + process.on('SIGINT', () => { + console.log(`\n${TAG} ${chalk.yellow('Closing watchers...')}`); + watchers.forEach((watcher) => watcher.close()); + process.exit(0); + }); + } else { + console.log(`${TAG} ${chalk.blue('Building all preload scripts...')}`); + + try { + // 串行构建所有 preload 脚本 + for (const entry of preloadEntries) { + await buildEntry(entry); + } + + console.log( + `${TAG} ${chalk.green('All preload scripts built successfully!')}`, + ); + process.exit(0); + } catch { + console.log(`${TAG} ${chalk.red('Build failed!')}`); + process.exit(1); } } -})() +})(); diff --git a/src/utils/tools.ts b/src/utils/tools.ts index d4c830b867888c41ba0e7bbd0a75435b65f29a3e..10b365dfd99236ecf0441ab68c188f788a5c687c 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -69,7 +69,11 @@ export const writeText = (text: string): void => { textArea.focus(); textArea.select(); new Promise((res, rej) => { - document.execCommand('copy') ? res() : rej(new Error(i18n.global.t('semantic.copyFailed'))); + if (document.execCommand('copy')) { + res(); + } else { + rej(new Error(i18n.global.t('semantic.copyFailed'))); + } textArea.remove(); }); } @@ -80,13 +84,17 @@ export const writeText = (text: string): void => { */ export async function getBaseProxyUrl(): Promise { // Electron 生产环境(file:协议)读取配置 - if ( - window.eulercopilot && - typeof window.eulercopilot.ipcRenderer?.getProxyUrl === 'function' && - window.location.protocol === 'file:' - ) { - const url = await window.eulercopilot.ipcRenderer.getProxyUrl(); - if (url) return url; + if (window.eulercopilot && window.location.protocol === 'file:') { + try { + if (typeof window.eulercopilot.config?.get === 'function') { + const config = await window.eulercopilot.config.get(); + if (config?.base_url) { + return config.base_url; + } + } + } catch (error) { + console.warn('Failed to get base URL from config:', error); + } } // 本地开发环境(localhost:3000),直接返回空字符串,确保 axios 只拼接 path if (window.location.hostname === 'localhost') { @@ -102,13 +110,19 @@ export async function getBaseProxyUrl(): Promise { */ export async function getBaseUrl(): Promise { // Electron 生产环境(file:协议)读取配置 - if ( - window.eulercopilot && - typeof window.eulercopilot.ipcRenderer?.getProxyUrl === 'function' - ) { - const url = await window.eulercopilot.ipcRenderer.getProxyUrl(); - if (url) return url; + if (window.eulercopilot) { + try { + if (typeof window.eulercopilot.config?.get === 'function') { + const config = await window.eulercopilot.config.get(); + if (config?.base_url) { + return config.base_url; + } + } + } catch (error) { + console.warn('Failed to get base URL from config:', error); + } } + // VITE_BASE_API_URL 未定义时返回空字符串 const viteProxyUrl = import.meta.env.VITE_BASE_PROXY_URL; return typeof viteProxyUrl === 'string' && viteProxyUrl ? viteProxyUrl : '';