feat: TypeScript + Electron v2 重构 - 纯桌面版

- 全面迁移到 TypeScript,严格类型定义
- 放弃 Web 版,专注 Electron 桌面应用
- 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts
- 渲染进程完整迁移所有功能组件
- 删除 PWA 相关文件 (sw.js, manifest.json)
- 删除 Web 版降级逻辑
- 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理
- 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
thzxx
2026-04-06 03:04:20 +08:00
parent 0924497cd6
commit 7ca0e33d77
33 changed files with 7265 additions and 15 deletions
+72
View File
@@ -0,0 +1,72 @@
/**
* Metona Ollama Desktop - IPC 处理器
*/
import { ipcMain, dialog, shell } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { mainWindow } from './main.js';
import { showNotification } from './utils.js';
export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile', 'multiSelections'],
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
});
if (result.canceled) return null;
return result.filePaths;
});
ipcMain.handle('dialog:saveFile', async (_, options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => {
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: options?.defaultPath || 'export',
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
});
if (result.canceled) return null;
return result.filePath;
});
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try {
const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, content, name: path.basename(filePath) };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
try {
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
ipcMain.handle('notify', (_: unknown, title: string, body: string) => {
showNotification(title, body);
});
ipcMain.handle('app:info', () => ({
version: require('electron').app.getVersion(),
platform: process.platform,
arch: process.arch,
electronVersion: process.versions.electron,
userDataPath: require('electron').app.getPath('userData')
}));
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
ipcMain.handle('window:maximize', () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
else mainWindow?.maximize();
});
ipcMain.handle('window:close', () => mainWindow?.close());
ipcMain.handle('shell:openExternal', (_, url: string) => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
shell.openExternal(url);
}
});
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Metona Ollama Desktop - 主进程入口
*/
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { setupIPC } from './ipc.js';
import { createTray } from './tray.js';
import { createMenu } from './menu.js';
import { showNotification } from './utils.js';
const APP_NAME = 'Metona Ollama';
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
const ICO_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.ico');
const IS_DEV = !app.isPackaged;
export let mainWindow: BrowserWindow | null = null;
export let isQuitting = false;
export function getIconPath(): string {
return process.platform === 'win32' ? ICO_PATH : ICON_PATH;
}
function createMainWindow(): BrowserWindow {
const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'window-state.json');
let windowState = { width: 1200, height: 800, x: undefined as number | undefined, y: undefined as number | undefined, maximized: false };
try {
if (fs.existsSync(configPath)) {
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch { /* use defaults */ }
mainWindow = new BrowserWindow({
width: windowState.width || 1200,
height: windowState.height || 800,
x: windowState.x,
y: windowState.y,
minWidth: 800,
minHeight: 600,
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH,
title: APP_NAME,
backgroundColor: '#202020',
show: false,
autoHideMenuBar: true,
menuBarVisible: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: false,
allowRunningInsecureContent: false
}
});
mainWindow.loadFile(path.join(__dirname, '..', '..', 'index.html'));
mainWindow.setMenuBarVisibility(false);
mainWindow.once('ready-to-show', () => {
if (windowState.maximized) {
mainWindow!.maximize();
}
mainWindow!.show();
});
const saveWindowState = (): void => {
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
const bounds = mainWindow.getBounds();
try {
fs.writeFileSync(configPath, JSON.stringify(bounds));
} catch { /* ignore */ }
};
mainWindow.on('resize', saveWindowState);
mainWindow.on('move', saveWindowState);
mainWindow.on('maximize', () => {
try {
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
state.maximized = true;
fs.writeFileSync(configPath, JSON.stringify(state));
} catch { /* ignore */ }
});
mainWindow.on('unmaximize', () => {
try {
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
state.maximized = false;
fs.writeFileSync(configPath, JSON.stringify(state));
} catch { /* ignore */ }
});
mainWindow.on('close', (e) => {
if (!isQuitting) {
e.preventDefault();
mainWindow!.hide();
if (!(global as Record<string, unknown>)._trayHintShown) {
(global as Record<string, unknown>)._trayHintShown = true;
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
}
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
const { shell } = require('electron');
shell.openExternal(url);
return { action: 'deny' as const };
}
return { action: 'allow' as const };
});
return mainWindow;
}
// ── 单实例锁 ──
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
app.whenReady().then(() => {
setupIPC();
createMainWindow();
createTray();
createMenu();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// keep running with tray
}
});
app.on('before-quit', () => {
isQuitting = true;
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Metona Ollama Desktop - 原生菜单
*/
import { Menu, dialog, shell } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js';
export function createMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: '文件',
submenu: [
{
label: '新建会话',
accelerator: 'Ctrl+N',
click: () => mainWindow?.webContents.send('menu-action', 'new-chat')
},
{ type: 'separator' },
{
label: '导入会话',
click: () => mainWindow?.webContents.send('menu-action', 'import')
},
{
label: '导出会话',
accelerator: 'Ctrl+Shift+E',
click: () => mainWindow?.webContents.send('menu-action', 'export')
},
{ type: 'separator' },
{
label: '退出',
accelerator: 'Ctrl+Q',
click: () => {
const { app } = require('electron');
(require('./main.js') as Record<string, unknown>).isQuitting = true;
app.quit();
}
}
]
},
{
label: '编辑',
submenu: [
{ role: 'undo', label: '撤销' },
{ role: 'redo', label: '重做' },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' }
]
},
{
label: '视图',
submenu: [
{ role: 'reload', label: '刷新' },
{ role: 'forceReload', label: '强制刷新' },
{ type: 'separator' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' },
{ role: 'resetZoom', label: '重置缩放' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: '全屏' }
]
},
{
label: '窗口',
submenu: [
{ role: 'minimize', label: '最小化' },
{
label: '最大化/还原',
click: () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
else mainWindow?.maximize();
}
},
{ type: 'separator' },
{
label: '置顶',
type: 'checkbox',
checked: false,
click: (menuItem: Electron.MenuItem) => {
mainWindow?.setAlwaysOnTop(menuItem.checked);
}
}
]
},
{
label: '帮助',
submenu: [
{
label: '使用帮助',
click: () => mainWindow?.webContents.send('menu-action', 'help')
},
{ type: 'separator' },
{
label: '关于 Ollama',
click: () => shell.openExternal('https://ollama.com')
},
{
label: '关于 Metona',
click: () => {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v2.0.0',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
}
}
]
}
];
const IS_DEV = !require('electron').app.isPackaged;
if (IS_DEV) {
template.push({
label: 'Dev',
submenu: [
{
label: '切换开发者工具',
accelerator: 'F12',
click: () => mainWindow?.webContents.toggleDevTools()
}
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Metona Ollama Desktop - Preload 脚本
*/
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('metonaDesktop', {
isDesktop: true,
info: () => ipcRenderer.invoke('app:info'),
dialog: {
openFile: (filters?: unknown) => ipcRenderer.invoke('dialog:openFile', { filters }),
saveFile: (options?: unknown) => ipcRenderer.invoke('dialog:saveFile', options)
},
fs: {
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content)
},
notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body),
window: {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close')
},
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
onMenuAction: (callback: (action: string) => void) => {
ipcRenderer.on('menu-action', (_: unknown, action: string) => callback(action));
},
onTrayAction: (callback: (action: string) => void) => {
ipcRenderer.on('tray-action', (_: unknown, action: string) => callback(action));
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
}
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Metona Ollama Desktop - 系统托盘
*/
import { Tray, Menu, nativeImage } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js';
let tray: Tray | null = null;
export function createTray(): void {
const icon = nativeImage.createFromPath(getIconPath());
tray = new Tray(icon.resize({ width: 16, height: 16 }));
const contextMenu = Menu.buildFromTemplate([
{
label: '打开 Metona',
click: () => {
mainWindow?.show();
mainWindow?.focus();
}
},
{ type: 'separator' },
{
label: '新建会话',
click: () => {
mainWindow?.show();
mainWindow?.focus();
mainWindow?.webContents.send('tray-action', 'new-chat');
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
const main = require('./main.js');
main.isQuitting = true;
require('electron').app.quit();
}
}
]);
tray.setToolTip('Metona Ollama');
tray.setContextMenu(contextMenu);
tray.on('double-click', () => {
mainWindow?.show();
mainWindow?.focus();
});
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Metona Ollama Desktop - 主进程工具函数
*/
import { Notification } from 'electron';
import * as path from 'path';
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
export function showNotification(title: string, body: string): void {
if (Notification.isSupported()) {
new Notification({ title, body, icon: ICON_PATH }).show();
}
}