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:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user