From 7ca0e33d7774a926180397399dc404116a6218fc Mon Sep 17 00:00:00 2001
From: thzxx
Date: Mon, 6 Apr 2026 03:04:20 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20TypeScript=20+=20Electron=20v2=20?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=20-=20=E7=BA=AF=E6=A1=8C=E9=9D=A2=E7=89=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 全面迁移到 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 暗色主题样式
---
package.json | 27 +-
src/main/ipc.ts | 72 +
src/main/main.ts | 159 ++
src/main/menu.ts | 131 ++
src/main/preload.ts | 34 +
src/main/tray.ts | 49 +
src/main/utils.ts | 14 +
src/renderer/api/ollama.ts | 136 ++
src/renderer/components/chat-area.ts | 331 +++
src/renderer/components/header.ts | 87 +
src/renderer/components/history-modal.ts | 191 ++
src/renderer/components/input-area.ts | 534 +++++
src/renderer/components/kb-modal.ts | 307 +++
src/renderer/components/lightbox.ts | 32 +
src/renderer/components/model-bar.ts | 195 ++
src/renderer/components/preset-bar.ts | 187 ++
src/renderer/components/settings-modal.ts | 179 ++
src/renderer/components/toast.ts | 24 +
src/renderer/db/chat-db.ts | 154 ++
src/renderer/index.html | 379 ++++
src/renderer/main.ts | 243 +++
src/renderer/services/crypto.ts | 163 ++
src/renderer/services/document-processor.ts | 74 +
src/renderer/services/preset-manager.ts | 179 ++
src/renderer/services/rag.ts | 145 ++
src/renderer/services/vector-store.ts | 368 ++++
src/renderer/state/state.ts | 151 ++
src/renderer/styles/style.css | 2183 +++++++++++++++++++
src/renderer/types.d.ts | 250 +++
src/renderer/utils/marked-config.ts | 83 +
src/renderer/utils/sanitizer.ts | 74 +
src/renderer/utils/utils.ts | 115 +
tsconfig.json | 30 +
33 files changed, 7265 insertions(+), 15 deletions(-)
create mode 100644 src/main/ipc.ts
create mode 100644 src/main/main.ts
create mode 100644 src/main/menu.ts
create mode 100644 src/main/preload.ts
create mode 100644 src/main/tray.ts
create mode 100644 src/main/utils.ts
create mode 100644 src/renderer/api/ollama.ts
create mode 100644 src/renderer/components/chat-area.ts
create mode 100644 src/renderer/components/header.ts
create mode 100644 src/renderer/components/history-modal.ts
create mode 100644 src/renderer/components/input-area.ts
create mode 100644 src/renderer/components/kb-modal.ts
create mode 100644 src/renderer/components/lightbox.ts
create mode 100644 src/renderer/components/model-bar.ts
create mode 100644 src/renderer/components/preset-bar.ts
create mode 100644 src/renderer/components/settings-modal.ts
create mode 100644 src/renderer/components/toast.ts
create mode 100644 src/renderer/db/chat-db.ts
create mode 100644 src/renderer/index.html
create mode 100644 src/renderer/main.ts
create mode 100644 src/renderer/services/crypto.ts
create mode 100644 src/renderer/services/document-processor.ts
create mode 100644 src/renderer/services/preset-manager.ts
create mode 100644 src/renderer/services/rag.ts
create mode 100644 src/renderer/services/vector-store.ts
create mode 100644 src/renderer/state/state.ts
create mode 100644 src/renderer/styles/style.css
create mode 100644 src/renderer/types.d.ts
create mode 100644 src/renderer/utils/marked-config.ts
create mode 100644 src/renderer/utils/sanitizer.ts
create mode 100644 src/renderer/utils/utils.ts
create mode 100644 tsconfig.json
diff --git a/package.json b/package.json
index e0b5082..2443a32 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,13 @@
{
"name": "metona-ollama-desktop",
- "version": "1.1.0",
- "description": "Metona Ollama - 桌面端 AI 聊天客户端",
- "main": "electron/main.js",
+ "version": "2.0.0",
+ "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
+ "main": "dist/main/main.js",
"author": "thzxx",
"license": "MIT",
"scripts": {
+ "dev": "vite && electron .",
+ "build": "tsc && vite build",
"start": "electron . --no-sandbox",
"pack": "electron-builder --dir",
"dist": "electron-builder --win",
@@ -19,27 +21,19 @@
"output": "release"
},
"files": [
- "electron/**/*",
- "js/**/*",
- "css/**/*",
+ "dist/**/*",
"assets/**/*",
- "index.html",
- "manifest.json",
"!node_modules/**/*"
],
"win": {
"target": [
{
"target": "nsis",
- "arch": [
- "x64"
- ]
+ "arch": ["x64"]
},
{
"target": "portable",
- "arch": [
- "x64"
- ]
+ "arch": ["x64"]
}
],
"icon": "assets/icons/llama.ico",
@@ -60,7 +54,10 @@
}
},
"devDependencies": {
+ "@types/node": "^20.17.0",
"electron": "^33.4.11",
- "electron-builder": "^25.0.0"
+ "electron-builder": "^25.1.8",
+ "typescript": "^5.7.0",
+ "vite": "^5.4.0"
}
}
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
new file mode 100644
index 0000000..3739d6c
--- /dev/null
+++ b/src/main/ipc.ts
@@ -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);
+ }
+ });
+}
diff --git a/src/main/main.ts b/src/main/main.ts
new file mode 100644
index 0000000..8b5460a
--- /dev/null
+++ b/src/main/main.ts
@@ -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)._trayHintShown) {
+ (global as Record)._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;
+});
diff --git a/src/main/menu.ts b/src/main/menu.ts
new file mode 100644
index 0000000..6d59670
--- /dev/null
+++ b/src/main/menu.ts
@@ -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).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);
+}
diff --git a/src/main/preload.ts b/src/main/preload.ts
new file mode 100644
index 0000000..0c4dd54
--- /dev/null
+++ b/src/main/preload.ts
@@ -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);
+ }
+});
diff --git a/src/main/tray.ts b/src/main/tray.ts
new file mode 100644
index 0000000..a7d911f
--- /dev/null
+++ b/src/main/tray.ts
@@ -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();
+ });
+}
diff --git a/src/main/utils.ts b/src/main/utils.ts
new file mode 100644
index 0000000..3e004ab
--- /dev/null
+++ b/src/main/utils.ts
@@ -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();
+ }
+}
diff --git a/src/renderer/api/ollama.ts b/src/renderer/api/ollama.ts
new file mode 100644
index 0000000..0806c38
--- /dev/null
+++ b/src/renderer/api/ollama.ts
@@ -0,0 +1,136 @@
+/**
+ * Ollama API 客户端封装
+ */
+
+import type {
+ OllamaChatParams, OllamaStreamChunk, OllamaModelsResponse,
+ OllamaPsResponse, OllamaVersionResponse, OllamaModelDetail,
+ OllamaEmbedResponse
+} from '../types.js';
+
+export class OllamaAPI {
+ baseUrl: string;
+
+ constructor(baseUrl = 'http://127.0.0.1:11434') {
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
+ }
+
+ private async _request(path: string, options: RequestInit = {}): Promise {
+ const url = `${this.baseUrl}${path}`;
+ const config: RequestInit = {
+ ...options,
+ headers: { 'Content-Type': 'application/json', ...(options.headers as Record || {}) }
+ };
+ const response = await fetch(url, config);
+ if (!response.ok) {
+ const errorBody = await response.text().catch(() => '');
+ throw new Error(`Ollama API 错误 ${response.status}: ${errorBody || response.statusText}`);
+ }
+ return response;
+ }
+
+ private async _json(path: string, body: unknown = null): Promise {
+ const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {};
+ const response = await this._request(path, options);
+ return response.json() as Promise;
+ }
+
+ async listModels(): Promise {
+ return this._json('/api/tags');
+ }
+
+ async psModels(): Promise {
+ return this._json('/api/ps');
+ }
+
+ async getVersion(): Promise {
+ return this._json('/api/version');
+ }
+
+ async showModel(model: string): Promise {
+ return this._json('/api/show', { model });
+ }
+
+ async chat(params: Omit): Promise {
+ const body: Record = {
+ model: params.model,
+ messages: params.messages,
+ stream: false,
+ };
+ if (params.think !== undefined) body.think = params.think;
+ if (params.system) body.system = params.system;
+ if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive;
+ if (params.options) body.options = params.options;
+ return this._json('/api/chat', body);
+ }
+
+ async chatStream(
+ params: OllamaChatParams,
+ onChunk: (chunk: OllamaStreamChunk) => void,
+ abortController?: AbortController
+ ): Promise {
+ const body: Record = {
+ model: params.model,
+ messages: params.messages,
+ stream: true,
+ };
+ if (params.think !== undefined) body.think = params.think;
+ if (params.system) body.system = params.system;
+ if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive;
+ if (params.options) body.options = params.options;
+
+ const fetchOptions: RequestInit = {
+ method: 'POST',
+ body: JSON.stringify(body)
+ };
+
+ if (abortController) {
+ fetchOptions.signal = abortController.signal;
+ }
+
+ const response = await this._request('/api/chat', fetchOptions);
+ const reader = response.body!.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ if (abortController) {
+ abortController.signal.addEventListener('abort', () => {
+ reader.cancel();
+ }, { once: true });
+ }
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const chunk: OllamaStreamChunk = JSON.parse(line);
+ if (onChunk) onChunk(chunk);
+ if (chunk.done) {
+ reader.cancel();
+ return;
+ }
+ } catch (err) {
+ console.warn('[OllamaAPI] 流式数据解析警告:', (err as Error).message);
+ }
+ }
+ }
+
+ if (buffer.trim()) {
+ try {
+ const chunk: OllamaStreamChunk = JSON.parse(buffer);
+ if (onChunk) onChunk(chunk);
+ } catch { /* ignore */ }
+ }
+ }
+
+ async embed(model: string, input: string): Promise {
+ return this._json('/api/embed', { model, input });
+ }
+}
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
new file mode 100644
index 0000000..533d8ef
--- /dev/null
+++ b/src/renderer/components/chat-area.ts
@@ -0,0 +1,331 @@
+/**
+ * ChatArea - 聊天区域组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { marked } from '../utils/marked-config.js';
+import { escapeHtml, formatTime, downloadFile } from '../utils/utils.js';
+import type { ChatSession, ChatMessage } from '../types.js';
+
+function getFileIcon(filename: string): string {
+ const name = filename.toLowerCase();
+ if (name === 'dockerfile') return '🐳';
+ if (name === 'makefile' || name === 'gnumakefile') return '🔨';
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+ const map: Record = {
+ py: '🐍', js: '💛', ts: '🔷', tsx: '⚛️', jsx: '⚛️',
+ java: '☕', go: '🐹', rs: '🦀', rb: '💎', php: '🐘',
+ cpp: '⚙️', c: '⚙️', sh: '🐚', html: '🌐', css: '🎨',
+ json: '📋', yaml: '📋', yml: '📋', md: '📝', txt: '📄',
+ sql: '🗃️', xml: '📰', csv: '📊', diff: '🔀',
+ };
+ return map[ext] || '📄';
+}
+
+let chatAreaEl: HTMLElement;
+let messagesContainerEl: HTMLElement;
+let emptyStateEl: HTMLElement;
+let scrollBtnEl: HTMLElement;
+let autoScroll = true;
+let currentPlaceholder: HTMLElement | null = null;
+
+export function initChatArea(): void {
+ chatAreaEl = document.querySelector('#chatArea')!;
+ messagesContainerEl = document.querySelector('#messagesContainer')!;
+ emptyStateEl = document.querySelector('#emptyState')!;
+ scrollBtnEl = document.querySelector('#scrollToBottom')!;
+
+ chatAreaEl.addEventListener('scroll', () => {
+ const atBottom = isAtBottom();
+ if (atBottom) {
+ autoScroll = true;
+ scrollBtnEl.style.display = 'none';
+ } else {
+ autoScroll = false;
+ scrollBtnEl.style.display = '';
+ }
+ });
+
+ scrollBtnEl.addEventListener('click', () => {
+ autoScroll = true;
+ scrollBtnEl.style.display = 'none';
+ chatAreaEl.scrollTo({ top: chatAreaEl.scrollHeight, behavior: 'smooth' });
+ });
+}
+
+function isAtBottom(): boolean {
+ return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60;
+}
+
+export function safeMarkdown(text: unknown): string {
+ if (!text && text !== 0) return '';
+ const str = typeof text === 'string' ? text : String(text);
+ if (!str) return '';
+ try {
+ return marked(str);
+ } catch {
+ return escapeHtml(str);
+ }
+}
+
+export function scrollToBottom(): void {
+ if (!autoScroll) return;
+ requestAnimationFrame(() => {
+ chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
+ });
+}
+
+export function renderMessages(): void {
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ const msgs = currentSession ? currentSession.messages : [];
+
+ if (msgs.length === 0) {
+ emptyStateEl.style.display = '';
+ messagesContainerEl.style.display = 'none';
+ return;
+ }
+
+ emptyStateEl.style.display = 'none';
+ messagesContainerEl.style.display = '';
+
+ const existingCount = messagesContainerEl.children.length;
+ const isNewMessage = existingCount > 0 && existingCount < msgs.length;
+ for (let i = existingCount; i < msgs.length; i++) {
+ appendMessageDOM(msgs[i], i);
+ }
+
+ if (isNewMessage) {
+ scrollToBottom();
+ } else if (msgs.length > 0) {
+ chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
+ }
+}
+
+export function appendMessageDOM(msg: ChatMessage, index: number): void {
+ const div = document.createElement('div');
+ div.className = `message ${msg.role}`;
+ div.dataset.index = String(index);
+
+ const avatar = msg.role === 'user' ? '👤' : '🤖';
+ const roleLabel = msg.role === 'user' ? '你' : 'AI';
+ const modelTag = (msg.role === 'assistant' && msg.model)
+ ? `${escapeHtml(msg.model)} `
+ : '';
+
+ let contentHtml = '';
+ const safeContent = (msg.content != null) ? String(msg.content) : '';
+
+ if (msg.role === 'assistant') {
+ if (msg.think) {
+ contentHtml += `
+ `;
+ }
+ if (safeContent) {
+ contentHtml += `${safeMarkdown(safeContent)}
`;
+ } else {
+ contentHtml += `
`;
+ }
+ } else {
+ contentHtml += `${escapeHtml(safeContent)}
`;
+ }
+
+ if (msg.images && msg.images.length > 0) {
+ contentHtml += '';
+ for (const img of msg.images) {
+ contentHtml += `
`;
+ }
+ contentHtml += '
';
+ }
+
+ if (msg.files && msg.files.length > 0) {
+ contentHtml += '';
+ for (const f of msg.files) {
+ const icon = getFileIcon(f.name);
+ contentHtml += `
${icon} ${escapeHtml(f.name)}
`;
+ }
+ contentHtml += '
';
+ }
+
+ if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && (msg.images?.length || 0) > 0) {
+ contentHtml = contentHtml.replace('
', '');
+ }
+
+ const stats: string[] = [];
+ if (msg.timestamp) stats.push(formatTime(msg.timestamp));
+ if (msg.eval_count) stats.push(`${msg.eval_count} tokens`);
+ if (msg.total_duration) stats.push(`${(msg.total_duration / 1e9).toFixed(1)}s`);
+ if (stats.length > 0) {
+ contentHtml += `${stats.map(s => `${s} `).join(' · ')}
`;
+ }
+
+ if (msg.role === 'assistant' && msg.ragSources && msg.ragSources.length > 0) {
+ const sourcesHtml = msg.ragSources.map((s, i) => {
+ const scorePercent = s.score ? `${(s.score * 100).toFixed(0)}%` : '';
+ return `
+ 📄 来源 ${i + 1}: ${escapeHtml(s.filename)}
+ ${scorePercent ? `${scorePercent} ` : ''}
+
`;
+ }).join('');
+ contentHtml += `${sourcesHtml}
`;
+ }
+
+ div.innerHTML = `
+ ${avatar}
+
+
${roleLabel}${modelTag}
+ ${contentHtml}
+
+ `;
+
+ messagesContainerEl.appendChild(div);
+}
+
+export function updateLastAssistantMessage(content: string, think: string | null, stats: { eval_count?: number; total_duration?: number } | null, model?: string): void {
+ const lastMsg = currentPlaceholder;
+ if (!lastMsg) return;
+
+ if (lastMsg.classList.contains('loading')) {
+ lastMsg.classList.remove('loading');
+ const loadingDots = lastMsg.querySelector('.loading-dots');
+ const loadingText = lastMsg.querySelector('.loading-text');
+ if (loadingDots) loadingDots.remove();
+ if (loadingText) loadingText.remove();
+ }
+
+ if (model) {
+ let modelTag = lastMsg.querySelector('.model-tag');
+ if (!modelTag) {
+ modelTag = document.createElement('span');
+ modelTag.className = 'model-tag';
+ const roleEl = lastMsg.querySelector('.msg-role');
+ if (roleEl) roleEl.appendChild(modelTag);
+ }
+ modelTag.textContent = model;
+ }
+
+ const contentDiv = lastMsg.querySelector('.msg-content');
+ const safeContent = (content != null) ? String(content) : '';
+ if (contentDiv && safeContent) {
+ contentDiv.innerHTML = safeMarkdown(safeContent);
+ }
+
+ let thinkBlock = lastMsg.querySelector('.think-block');
+ if (think) {
+ if (!thinkBlock) {
+ const msgBody = lastMsg.querySelector('.msg-body')!;
+ thinkBlock = document.createElement('div');
+ thinkBlock.className = 'think-block';
+ thinkBlock.innerHTML = `
+
+
+ `;
+ msgBody.insertBefore(thinkBlock, contentDiv);
+ }
+ thinkBlock.querySelector('pre')!.textContent = think;
+ }
+
+ if (stats) {
+ let statsDiv = lastMsg.querySelector('.msg-stats');
+ if (!statsDiv) {
+ statsDiv = document.createElement('div');
+ statsDiv.className = 'msg-stats';
+ lastMsg.querySelector('.msg-body')!.appendChild(statsDiv);
+ }
+ const parts: string[] = [];
+ if (stats.eval_count) parts.push(`${stats.eval_count} tokens`);
+ if (stats.total_duration) parts.push(`${(stats.total_duration / 1e9).toFixed(1)}s`);
+ if (parts.length) statsDiv.innerHTML = parts.join(' · ');
+ }
+
+ scrollToBottom();
+}
+
+export function appendAssistantPlaceholder(): void {
+ const div = document.createElement('div');
+ div.className = 'message assistant loading';
+ div.innerHTML = `
+ 🤖
+
+ `;
+ messagesContainerEl.appendChild(div);
+ currentPlaceholder = div;
+ scrollToBottom();
+}
+
+export function appendSystemMessage(text: string, tempClass?: string): void {
+ const div = document.createElement('div');
+ div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
+ if (tempClass) div.classList.add(tempClass);
+ div.textContent = text;
+ emptyStateEl.style.display = 'none';
+ messagesContainerEl.style.display = '';
+ messagesContainerEl.appendChild(div);
+ scrollToBottom();
+}
+
+export function getMessagesContainer(): HTMLElement {
+ return messagesContainerEl;
+}
+
+export function clearMessages(): void {
+ messagesContainerEl.innerHTML = '';
+ currentPlaceholder = null;
+}
+
+export function enableAutoScroll(): void {
+ autoScroll = true;
+ if (scrollBtnEl) scrollBtnEl.style.display = 'none';
+}
+
+// ── 导出功能 ──
+
+export function exportAsMarkdown(session: ChatSession): void {
+ let md = `# ${session.title}\n\n**模型:** ${session.model} \n**时间:** ${formatTime(session.createdAt)}\n\n---\n\n`;
+ session.messages.forEach(m => {
+ const role = m.role === 'user' ? '👤 用户' : '🤖 AI';
+ md += `### ${role}\n\n${m.content || ''}\n\n`;
+ if (m.files && m.files.length > 0) {
+ md += m.files.map(f => `📎 \`${f.name}\``).join(' · ') + '\n\n';
+ }
+ if (m.think) md += `> **思考:** ${m.think}\n\n`;
+ });
+ downloadFile(`${session.title}.md`, md, 'text/markdown');
+}
+
+export function exportAsHtml(session: ChatSession): void {
+ let html = `${escapeHtml(session.title)}
+
+${escapeHtml(session.title)} ${formatTime(session.createdAt)} · ${session.model}
`;
+ session.messages.forEach(m => {
+ html += `${m.role === 'user' ? '👤 用户' : '🤖 AI'} ${(m.content || '').replace(/\n/g, ' ')}
`;
+ });
+ html += '';
+ downloadFile(`${session.title}.html`, html, 'text/html');
+}
+
+export function exportAsTxt(session: ChatSession): void {
+ let txt = `${session.title}\n模型: ${session.model}\n时间: ${formatTime(session.createdAt)}\n${'='.repeat(50)}\n\n`;
+ session.messages.forEach(m => {
+ txt += `[${m.role === 'user' ? '用户' : 'AI'}]\n${m.content || ''}\n\n`;
+ });
+ downloadFile(`${session.title}.txt`, txt, 'text/plain');
+}
diff --git a/src/renderer/components/header.ts b/src/renderer/components/header.ts
new file mode 100644
index 0000000..c486dd4
--- /dev/null
+++ b/src/renderer/components/header.ts
@@ -0,0 +1,87 @@
+/**
+ * Header - 顶部导航组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { formatSize } from '../utils/utils.js';
+import { OllamaAPI } from '../api/ollama.js';
+
+let connStatusEl: HTMLElement;
+
+export function initHeader(): void {
+ connStatusEl = document.querySelector('#connStatus')!;
+}
+
+export async function checkConnection(): Promise<{ ok: boolean; version?: { version: string }; error?: Error }> {
+ const api = state.get(KEYS.API);
+ if (!api) return { ok: false, error: new Error('API 未初始化') };
+
+ connStatusEl.className = 'conn-status pending';
+ connStatusEl.querySelector('.status-label')!.textContent = '连接中...';
+
+ try {
+ const version = await api.getVersion();
+ connStatusEl.className = 'conn-status connected';
+ connStatusEl.title = `已连接 (v${version.version})`;
+ connStatusEl.querySelector('.status-label')!.textContent = '已连接';
+ return { ok: true, version };
+ } catch (err) {
+ connStatusEl.className = 'conn-status disconnected';
+ connStatusEl.title = '未连接';
+ connStatusEl.querySelector('.status-label')!.textContent = '未连接';
+ return { ok: false, error: err as Error };
+ }
+}
+
+export async function updateConnectionInfo(): Promise {
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ const badge = document.querySelector('#connBadge')!;
+ const verEl = document.querySelector('#serverVersion')!;
+ const corsHint = document.querySelector('#corsHint') as HTMLElement;
+
+ badge.textContent = '检测中...';
+ badge.className = 'status-badge warning';
+ verEl.textContent = '';
+
+ try {
+ const version = await api.getVersion();
+ badge.textContent = '✓ 已连接';
+ badge.className = 'status-badge success';
+ verEl.textContent = `v${version.version}`;
+ corsHint.style.display = 'none';
+ } catch (err) {
+ badge.textContent = '✗ 未连接';
+ badge.className = 'status-badge error';
+ verEl.textContent = '';
+
+ if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError') || (err as Error).message.includes('CORS')) {
+ corsHint.style.display = '';
+ } else {
+ corsHint.style.display = 'none';
+ }
+ }
+}
+
+export async function updateRunningModels(): Promise {
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ const container = document.querySelector('#runningModels')!;
+ try {
+ const data = await api.psModels();
+ if (!data.models || data.models.length === 0) {
+ container.innerHTML = '无运行中的模型
';
+ return;
+ }
+ container.innerHTML = data.models.map(m => `
+
+ ${m.name}
+ ${formatSize(m.size_vram || 0)} VRAM
+
+ `).join('');
+ } catch {
+ container.innerHTML = '无法获取运行信息
';
+ }
+}
diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts
new file mode 100644
index 0000000..d8f93c5
--- /dev/null
+++ b/src/renderer/components/history-modal.ts
@@ -0,0 +1,191 @@
+/**
+ * HistoryModal - 历史记录面板组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { debounce, escapeHtml, formatTime } from '../utils/utils.js';
+import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js';
+import { ChatDB } from '../db/chat-db.js';
+import type { ChatSession } from '../types.js';
+
+const HISTORY_PAGE_SIZE = 10;
+
+let historyModalEl: HTMLElement;
+let historyListEl: HTMLElement;
+let historyPaginationEl: HTMLElement;
+let historyPage = 1;
+let historySearchQuery = '';
+
+export function initHistoryModal(): void {
+ historyModalEl = document.querySelector('#historyModal')!;
+ historyListEl = document.querySelector('#historyList')!;
+ historyPaginationEl = document.querySelector('#historyPagination')!;
+
+ document.querySelector('#btnHistory')!.addEventListener('click', openHistoryModal);
+ document.querySelector('#btnCloseHistory')!.addEventListener('click', closeHistoryModal);
+ historyModalEl.addEventListener('click', (e) => {
+ if (e.target === historyModalEl) closeHistoryModal();
+ });
+
+ document.querySelector('#btnExitHistory')!.addEventListener('click', () => {
+ (document.querySelector('#btnNewChat') as HTMLElement).click();
+ });
+
+ const historySearchInput = document.querySelector('#historySearchInput') as HTMLInputElement;
+ const historySearchClear = document.querySelector('#historySearchClear') as HTMLElement;
+ const doHistorySearch = debounce(() => {
+ historySearchQuery = historySearchInput.value.trim();
+ historySearchClear.style.display = historySearchQuery ? '' : 'none';
+ historyPage = 1;
+ loadHistory();
+ }, 250);
+ historySearchInput.addEventListener('input', doHistorySearch);
+ historySearchClear.addEventListener('click', () => {
+ historySearchInput.value = '';
+ historySearchQuery = '';
+ historySearchClear.style.display = 'none';
+ historyPage = 1;
+ loadHistory();
+ });
+
+ historyListEl.addEventListener('click', async (e) => {
+ const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
+ if (!target) return;
+ const sessionId = target.dataset.id!;
+ const db = state.get(KEYS.DB);
+
+ if (target.classList.contains('btn-delete-session')) {
+ if (confirm('确定删除此会话?')) {
+ await db!.deleteSession(sessionId);
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ if (currentSession && currentSession.id === sessionId) {
+ (document.querySelector('#btnNewChat') as HTMLElement).click();
+ }
+ loadHistory();
+ }
+ } else if (target.classList.contains('btn-export-md')) {
+ const session = await db!.getSession(sessionId);
+ if (session) exportAsMarkdown(session);
+ } else if (target.classList.contains('btn-export-html')) {
+ const session = await db!.getSession(sessionId);
+ if (session) exportAsHtml(session);
+ } else if (target.classList.contains('btn-export-txt')) {
+ const session = await db!.getSession(sessionId);
+ if (session) exportAsTxt(session);
+ } else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
+ loadHistorySession(sessionId);
+ }
+ });
+
+ historyPaginationEl.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('.page-btn') as HTMLElement;
+ if (!btn || btn.classList.contains('disabled')) return;
+ const page = parseInt(btn.dataset.page!);
+ if (page && page !== historyPage) {
+ historyPage = page;
+ loadHistory();
+ historyListEl.scrollTop = 0;
+ }
+ });
+}
+
+async function loadHistory(): Promise {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+
+ let allSessions = await db.getAllSessions();
+
+ if (historySearchQuery) {
+ const q = historySearchQuery.toLowerCase();
+ allSessions = allSessions.filter(s => {
+ if (s.title?.toLowerCase().includes(q)) return true;
+ if (s.model?.toLowerCase().includes(q)) return true;
+ return s.messages.some(m => m.content?.toLowerCase().includes(q));
+ });
+ }
+
+ allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
+
+ if (allSessions.length === 0) {
+ historyListEl.innerHTML = ``;
+ historyPaginationEl.innerHTML = '';
+ return;
+ }
+
+ const totalPages = Math.ceil(allSessions.length / HISTORY_PAGE_SIZE);
+ if (historyPage > totalPages) historyPage = totalPages;
+
+ const start = (historyPage - 1) * HISTORY_PAGE_SIZE;
+ const pageSessions = allSessions.slice(start, start + HISTORY_PAGE_SIZE);
+
+ historyListEl.innerHTML = pageSessions.map(s => `
+
+
+
${escapeHtml(s.title)}
+
+ ${formatTime(s.updatedAt)}
+ ${s.messages.length} 条消息
+ ${s.model || '无模型'}
+
+
+
+ 📄
+ 🌐
+ 📝
+ 🗑️
+
+
+ `).join('');
+
+ if (totalPages <= 1) {
+ historyPaginationEl.innerHTML = `共 ${allSessions.length} 条 `;
+ return;
+ }
+
+ let html = `共 ${allSessions.length} 条 `;
+ html += `‹ `;
+ const range = 2;
+ const pages: number[] = [];
+ for (let i = 1; i <= totalPages; i++) {
+ if (i === 1 || i === totalPages || (i >= historyPage - range && i <= historyPage + range)) pages.push(i);
+ }
+ let prev = 0;
+ for (const p of pages) {
+ if (p - prev > 1) html += '… ';
+ html += `${p} `;
+ prev = p;
+ }
+ html += `›
`;
+ historyPaginationEl.innerHTML = html;
+}
+
+async function loadHistorySession(sessionId: string): Promise {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+
+ const session = await db.getSession(sessionId);
+ if (!session) return;
+
+ state.set(KEYS.CURRENT_SESSION, session);
+ state.set(KEYS.IS_HISTORY_VIEW, true);
+
+ (document.querySelector('#historyBar') as HTMLElement).style.display = '';
+ (document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
+
+ clearMessages();
+ renderMessages();
+ closeHistoryModal();
+}
+
+export function openHistoryModal(): void {
+ historyPage = 1;
+ historySearchQuery = '';
+ (document.querySelector('#historySearchInput') as HTMLInputElement).value = '';
+ (document.querySelector('#historySearchClear') as HTMLElement).style.display = 'none';
+ loadHistory();
+ historyModalEl.style.display = '';
+}
+
+export function closeHistoryModal(): void {
+ historyModalEl.style.display = 'none';
+}
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
new file mode 100644
index 0000000..b1a904d
--- /dev/null
+++ b/src/renderer/components/input-area.ts
@@ -0,0 +1,534 @@
+/**
+ * InputArea - 输入区域组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { fileToBase64, fileToText, detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
+import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js';
+import {
+ renderMessages, appendAssistantPlaceholder, appendSystemMessage,
+ updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll
+} from './chat-area.js';
+import { showToast } from './toast.js';
+import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
+import { ChatDB } from '../db/chat-db.js';
+import { OllamaAPI } from '../api/ollama.js';
+import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile } from '../types.js';
+
+let chatInputEl: HTMLTextAreaElement;
+let btnSendEl: HTMLButtonElement;
+let fileInputEl: HTMLInputElement;
+let textFileInputEl: HTMLInputElement;
+let imagePreviewEl: HTMLElement;
+let filePreviewEl: HTMLElement;
+let pendingImages: Array<{ name: string; base64: string }> = [];
+let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
+
+const TEXT_EXTENSIONS = new Set([
+ 'txt','md','markdown','rst','log','csv','tsv',
+ 'py','pyw','pyi','js','mjs','cjs','ts','tsx','jsx',
+ 'java','c','h','cpp','cc','cxx','hpp','go','rs','rb','php',
+ 'sh','bash','zsh','fish','sql','html','htm','css',
+ 'json','jsonl','xml','svg','yaml','yml','toml','ini','cfg','conf',
+ 'swift','kt','kts','scala','lua','r','m','mm',
+ 'cs','fs','vb','ex','exs','erl','hs','clj','lisp',
+ 'diff','patch','pl','dockerfile','makefile'
+]);
+
+const MAX_FILE_SIZE = 500 * 1024;
+
+function getFileIcon(filename: string): string {
+ const name = filename.toLowerCase();
+ if (name === 'dockerfile') return '🐳';
+ if (name === 'makefile' || name === 'gnumakefile') return '🔨';
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+ const map: Record = { py: '🐍', js: '💛', ts: '🔷', java: '☕', go: '🐹', rs: '🦀', cpp: '⚙️', c: '⚙️', sh: '🐚', html: '🌐', css: '🎨', json: '📋', md: '📝', txt: '📄', sql: '🗃️', csv: '📊' };
+ return map[ext] || '📄';
+}
+
+function isTextFile(file: File): boolean {
+ const name = file.name.toLowerCase();
+ if (name === 'dockerfile' || name === 'makefile') return true;
+ const ext = name.split('.').pop() || '';
+ return TEXT_EXTENSIONS.has(ext);
+}
+
+export function initInputArea(): void {
+ chatInputEl = document.querySelector('#chatInput')!;
+ btnSendEl = document.querySelector('#btnSend')!;
+ fileInputEl = document.querySelector('#fileInput')!;
+ textFileInputEl = document.querySelector('#textFileInput')!;
+ imagePreviewEl = document.querySelector('#imagePreview')!;
+ filePreviewEl = document.querySelector('#filePreview')!;
+
+ btnSendEl.addEventListener('click', () => {
+ if (state.get(KEYS.IS_STREAMING)) {
+ stopGeneration();
+ } else {
+ sendMessage();
+ }
+ });
+
+ chatInputEl.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ });
+
+ chatInputEl.addEventListener('input', autoResizeTextarea);
+
+ document.querySelector('#btnAttachImg')!.addEventListener('click', () => {
+ if (!isVisionAvailable()) {
+ showToast('当前模型不支持图片分析', 'warning');
+ return;
+ }
+ fileInputEl.click();
+ });
+ fileInputEl.addEventListener('change', (e) => {
+ handleImageSelect((e.target as HTMLInputElement).files!);
+ (e.target as HTMLInputElement).value = '';
+ });
+
+ document.querySelector('#btnAttachFile')!.addEventListener('click', () => textFileInputEl.click());
+ textFileInputEl.addEventListener('change', (e) => {
+ handleTextFileSelect((e.target as HTMLInputElement).files!);
+ (e.target as HTMLInputElement).value = '';
+ });
+
+ imagePreviewEl.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (target.classList.contains('remove-img')) {
+ pendingImages.splice(parseInt((target as HTMLElement).dataset.index!), 1);
+ renderImagePreviews();
+ }
+ });
+
+ filePreviewEl.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (target.classList.contains('remove-file')) {
+ pendingFiles.splice(parseInt((target as HTMLElement).dataset.index!), 1);
+ renderFilePreviews();
+ }
+ });
+}
+
+function autoResizeTextarea(): void {
+ chatInputEl.style.height = 'auto';
+ chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px';
+}
+
+async function handleImageSelect(files: FileList): Promise {
+ const maxSize = 20 * 1024 * 1024;
+ for (const file of Array.from(files)) {
+ if (!file.type.startsWith('image/')) continue;
+ if (file.size > maxSize) {
+ appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`);
+ continue;
+ }
+ try {
+ const base64 = await fileToBase64(file);
+ pendingImages.push({ name: file.name, base64 });
+ renderImagePreviews();
+ } catch (err) {
+ console.error('[InputArea] 图片读取失败:', err);
+ }
+ }
+}
+
+function renderImagePreviews(): void {
+ if (pendingImages.length === 0) {
+ imagePreviewEl.style.display = 'none';
+ imagePreviewEl.innerHTML = '';
+ return;
+ }
+ imagePreviewEl.style.display = '';
+ imagePreviewEl.innerHTML = pendingImages.map((img, i) => `
+
+
+
×
+
+ `).join('');
+}
+
+async function handleTextFileSelect(files: FileList): Promise {
+ for (const file of Array.from(files)) {
+ if (!isTextFile(file)) {
+ appendSystemMessage(`⚠️ ${file.name} 不是支持的文本/代码文件`);
+ continue;
+ }
+ if (file.size > MAX_FILE_SIZE) {
+ appendSystemMessage(`⚠️ ${file.name} 超过 500KB 限制(${formatSize(file.size)})`);
+ continue;
+ }
+ if (pendingFiles.some(f => f.name === file.name && f.size === file.size)) {
+ appendSystemMessage(`ℹ️ ${file.name} 已添加`);
+ continue;
+ }
+ try {
+ const content = await fileToText(file);
+ pendingFiles.push({ name: file.name, content, language: detectLanguage(file.name), size: file.size });
+ renderFilePreviews();
+ } catch (err) {
+ console.error('[InputArea] 文件读取失败:', err);
+ appendSystemMessage(`❌ 读取 ${file.name} 失败`);
+ }
+ }
+}
+
+function renderFilePreviews(): void {
+ if (pendingFiles.length === 0) {
+ filePreviewEl.style.display = 'none';
+ filePreviewEl.innerHTML = '';
+ return;
+ }
+ filePreviewEl.style.display = '';
+ filePreviewEl.innerHTML = pendingFiles.map((f, i) => `
+
+ ${getFileIcon(f.name)}
+ ${escapeHtml(f.name)}
+ ${formatSize(f.size)}
+ ×
+
+ `).join('');
+}
+
+function updateSendButton(streaming: boolean): void {
+ if (streaming) {
+ btnSendEl.innerHTML = ` `;
+ btnSendEl.classList.add('stop-btn');
+ btnSendEl.classList.remove('disabled');
+ btnSendEl.title = '停止生成';
+ } else {
+ btnSendEl.innerHTML = ` `;
+ btnSendEl.classList.remove('stop-btn');
+ btnSendEl.classList.remove('disabled');
+ btnSendEl.title = '发送';
+ }
+}
+
+function stopGeneration(): void {
+ const abortController = state.get(KEYS.ABORT_CONTROLLER);
+ if (abortController) abortController.abort();
+}
+
+function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> {
+ return messages.map(m => {
+ let content = m.content || '';
+ if (m._fileContents && m._fileContents.length > 0) {
+ const fileParts = m._fileContents.map(f => {
+ const lang = f.language || '';
+ return `📄 以下是一个文件内容:\n\n\`\`\`${lang}\n${f.content}\n\`\`\``;
+ });
+ if (content) {
+ content += '\n\n---\n' + fileParts.join('\n\n---\n');
+ } else {
+ const count = m._fileContents.length;
+ content = `请分析以下 ${count > 1 ? count + ' 个' : ''}文件:\n\n${fileParts.join('\n\n---\n')}`;
+ }
+ }
+ return {
+ role: m.role,
+ content,
+ ...(m.images && { images: m.images })
+ };
+ });
+}
+
+export async function sendMessage(): Promise {
+ const text = chatInputEl.value.trim();
+ if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
+ if (state.get(KEYS.IS_STREAMING)) return;
+
+ const model = getSelectedModel();
+ if (!model) {
+ appendSystemMessage('⚠️ 请先选择一个模型');
+ return;
+ }
+
+ if (pendingImages.length > 0 && !isVisionAvailable()) {
+ appendSystemMessage('⚠️ 当前模型不支持图片分析,请移除图片后重试');
+ return;
+ }
+
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ if (!currentSession) return;
+
+ const now = Date.now();
+ const msgsToAdd: ChatMessage[] = [];
+
+ const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
+
+ if (pendingImages.length > 0) {
+ msgsToAdd.push({
+ role: 'user',
+ content: pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`,
+ images: pendingImages.map(img => img.base64),
+ timestamp: now
+ });
+ }
+
+ if (text || pendingFiles.length > 0) {
+ const msg: ChatMessage = {
+ role: 'user',
+ content: text || '',
+ timestamp: now
+ };
+ if (userFiles.length > 0) {
+ msg.files = userFiles;
+ msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
+ }
+ msgsToAdd.push(msg);
+ }
+
+ const isFirstMsg = currentSession.messages.length === 0;
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ ...(isFirstMsg && {
+ title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
+ model
+ }),
+ messages: [...session.messages, ...msgsToAdd],
+ updatedAt: Date.now()
+ }));
+
+ const freshSession = state.get(KEYS.CURRENT_SESSION);
+
+ enableAutoScroll();
+ renderMessages();
+ await saveCurrentSession();
+
+ chatInputEl.value = '';
+ pendingImages = [];
+ pendingFiles = [];
+ imagePreviewEl.style.display = 'none';
+ imagePreviewEl.innerHTML = '';
+ filePreviewEl.style.display = 'none';
+ filePreviewEl.innerHTML = '';
+ autoResizeTextarea();
+
+ appendAssistantPlaceholder();
+
+ state.set(KEYS.IS_STREAMING, true);
+ updateSendButton(true);
+
+ const api = state.get(KEYS.API)!;
+ const abortController = new AbortController();
+ state.set(KEYS.ABORT_CONTROLLER, abortController);
+ const thinkMode = isThinkEnabled();
+ const systemPromptEnabled = state.get(KEYS.SYSTEM_PROMPT_ENABLED);
+ const systemPrompt = state.get(KEYS.SYSTEM_PROMPT);
+ const numCtx = state.get(KEYS.NUM_CTX, 24576);
+
+ let assistantContent = '';
+ let thinkContent = '';
+ let finalStats: { eval_count?: number; total_duration?: number } | null = null;
+ let modelName = '';
+
+ try {
+ const apiMessages = buildApiMessages(freshSession.messages);
+ const temperature = state.get('temperature', 0.7);
+
+ const chatParams: Record = {
+ model,
+ messages: apiMessages,
+ stream: true,
+ think: thinkMode,
+ };
+ if (systemPromptEnabled && systemPrompt) chatParams.system = systemPrompt;
+ if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
+
+ let ragSources: RagSource[] | null = null;
+
+ if (isRagEnabled()) {
+ appendSystemMessage('🧠 正在检索知识库...', 'rag-status');
+ try {
+ const textForRag = text;
+ const ragResult = await performRagRetrieval(textForRag);
+ if (ragResult && ragResult.results && ragResult.results.length > 0) {
+ const ctxLimit = numCtx || 24576;
+ const outputReserve = 2048;
+ const msgChars = apiMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0);
+ const systemChars = ((chatParams.system as string)?.length || 0);
+ const usedTokens = Math.ceil((msgChars + systemChars) / 2);
+ const ragBudget = Math.max(0, ctxLimit - outputReserve - usedTokens);
+ const ragBudgetChars = ragBudget * 2;
+
+ let ragContext = ragResult.results.map((r: any, i: number) =>
+ `[来源 ${i + 1}: ${r.filename}]\n${r.text}`
+ ).join('\n\n---\n\n');
+
+ const ragTemplate = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===`;
+ if (ragContext.length + ragTemplate.length > ragBudgetChars) {
+ ragContext = ragContext.slice(0, Math.max(0, ragBudgetChars - ragTemplate.length - 50)) + '\n\n[知识库内容过长,已截取最相关的部分]';
+ appendSystemMessage(`⚠️ 知识库内容已截取(预算 ${ragBudgetChars} 字符)`, 'rag-status');
+ }
+
+ const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===\n\n请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
+
+ chatParams.system = chatParams.system
+ ? (chatParams.system as string) + '\n\n' + ragPrompt
+ : ragPrompt;
+
+ ragSources = ragResult.results.map((r: any) => ({
+ filename: r.filename,
+ score: r.score,
+ text: truncate(r.text, 100)
+ }));
+ const sourceNames = [...new Set(ragSources.map(s => s.filename))];
+ appendSystemMessage(`🧠 已检索 ${ragSources.length} 个相关片段(来源:${sourceNames.join('、')})`, 'rag-status');
+ } else {
+ appendSystemMessage('🧠 知识库未检索到相关内容,使用通用知识回答', 'rag-status');
+ }
+ } catch (ragErr) {
+ console.error('[RAG] 检索失败:', ragErr);
+ appendSystemMessage(`🧠 知识库检索失败: ${(ragErr as Error).message}`, 'rag-status');
+ }
+ }
+
+ let ragStatusCleared = false;
+
+ await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => {
+ if (!ragStatusCleared && chunk.message?.content) {
+ ragStatusCleared = true;
+ document.querySelectorAll('.rag-status').forEach(el => el.remove());
+ }
+ if (chunk.message) {
+ if (chunk.message.content) assistantContent += chunk.message.content;
+ const think = chunk.message.thinking || chunk.message.reasoning_content;
+ if (think && typeof think === 'string') thinkContent += think;
+ }
+ if (chunk.model) modelName = chunk.model;
+ updateLastAssistantMessage(assistantContent, thinkContent || null, null, modelName || undefined);
+ if (chunk.done) {
+ finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration };
+ }
+ }, abortController);
+
+ if (!assistantContent && !thinkContent) {
+ appendSystemMessage('⚠️ 模型未返回任何内容,可能是上下文过长或模型不支持当前请求');
+ }
+
+ const assistantMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent || '',
+ timestamp: Date.now(),
+ ...(modelName && { model: modelName }),
+ ...(thinkContent && { think: thinkContent }),
+ ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
+ ...(ragSources && { ragSources })
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, assistantMsg],
+ updatedAt: Date.now()
+ }));
+
+ if (finalStats) {
+ updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || undefined);
+ }
+
+ if (ragSources && ragSources.length > 0) {
+ const lastAssistant = document.querySelector('#messagesContainer .message.assistant:last-of-type');
+ if (lastAssistant) {
+ const sourcesHtml = ragSources.map((s, i) => {
+ const scorePercent = s.score ? `${(s.score * 100).toFixed(0)}%` : '';
+ return `📄 来源 ${i + 1}: ${escapeHtml(s.filename)} ${scorePercent ? `${scorePercent} ` : ''}
`;
+ }).join('');
+ const ragDiv = document.createElement('div');
+ ragDiv.className = 'rag-sources';
+ ragDiv.innerHTML = `${sourcesHtml}`;
+ lastAssistant.querySelector('.msg-body')!.appendChild(ragDiv);
+ }
+ }
+
+ await saveCurrentSession();
+ } catch (err) {
+ console.error('[InputArea] 流式聊天错误:', err);
+ document.querySelectorAll('.rag-status').forEach(el => el.remove());
+
+ if ((err as Error).name === 'AbortError') {
+ const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
+ const loadingDots = placeholder?.querySelector('.loading-dots');
+ const loadingText = placeholder?.querySelector('.loading-text');
+ if (loadingDots) loadingDots.remove();
+ if (loadingText) loadingText.remove();
+ placeholder?.classList.remove('loading');
+
+ const contentDiv = placeholder?.querySelector('.msg-content');
+ const partialMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent,
+ timestamp: Date.now(),
+ ...(modelName && { model: modelName }),
+ ...(thinkContent && { think: thinkContent }),
+ stopped: true
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, partialMsg],
+ updatedAt: Date.now()
+ }));
+
+ if (contentDiv) {
+ let html = assistantContent ? safeMarkdown(assistantContent) : '';
+ html += '[已停止]
';
+ contentDiv.innerHTML = html;
+ }
+ appendSystemMessage('⏹ 已停止生成');
+ await saveCurrentSession();
+ state.set(KEYS.IS_STREAMING, false);
+ updateSendButton(false);
+ state.set(KEYS.ABORT_CONTROLLER, null);
+ return;
+ }
+
+ const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
+ const loadingDots = placeholder?.querySelector('.loading-dots');
+ const loadingText = placeholder?.querySelector('.loading-text');
+ if (loadingDots) loadingDots.remove();
+ if (loadingText) loadingText.remove();
+ placeholder?.classList.remove('loading');
+
+ const contentDiv = placeholder?.querySelector('.msg-content');
+
+ if (assistantContent && contentDiv) {
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
+ updatedAt: Date.now()
+ }));
+ contentDiv.innerHTML = safeMarkdown(assistantContent) + '[已中断]
';
+ await saveCurrentSession();
+ } else {
+ if (placeholder) placeholder.remove();
+ }
+
+ let errMsg = `❌ 错误: ${(err as Error).message}`;
+ if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
+ errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
+ } else if ((err as Error).message.includes('400')) {
+ errMsg = '❌ 请求参数错误,可能是知识库内容超出模型上下文限制';
+ } else if ((err as Error).message.includes('500')) {
+ errMsg = '❌ Ollama 服务器错误,请检查 Ollama 日志';
+ }
+ appendSystemMessage(errMsg);
+ } finally {
+ state.set(KEYS.IS_STREAMING, false);
+ updateSendButton(false);
+ state.set(KEYS.ABORT_CONTROLLER, null);
+ }
+}
+
+async function saveCurrentSession(): Promise {
+ const db = state.get(KEYS.DB);
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ const isHistoryView = state.get(KEYS.IS_HISTORY_VIEW);
+ if (!currentSession || !db || isHistoryView) return;
+ currentSession.updatedAt = Date.now();
+ try {
+ await db.saveSession(currentSession);
+ } catch (err) {
+ console.error('[InputArea] 保存会话失败:', err);
+ }
+}
diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts
new file mode 100644
index 0000000..e3983dd
--- /dev/null
+++ b/src/renderer/components/kb-modal.ts
@@ -0,0 +1,307 @@
+/**
+ * KBModal - 知识库管理面板
+ */
+
+import { state, KEYS } from '../state/state.js';
+import {
+ initVectorStore, getVectorStore, addDocumentToKB,
+ removeDocumentFromKB, getDocumentsInCollection, retrieveContext,
+ buildRagSystemPrompt
+} from '../services/rag.js';
+import { fileToText, formatSize, escapeHtml, truncate } from '../utils/utils.js';
+import { showToast } from './toast.js';
+import { OllamaAPI } from '../api/ollama.js';
+import type { VectorCollection, SearchResult } from '../types.js';
+
+let kbModalEl: HTMLElement;
+let currentColId: string | null = null;
+let ragEnabled = false;
+let ragCollectionId: string | null = null;
+
+export function initKBModal(): void {
+ kbModalEl = document.querySelector('#kbModal')!;
+
+ document.querySelector('#btnKB')!.addEventListener('click', openKBModal);
+ document.querySelector('#btnCloseKB')!.addEventListener('click', closeKBModal);
+ kbModalEl.addEventListener('click', (e) => {
+ if (e.target === kbModalEl) closeKBModal();
+ });
+
+ document.querySelector('#btnNewCollection')!.addEventListener('click', createCollection);
+
+ document.querySelector('#btnUploadDoc')!.addEventListener('click', () => {
+ if (!currentColId) { showToast('请先选择一个知识库集合', 'warning'); return; }
+ (document.querySelector('#kbFileInput') as HTMLInputElement).click();
+ });
+ document.querySelector('#kbFileInput')!.addEventListener('change', handleDocUpload);
+
+ document.querySelector('#toggleRAG')!.addEventListener('change', (e) => {
+ ragEnabled = (e.target as HTMLInputElement).checked;
+ if (ragEnabled && currentColId) {
+ ragCollectionId = currentColId;
+ showToast('RAG 知识检索已开启', 'success');
+ } else if (ragEnabled && !currentColId) {
+ (e.target as HTMLInputElement).checked = false;
+ ragEnabled = false;
+ showToast('请先选择一个知识库集合', 'warning');
+ return;
+ } else {
+ ragCollectionId = null;
+ showToast('RAG 知识检索已关闭', 'info');
+ }
+ updateRagBadge();
+ });
+
+ document.querySelector('#selectEmbedModel')!.addEventListener('change', async (e) => {
+ if (!currentColId) return;
+ const vs = getVectorStore();
+ const col = await vs!.getCollection(currentColId);
+ if (col) {
+ col.embeddingModel = (e.target as HTMLSelectElement).value;
+ await vs!.updateCollection(col);
+ }
+ });
+}
+
+export function isRagEnabled(): boolean {
+ return ragEnabled && !!ragCollectionId;
+}
+
+export function getRagCollectionId(): string | null {
+ return ragCollectionId;
+}
+
+export async function performRagRetrieval(query: string): Promise<{ context: string; results: SearchResult[]; ragPrompt: string } | null> {
+ if (!isRagEnabled()) return null;
+ try {
+ const { context, results } = await retrieveContext(query, ragCollectionId!, 5);
+ if (!context) return null;
+ return { context, results, ragPrompt: buildRagSystemPrompt(context) };
+ } catch (err) {
+ console.warn('[KB] RAG 检索失败:', err);
+ return null;
+ }
+}
+
+function updateRagBadge(): void {
+ const badge = document.querySelector('#badgeRAG') as HTMLElement;
+ if (!badge) return;
+ badge.style.display = ragEnabled ? '' : 'none';
+}
+
+async function openKBModal(): Promise {
+ kbModalEl.style.display = '';
+ await refreshCollections();
+ await populateEmbedModels();
+}
+
+function closeKBModal(): void {
+ kbModalEl.style.display = 'none';
+}
+
+async function refreshCollections(): Promise {
+ const vs = await initVectorStore();
+ const collections = await vs.getCollections();
+ const listEl = document.querySelector('#kbCollectionList')!;
+
+ if (collections.length === 0) {
+ listEl.innerHTML = '暂无知识库,点击上方「新建集合」创建
';
+ currentColId = null;
+ refreshDocList();
+ return;
+ }
+
+ listEl.innerHTML = collections.map(c => `
+
+
+ ${escapeHtml(c.name)}
+ ${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块
+
+
+
+
+
+
+
+ `).join('');
+
+ listEl.querySelectorAll('.kb-collection-item').forEach(el => {
+ el.addEventListener('click', async (e) => {
+ if ((e.target as HTMLElement).closest('.kb-col-delete')) return;
+ currentColId = (el as HTMLElement).dataset.id!;
+ await refreshCollections();
+ await refreshDocList();
+ const vs = getVectorStore();
+ const col = await vs!.getCollection(currentColId);
+ if (col?.embeddingModel) {
+ (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value = col.embeddingModel;
+ }
+ });
+ });
+
+ listEl.querySelectorAll('.kb-col-delete').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const id = (btn as HTMLElement).dataset.id!;
+ if (!confirm('确定删除此知识库集合?所有文档将被清除。')) return;
+ const vs = getVectorStore();
+ await vs!.deleteCollection(id);
+ if (currentColId === id) currentColId = null;
+ if (ragCollectionId === id) {
+ ragEnabled = false;
+ ragCollectionId = null;
+ (document.querySelector('#toggleRAG') as HTMLInputElement).checked = false;
+ updateRagBadge();
+ }
+ showToast('集合已删除', 'success');
+ await refreshCollections();
+ await refreshDocList();
+ });
+ });
+}
+
+async function refreshDocList(): Promise {
+ const docListEl = document.querySelector('#kbDocList')!;
+ if (!currentColId) {
+ docListEl.innerHTML = '← 选择一个知识库集合
';
+ return;
+ }
+
+ const docs = await getDocumentsInCollection(currentColId);
+ if (docs.length === 0) {
+ docListEl.innerHTML = '此集合暂无文档,上传文件开始构建知识库
';
+ return;
+ }
+
+ docListEl.innerHTML = docs.map(d => `
+
+
📄
+
+ ${escapeHtml(d.filename)}
+ ${d.chunkCount} 个分块
+
+
+
+
+
+
+
+ `).join('');
+
+ docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const docId = (btn as HTMLElement).dataset.docid!;
+ if (!confirm('确定删除此文档?')) return;
+ await removeDocumentFromKB(currentColId!, docId);
+ showToast('文档已删除', 'success');
+ await refreshCollections();
+ await refreshDocList();
+ });
+ });
+}
+
+async function createCollection(): Promise {
+ const nameInput = document.querySelector('#inputKBName') as HTMLInputElement;
+ const name = nameInput.value.trim();
+ if (!name) { showToast('请输入知识库名称', 'warning'); return; }
+ const vs = await initVectorStore();
+ const col = await vs.createCollection(name);
+ currentColId = col.id;
+ nameInput.value = '';
+ showToast(`知识库「${name}」已创建`, 'success');
+ await refreshCollections();
+ await refreshDocList();
+}
+
+async function handleDocUpload(e: Event): Promise {
+ const fileArr = Array.from((e.target as HTMLInputElement).files || []);
+ (e.target as HTMLInputElement).value = '';
+
+ if (fileArr.length === 0) return;
+
+ const embedModel = (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value;
+ if (!embedModel) { showToast('请先选择一个嵌入模型', 'warning'); return; }
+
+ const progressEl = document.querySelector('#kbProgress') as HTMLElement;
+ const progressTextEl = document.querySelector('#kbProgressText')!;
+
+ let successCount = 0;
+ let failCount = 0;
+
+ for (const file of fileArr) {
+ if (file.size > 5 * 1024 * 1024) {
+ showToast(`${file.name} 超过 5MB 限制`, 'warning');
+ failCount++;
+ continue;
+ }
+
+ try {
+ progressEl.style.display = '';
+ progressTextEl.textContent = `正在处理 ${file.name}...`;
+
+ const content = await fileToText(file);
+ const result = await addDocumentToKB(
+ currentColId!, file.name, content, embedModel,
+ (done, total, msg) => {
+ progressTextEl.textContent = `${file.name}: ${msg} (${done}/${total})`;
+ }
+ );
+
+ showToast(`${file.name} 已添加到知识库(${result.chunkCount} 个分块)`, 'success');
+ successCount++;
+ } catch (err) {
+ console.error('[KB] 文档处理失败:', err);
+ showToast(`${file.name} 处理失败: ${(err as Error).message}`, 'error');
+ failCount++;
+ }
+ }
+
+ progressEl.style.display = 'none';
+
+ if (fileArr.length > 1) {
+ showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount} 个`, failCount > 0 ? 'warning' : 'success');
+ }
+
+ await refreshCollections();
+ await refreshDocList();
+}
+
+async function populateEmbedModels(): Promise {
+ const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement;
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ try {
+ const data = await api.listModels();
+ select.innerHTML = '选择嵌入模型... ';
+ if (!data.models || data.models.length === 0) {
+ select.innerHTML = '未安装任何模型 ';
+ return;
+ }
+
+ const checks = await Promise.allSettled(
+ data.models.map(async (m) => {
+ const info = await api.showModel(m.name);
+ const caps = info.capabilities || [];
+ return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') };
+ })
+ );
+
+ const embedModels = checks
+ .filter(r => r.status === 'fulfilled' && r.value.isEmbed)
+ .map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value);
+
+ embedModels.forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.name;
+ opt.textContent = m.name + (m.size ? ` (${formatSize(m.size)})` : '');
+ select.appendChild(opt);
+ });
+
+ if (embedModels.length === 0) {
+ select.innerHTML = '未检测到嵌入模型,请先 ollama pull ';
+ }
+ } catch (err) {
+ console.warn('[KB] 加载模型列表失败:', err);
+ }
+}
diff --git a/src/renderer/components/lightbox.ts b/src/renderer/components/lightbox.ts
new file mode 100644
index 0000000..9545b1c
--- /dev/null
+++ b/src/renderer/components/lightbox.ts
@@ -0,0 +1,32 @@
+/**
+ * Lightbox - 图片预览组件
+ */
+
+let lightboxEl: HTMLElement, lightboxImg: HTMLImageElement;
+
+export function initLightbox(): void {
+ lightboxEl = document.querySelector('#lightbox')!;
+ lightboxImg = document.querySelector('#lightboxImg')!;
+
+ lightboxEl.addEventListener('click', (e) => {
+ if (e.target === lightboxEl) closeLightbox();
+ });
+ document.querySelector('#lightboxClose')!.addEventListener('click', closeLightbox);
+
+ document.querySelector('#messagesContainer')?.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if ((target as HTMLImageElement).dataset.lightbox === 'true') {
+ openLightbox((target as HTMLImageElement).src);
+ }
+ });
+}
+
+export function openLightbox(src: string): void {
+ lightboxImg.src = src;
+ lightboxEl.style.display = '';
+}
+
+export function closeLightbox(): void {
+ lightboxEl.style.display = 'none';
+ lightboxImg.src = '';
+}
diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts
new file mode 100644
index 0000000..04ee151
--- /dev/null
+++ b/src/renderer/components/model-bar.ts
@@ -0,0 +1,195 @@
+/**
+ * ModelBar - 模型选择栏组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { formatSize } from '../utils/utils.js';
+import { OllamaAPI } from '../api/ollama.js';
+import { ChatDB } from '../db/chat-db.js';
+import type { OllamaModelDetail } from '../types.js';
+
+let modelSelectEl: HTMLSelectElement;
+let badgeThinkEl: HTMLElement;
+let badgeVisionEl: HTMLElement;
+let thinkCheckbox: HTMLInputElement;
+let thinkWrap: HTMLElement;
+let btnAttachImg: HTMLButtonElement;
+
+const modelCapabilityCache = new Map();
+
+export function initModelBar(): void {
+ modelSelectEl = document.querySelector('#modelSelect')!;
+ badgeThinkEl = document.querySelector('#badgeThink')!;
+ badgeVisionEl = document.querySelector('#badgeVision')!;
+ thinkCheckbox = document.querySelector('#toggleThink')!;
+ thinkWrap = document.querySelector('#thinkToggleWrap')!;
+ btnAttachImg = document.querySelector('#btnAttachImg')!;
+
+ modelSelectEl.addEventListener('change', async () => {
+ const model = modelSelectEl.value;
+ const db = state.get(KEYS.DB);
+ if (db) await db.saveSetting('selectedModel', model);
+ state.set('_defaultModel', model);
+ state.update(KEYS.CURRENT_SESSION, (session: any) => session ? ({ ...session, model }) : session);
+ if (model) {
+ await checkModelCapability(model);
+ } else {
+ setThinkAvailable(false);
+ setVisionAvailable(false);
+ badgeThinkEl.style.display = 'none';
+ badgeVisionEl.style.display = 'none';
+ }
+ });
+}
+
+export async function loadModels(): Promise {
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ try {
+ const data = await api.listModels();
+ modelSelectEl.innerHTML = '';
+
+ if (!data.models || data.models.length === 0) {
+ modelSelectEl.innerHTML = '未安装任何模型 ';
+ badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
+ setThinkAvailable(false); setVisionAvailable(false);
+ return;
+ }
+
+ data.models
+ .sort((a, b) => {
+ const nameA = a.name.toLowerCase(), nameB = b.name.toLowerCase();
+ if (nameA !== nameB) return nameA.localeCompare(nameB);
+ return (a.size || 0) - (b.size || 0);
+ })
+ .forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.name;
+ const diskSize = formatSize(m.size || 0);
+ opt.textContent = diskSize ? `${m.name} · ${diskSize}` : m.name;
+ modelSelectEl.appendChild(opt);
+ });
+
+ const defaultModel = state.get('_defaultModel', '');
+ if (defaultModel) modelSelectEl.value = defaultModel;
+
+ if (modelSelectEl.value) {
+ await checkModelCapability(modelSelectEl.value);
+ }
+
+ filterEmbedModels(data.models);
+ } catch (err) {
+ console.error('[ModelBar] 加载模型失败:', err);
+ modelSelectEl.innerHTML = '加载失败 - 检查连接 ';
+ badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
+ setThinkAvailable(false); setVisionAvailable(false);
+ }
+}
+
+async function filterEmbedModels(models: Array<{ name: string }>): Promise {
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ for (const m of models) {
+ try {
+ const info = await api.showModel(m.name);
+ const caps = info.capabilities || [];
+ const isCompletion = caps.includes('completion');
+ modelCapabilityCache.set(m.name, {
+ think: caps.includes('thinking'),
+ vision: caps.includes('vision'),
+ completion: isCompletion,
+ });
+ if (!isCompletion) {
+ const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
+ if (opt) opt.remove();
+ }
+ } catch { /* ignore */ }
+ }
+
+ if (!modelSelectEl.value && modelSelectEl.options.length > 0) {
+ modelSelectEl.value = modelSelectEl.options[0].value;
+ state.set('_defaultModel', modelSelectEl.value);
+ const db = state.get(KEYS.DB);
+ if (db) await db.saveSetting('selectedModel', modelSelectEl.value);
+ await checkModelCapability(modelSelectEl.value);
+ }
+}
+
+async function checkModelCapability(modelName: string): Promise {
+ if (modelCapabilityCache.has(modelName)) {
+ applyCapability(modelName, modelCapabilityCache.get(modelName)!);
+ return;
+ }
+
+ const api = state.get(KEYS.API);
+ if (!api) return;
+
+ try {
+ const info = await api.showModel(modelName);
+ const capabilities = info.capabilities || [];
+ const caps = {
+ think: capabilities.includes('thinking'),
+ vision: capabilities.includes('vision'),
+ completion: capabilities.includes('completion'),
+ };
+ modelCapabilityCache.set(modelName, caps);
+ applyCapability(modelName, caps);
+ } catch (err) {
+ console.warn('[ModelBar] 获取模型能力失败:', err);
+ const fallback = { think: false, vision: false, completion: true };
+ modelCapabilityCache.set(modelName, fallback);
+ applyCapability(modelName, fallback);
+ }
+}
+
+function applyCapability(_modelName: string, caps: { think: boolean; vision: boolean }): void {
+ setThinkAvailable(caps.think);
+ setVisionAvailable(caps.vision);
+ badgeThinkEl.style.display = caps.think ? '' : 'none';
+ badgeVisionEl.style.display = caps.vision ? '' : 'none';
+}
+
+function setThinkAvailable(available: boolean): void {
+ if (available) {
+ thinkCheckbox.disabled = false;
+ thinkWrap.title = 'Think 模式:启用深度推理';
+ thinkWrap.classList.remove('unavailable');
+ } else {
+ thinkCheckbox.checked = false;
+ thinkCheckbox.disabled = true;
+ thinkWrap.title = '当前模型不支持 Think 模式';
+ thinkWrap.classList.add('unavailable');
+ }
+}
+
+function setVisionAvailable(available: boolean): void {
+ if (!btnAttachImg) return;
+ if (available) {
+ btnAttachImg.disabled = false;
+ btnAttachImg.title = '上传图片';
+ btnAttachImg.classList.remove('disabled');
+ } else {
+ btnAttachImg.disabled = true;
+ btnAttachImg.title = '当前模型不支持图片分析';
+ btnAttachImg.classList.add('disabled');
+ }
+}
+
+export function isVisionAvailable(): boolean {
+ return btnAttachImg && !btnAttachImg.disabled;
+}
+
+export function getSelectedModel(): string {
+ return modelSelectEl.value;
+}
+
+export function setSelectedModel(modelName: string): void {
+ modelSelectEl.value = modelName;
+ modelSelectEl.dispatchEvent(new Event('change'));
+}
+
+export function isThinkEnabled(): boolean {
+ return thinkCheckbox.checked && !thinkCheckbox.disabled;
+}
diff --git a/src/renderer/components/preset-bar.ts b/src/renderer/components/preset-bar.ts
new file mode 100644
index 0000000..0fdac69
--- /dev/null
+++ b/src/renderer/components/preset-bar.ts
@@ -0,0 +1,187 @@
+/**
+ * PresetBar - 预设栏组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import {
+ getPresets, getActivePresetId, activatePreset,
+ createPreset, updatePreset, deletePreset
+} from '../services/preset-manager.js';
+import { showToast } from './toast.js';
+import type { Preset } from '../types.js';
+
+let presetBarEl: HTMLElement;
+let presetListEl: HTMLElement;
+let editingPresetId: string | null = null;
+
+function escapeHtml(str: string): string {
+ const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(str).replace(/[&<>"']/g, c => map[c]);
+}
+
+export function initPresetBar(): void {
+ const modelBar = document.querySelector('.model-bar');
+ if (!modelBar) return;
+
+ const bar = document.createElement('div');
+ bar.className = 'preset-bar';
+ bar.innerHTML = `
+
+
+
+
+
+
+ `;
+ modelBar.after(bar);
+
+ presetBarEl = bar;
+ presetListEl = bar.querySelector('#presetList')!;
+
+ bar.querySelector('#btnAddPreset')!.addEventListener('click', () => openPresetModal());
+
+ state.on('activePresetId', renderPresetBar);
+ state.on('presets', renderPresetBar);
+
+ renderPresetBar();
+}
+
+function renderPresetBar(): void {
+ if (!presetListEl) return;
+
+ const presets = getPresets();
+ const activeId = getActivePresetId();
+
+ presetListEl.innerHTML = '';
+
+ for (const preset of presets) {
+ const chip = document.createElement('button');
+ chip.className = 'preset-chip' + (preset.id === activeId ? ' active' : '');
+ chip.title = preset.systemPrompt ? preset.systemPrompt.slice(0, 100) : '无系统提示词';
+ chip.innerHTML = `${preset.icon} ${escapeHtml(preset.name)} `;
+
+ chip.addEventListener('click', () => handlePresetClick(preset.id));
+ chip.addEventListener('contextmenu', (e) => {
+ if (!preset.builtIn) {
+ e.preventDefault();
+ editingPresetId = preset.id;
+ openPresetModal(preset);
+ }
+ });
+
+ presetListEl.appendChild(chip);
+ }
+}
+
+async function handlePresetClick(presetId: string): Promise {
+ const currentId = getActivePresetId();
+ if (presetId === currentId) return;
+
+ await activatePreset(presetId);
+
+ const preset = getPresets().find(p => p.id === presetId);
+ if (preset) {
+ showToast(`已切换到 ${preset.icon} ${preset.name}`, 'success', 2000);
+ }
+}
+
+function openPresetModal(editPreset?: Preset): void {
+ const modal = document.querySelector('#presetModal') as HTMLElement;
+ const titleEl = modal.querySelector('#presetModalTitle')!;
+ const nameInput = modal.querySelector('#inputPresetName') as HTMLInputElement;
+ const iconInput = modal.querySelector('#inputPresetIcon') as HTMLInputElement;
+ const spInput = modal.querySelector('#inputPresetSystemPrompt') as HTMLTextAreaElement;
+ const tempInput = modal.querySelector('#inputPresetTemp') as HTMLInputElement;
+ const tempVal = modal.querySelector('#presetTempValue')!;
+ const ctxInput = modal.querySelector('#inputPresetNumCtx') as HTMLInputElement;
+ const thinkCheck = modal.querySelector('#togglePresetThink') as HTMLInputElement;
+ const btnSave = modal.querySelector('#btnSavePreset') as HTMLButtonElement;
+ const btnDelete = modal.querySelector('#btnDeletePreset') as HTMLElement;
+
+ if (editPreset && editPreset.id) {
+ editingPresetId = editPreset.id;
+ titleEl.textContent = '✏️ 编辑预设';
+ nameInput.value = editPreset.name;
+ iconInput.value = editPreset.icon;
+ spInput.value = editPreset.systemPrompt;
+ tempInput.value = String(editPreset.temperature ?? 0.7);
+ tempVal.textContent = (editPreset.temperature ?? 0.7).toFixed(1);
+ ctxInput.value = String(editPreset.numCtx || 24576);
+ thinkCheck.checked = editPreset.think ?? false;
+ btnDelete.style.display = editPreset.builtIn ? 'none' : '';
+ btnSave.textContent = '保存';
+ } else {
+ editingPresetId = null;
+ titleEl.textContent = '✨ 新建预设';
+ nameInput.value = '';
+ iconInput.value = '🤖';
+ spInput.value = '';
+ tempInput.value = '0.7';
+ tempVal.textContent = '0.7';
+ ctxInput.value = '24576';
+ thinkCheck.checked = false;
+ btnDelete.style.display = 'none';
+ btnSave.textContent = '创建';
+ }
+
+ modal.style.display = '';
+ nameInput.focus();
+
+ tempInput.oninput = () => {
+ tempVal.textContent = parseFloat(tempInput.value).toFixed(1);
+ };
+}
+
+export function initPresetModal(): void {
+ const modal = document.querySelector('#presetModal') as HTMLElement;
+ if (!modal) return;
+
+ modal.querySelector('#btnClosePresetModal')!.addEventListener('click', () => {
+ modal.style.display = 'none';
+ });
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) modal.style.display = 'none';
+ });
+
+ modal.querySelector('#btnSavePreset')!.addEventListener('click', async () => {
+ const name = (modal.querySelector('#inputPresetName') as HTMLInputElement).value.trim();
+ if (!name) { showToast('请输入预设名称', 'warning'); return; }
+
+ const data = {
+ name,
+ icon: (modal.querySelector('#inputPresetIcon') as HTMLInputElement).value.trim() || '🤖',
+ systemPrompt: (modal.querySelector('#inputPresetSystemPrompt') as HTMLTextAreaElement).value.trim(),
+ temperature: parseFloat((modal.querySelector('#inputPresetTemp') as HTMLInputElement).value) || 0.7,
+ numCtx: parseInt((modal.querySelector('#inputPresetNumCtx') as HTMLInputElement).value) || 24576,
+ think: (modal.querySelector('#togglePresetThink') as HTMLInputElement).checked
+ };
+
+ if (editingPresetId) {
+ await updatePreset(editingPresetId, data);
+ showToast('预设已更新', 'success', 2000);
+ if (editingPresetId === getActivePresetId()) {
+ await activatePreset(editingPresetId);
+ }
+ } else {
+ const created = await createPreset(data);
+ await activatePreset(created.id);
+ showToast(`已创建并激活 ${data.icon} ${data.name}`, 'success', 2000);
+ }
+
+ modal.style.display = 'none';
+ editingPresetId = null;
+ });
+
+ modal.querySelector('#btnDeletePreset')!.addEventListener('click', async () => {
+ if (!editingPresetId) return;
+ const preset = getPresets().find(p => p.id === editingPresetId);
+ if (!preset) return;
+
+ if (confirm(`确定删除预设 "${preset.icon} ${preset.name}" ?`)) {
+ await deletePreset(editingPresetId);
+ showToast('预设已删除', 'info', 2000);
+ modal.style.display = 'none';
+ editingPresetId = null;
+ }
+ });
+}
diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts
new file mode 100644
index 0000000..2a7084e
--- /dev/null
+++ b/src/renderer/components/settings-modal.ts
@@ -0,0 +1,179 @@
+/**
+ * SettingsModal - 设置面板组件
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { debounce } from '../utils/utils.js';
+import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js';
+import { updateConnectionInfo, updateRunningModels } from './header.js';
+import { loadModels } from './model-bar.js';
+import { showToast } from './toast.js';
+import { OllamaAPI } from '../api/ollama.js';
+import { ChatDB } from '../db/chat-db.js';
+import type { ChatSession } from '../types.js';
+
+let settingsModalEl: HTMLElement;
+
+export function initSettingsModal(): void {
+ settingsModalEl = document.querySelector('#settingsModal')!;
+
+ document.querySelector('#btnSettings')!.addEventListener('click', openSettingsModal);
+ document.querySelector('#btnCloseSettings')!.addEventListener('click', closeSettingsModal);
+ settingsModalEl.addEventListener('click', (e) => {
+ if (e.target === settingsModalEl) closeSettingsModal();
+ });
+
+ const saveServerUrl = debounce(async () => {
+ const url = (document.querySelector('#inputServerUrl') as HTMLInputElement).value.trim();
+ if (!url) return;
+ const db = state.get(KEYS.DB);
+ const api = new OllamaAPI(url);
+ state.set(KEYS.API, api);
+ if (db) await db.saveSetting('serverUrl', url);
+ updateConnectionInfo();
+ loadModels();
+ }, 500);
+ document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
+
+ document.querySelector('#toggleSystemPrompt')!.addEventListener('change', async (e) => {
+ const db = state.get(KEYS.DB);
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, (e.target as HTMLInputElement).checked);
+ (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !(e.target as HTMLInputElement).checked;
+ if (db) await db.saveSetting('systemPromptEnabled', (e.target as HTMLInputElement).checked);
+ });
+
+ const saveSystemPrompt = debounce(async () => {
+ const db = state.get(KEYS.DB);
+ const val = (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).value.trim();
+ state.set(KEYS.SYSTEM_PROMPT, val);
+ if (db) await db.saveSetting('systemPrompt', val);
+ }, 500);
+ document.querySelector('#inputSystemPrompt')!.addEventListener('input', saveSystemPrompt);
+
+ const saveNumCtx = debounce(async () => {
+ const db = state.get(KEYS.DB);
+ const val = (document.querySelector('#inputNumCtx') as HTMLInputElement).value.trim();
+ const numCtx = val ? parseInt(val) : 24576;
+ state.set(KEYS.NUM_CTX, numCtx);
+ if (db) await db.saveSetting('numCtx', numCtx);
+ }, 500);
+ document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx);
+
+ const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
+ const tempDisplay = document.querySelector('#tempValue')!;
+ tempSlider.addEventListener('input', () => {
+ tempDisplay.textContent = parseFloat(tempSlider.value).toFixed(1);
+ });
+ const saveTemperature = debounce(async () => {
+ const db = state.get(KEYS.DB);
+ const val = parseFloat(tempSlider.value);
+ state.set('temperature', val);
+ if (db) await db.saveSetting('temperature', val);
+ }, 300);
+ tempSlider.addEventListener('change', saveTemperature);
+
+ document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
+ const api = state.get(KEYS.API);
+ const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
+ try {
+ await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 } as any);
+ showToast('显存已释放', 'success');
+ updateRunningModels();
+ } catch (err) {
+ showToast(`释放失败: ${(err as Error).message}`, 'error');
+ }
+ });
+
+ document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
+ const db = state.get(KEYS.DB);
+ if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
+ await db!.clearAll();
+ (document.querySelector('#btnNewChat') as HTMLElement).click();
+ showToast('已清空所有历史记录', 'success');
+ }
+ });
+
+ document.querySelector('#btnExportAll')!.addEventListener('click', exportAllSessions);
+
+ const importFileInput = document.querySelector('#importFileInput') as HTMLInputElement;
+ document.querySelector('#btnImportSessions')!.addEventListener('click', () => importFileInput.click());
+ importFileInput.addEventListener('change', (e) => {
+ if ((e.target as HTMLInputElement).files!.length > 0) {
+ importSessions((e.target as HTMLInputElement).files![0]);
+ (e.target as HTMLInputElement).value = '';
+ }
+ });
+}
+
+export function openSettingsModal(): void {
+ settingsModalEl.style.display = '';
+ (document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get(KEYS.API)?.baseUrl || '';
+ updateConnectionInfo();
+ updateRunningModels();
+}
+
+export function closeSettingsModal(): void {
+ settingsModalEl.style.display = 'none';
+}
+
+async function exportAllSessions(): Promise {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+ const sessions = await db.getAllSessions();
+ if (sessions.length === 0) {
+ showToast('没有可导出的会话', 'warning');
+ return;
+ }
+
+ const backup = {
+ app: 'Metona Ollama Client',
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ count: sessions.length,
+ sessions
+ };
+
+ try {
+ const blob = await encryptData(backup);
+ const ts = new Date().toISOString().slice(0, 10);
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `metona-backup-${ts}.metona`;
+ a.click();
+ showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
+ } catch (err) {
+ console.error('[Settings] 导出失败:', err);
+ showToast(`导出失败: ${(err as Error).message}`, 'error');
+ }
+}
+
+async function importSessions(file: File): Promise {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+
+ if (!isMetonaFile(file)) {
+ showToast('仅支持导入 .metona 格式文件', 'error');
+ return;
+ }
+
+ try {
+ const buffer = await file.arrayBuffer();
+ const data = await decryptData(buffer);
+
+ let sessions: ChatSession[];
+ if (Array.isArray(data)) {
+ sessions = data as ChatSession[];
+ } else if ((data as any).sessions && Array.isArray((data as any).sessions)) {
+ sessions = (data as any).sessions;
+ } else {
+ showToast('文件内容格式错误:未找到会话数据', 'error');
+ return;
+ }
+
+ const result = await db.importSessions(sessions);
+ showToast(`导入完成:${result.imported} 个会话${result.skipped > 0 ? `,跳过 ${result.skipped} 个` : ''}`, 'success', 4000);
+ } catch (err) {
+ console.error('[Settings] 导入失败:', err);
+ showToast(`导入失败: ${(err as Error).message}`, 'error');
+ }
+}
diff --git a/src/renderer/components/toast.ts b/src/renderer/components/toast.ts
new file mode 100644
index 0000000..135cc2e
--- /dev/null
+++ b/src/renderer/components/toast.ts
@@ -0,0 +1,24 @@
+/**
+ * Toast - 通知组件
+ */
+
+let toastContainer: HTMLElement | null = null;
+
+export function initToast(): void {
+ toastContainer = document.querySelector('#toastContainer');
+}
+
+export function showToast(text: string, type: 'info' | 'success' | 'warning' | 'error' = 'info', duration = 3000): void {
+ if (!toastContainer) return;
+
+ const iconMap: Record = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.innerHTML = `${iconMap[type] || 'ℹ'} ${text} `;
+ toastContainer.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add('removing');
+ toast.addEventListener('animationend', () => toast.remove());
+ }, duration);
+}
diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts
new file mode 100644
index 0000000..180a46e
--- /dev/null
+++ b/src/renderer/db/chat-db.ts
@@ -0,0 +1,154 @@
+/**
+ * ChatDB - IndexedDB 封装层
+ */
+
+import type { ChatSession } from '../types.js';
+
+export class ChatDB {
+ private dbName: string;
+ private version: number;
+ private db: IDBDatabase | null = null;
+
+ constructor(dbName = 'metona-ollama', version = 1) {
+ this.dbName = dbName;
+ this.version = version;
+ }
+
+ async init(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+ request.onerror = () => {
+ console.error('[ChatDB] 数据库打开失败:', request.error);
+ reject(request.error);
+ };
+ request.onsuccess = () => {
+ this.db = request.result;
+ console.log('[ChatDB] 数据库已连接');
+ resolve();
+ };
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains('sessions')) {
+ const sessionStore = db.createObjectStore('sessions', { keyPath: 'id' });
+ sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false });
+ sessionStore.createIndex('model', 'model', { unique: false });
+ }
+ if (!db.objectStoreNames.contains('settings')) {
+ db.createObjectStore('settings', { keyPath: 'key' });
+ }
+ };
+ });
+ }
+
+ private _ensureDB(): void {
+ if (!this.db) throw new Error('数据库未初始化,请先调用 init()');
+ }
+
+ private _tx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
+ this._ensureDB();
+ const tx = this.db!.transaction(storeName, mode);
+ return tx.objectStore(storeName);
+ }
+
+ async saveSession(session: ChatSession): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions', 'readwrite');
+ const request = store.put(session);
+ request.onsuccess = () => resolve(session.id);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async getSession(id: string): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions');
+ const request = store.get(id);
+ request.onsuccess = () => resolve(request.result || null);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async getAllSessions(): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions');
+ const request = store.getAll();
+ request.onsuccess = () => resolve(request.result || []);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async deleteSession(id: string): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions', 'readwrite');
+ const request = store.delete(id);
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async clearAll(): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions', 'readwrite');
+ const request = store.clear();
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async importSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> {
+ return new Promise((resolve, reject) => {
+ const tx = this.db!.transaction('sessions', 'readwrite');
+ const store = tx.objectStore('sessions');
+ let imported = 0;
+ let skipped = 0;
+
+ tx.oncomplete = () => resolve({ imported, skipped });
+ tx.onerror = () => reject(tx.error);
+
+ for (const session of sessions) {
+ if (!session.id || !Array.isArray(session.messages)) {
+ skipped++;
+ continue;
+ }
+ const getRequest = store.get(session.id);
+ getRequest.onsuccess = () => {
+ if (getRequest.result) {
+ skipped++;
+ } else {
+ store.put(session);
+ imported++;
+ }
+ };
+ }
+ });
+ }
+
+ async getSessionsByTimeRange(startTime: number, endTime: number): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('sessions');
+ const index = store.index('updatedAt');
+ const range = IDBKeyRange.bound(startTime, endTime);
+ const request = index.getAll(range);
+ request.onsuccess = () => resolve(request.result || []);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async saveSetting(key: string, value: unknown): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('settings', 'readwrite');
+ const request = store.put({ key, value });
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async getSetting(key: string, defaultValue: T | null = null): Promise {
+ return new Promise((resolve, reject) => {
+ const store = this._tx('settings');
+ const request = store.get(key);
+ request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue);
+ request.onerror = () => reject(request.error);
+ });
+ }
+}
diff --git a/src/renderer/index.html b/src/renderer/index.html
new file mode 100644
index 0000000..9e1ef0d
--- /dev/null
+++ b/src/renderer/index.html
@@ -0,0 +1,379 @@
+
+
+
+
+
+
+
+
+ Metona Ollama
+
+
+
+
+
+
+
+
+
+
+
+
+ 🧠 Think
+ 👁️ Vision
+ 📚 RAG
+
+
+
+
+
+
+
+
开始对话
+
选择一个模型,输入消息开始聊天
+
+
+
+
+
+
+ 回到底部
+
+
+
+
+
+ 📖 正在查看历史记录(只读模式)
+ 返回新会话
+
+
+
+
+
+
+
+
+
+
+
+
+
Ollama 服务地址
+
+
+ 检测中...
+
+
+
+
⚠️ 连接失败,可能是 CORS 问题。请在启动 Ollama 时设置环境变量:
+
OLLAMA_ORIGINS="*" ollama serve
+
+
+
+
+ 系统提示词(System Prompt)
+
+
+
+
+
+
+
上下文长度(num_ctx)
+
+
+ tokens
+
+
常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。
+
温度(Temperature):0.7
+
+
低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)
+
+
+
+ 显存管理
+ 释放显存(卸载模型)
+
+
+
数据管理
+
+ 📦 导出全部会话
+ 📥 导入会话
+
+ 清空所有历史
+
+
导出格式为 JSON,可备份后在其他设备导入恢复。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🚀 快速开始 确保 Ollama 已启动(默认 http://127.0.0.1:11434) 顶部下拉框选择一个模型 输入消息,按 Enter 发送,Shift+Enter 换行
+
💬 聊天功能 流式回复 — 实时打字效果,随时点 ■ 停止Think 推理 — 🧠 按钮开启深度思考(需模型支持)上下文长度 — 设置中调整 num_ctx,越大记忆越长系统提示词 — 设置中开启,定义 AI 的角色和行为
+
📎 文件与图片 📷 上传图片 — 图标按钮上传,模型需支持 Vision📄 上传文件 — 支持 50+ 种文本/代码格式,单文件 ≤500KB文件内容自动以代码块格式发送给模型分析
+
📚 知识库 (RAG) 点击顶部 📚 按钮打开知识库管理 创建集合 → 选择嵌入模型(推荐 nomic-embed-text 等) 上传文档,自动分块并生成向量索引 开启 RAG 检索后,聊天时自动检索相关文档注入回答
+
🤖 Agent 预设 模型栏下方显示预设栏 ,点击胶囊按钮一键切换角色/工作模式 内置预设 — 💬默认、🌐翻译官、🔍代码审查、✍️写作助手、📊数据分析师、🎓学习导师切换预设自动应用:系统提示词、温度、上下文长度、Think 开关 点击 + 按钮可创建自定义预设
+
🕐 历史记录 所有会话自动保存到 IndexedDB 点击 🕐 按钮查看、搜索、恢复历史会话 支持导出 .metona 加密备份文件
+
⚡ 快捷键 Enter 发送消息 Shift + Enter 换行 Esc 关闭弹窗
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 系统提示词(System Prompt)
+
+
+
+ 温度(Temperature):0.7
+
+
+
+
上下文长度(num_ctx)
+
+
+ tokens
+
+
+
+ Think 深度推理
+
+
+
+ 需要模型支持
+
+
+
+
+
+
+
+
+
+
✕
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts
new file mode 100644
index 0000000..7adcfb8
--- /dev/null
+++ b/src/renderer/main.ts
@@ -0,0 +1,243 @@
+/**
+ * App - 主入口模块
+ * 负责初始化所有组件、协调事件流、管理生命周期
+ */
+
+import '../styles/style.css';
+import './types.js';
+
+import { ChatDB } from './db/chat-db.js';
+import { OllamaAPI } from './api/ollama.js';
+import { state, KEYS } from './state/state.js';
+import { generateId } from './utils/utils.js';
+
+// 组件
+import { initToast, showToast } from './components/toast.js';
+import { initLightbox, closeLightbox } from './components/lightbox.js';
+import { initHeader, checkConnection } from './components/header.js';
+import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
+import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
+import { initInputArea } from './components/input-area.js';
+import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
+import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
+import { initKBModal } from './components/kb-modal.js';
+import { initPresetBar, initPresetModal } from './components/preset-bar.js';
+import { initPresetManager } from './services/preset-manager.js';
+import { initVectorStore } from './services/rag.js';
+import type { ChatSession } from './types.js';
+
+function initHelpModal(): void {
+ const helpModal = document.querySelector('#helpModal') as HTMLElement;
+ document.querySelector('#btnHelp')!.addEventListener('click', () => {
+ helpModal.style.display = '';
+ });
+ document.querySelector('#btnCloseHelp')!.addEventListener('click', () => {
+ helpModal.style.display = 'none';
+ });
+ helpModal.addEventListener('click', (e) => {
+ if (e.target === helpModal) helpModal.style.display = 'none';
+ });
+}
+
+function setupDesktopIntegration(): void {
+ const bridge = window.__metonaBridge;
+ if (!bridge || !bridge.isDesktop) return;
+
+ console.log('[Desktop Integration] 初始化桌面集成...');
+
+ bridge.onMenuAction((action: string) => {
+ switch (action) {
+ case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break;
+ case 'import': document.querySelector('#btnImportSessions')?.dispatchEvent(new Event('click')); break;
+ case 'export': document.querySelector('#btnExportAll')?.dispatchEvent(new Event('click')); break;
+ case 'help': document.querySelector('#btnHelp')?.dispatchEvent(new Event('click')); break;
+ }
+ });
+
+ bridge.onTrayAction((action: string) => {
+ switch (action) {
+ case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break;
+ }
+ });
+
+ // 标题更新
+ window.addEventListener('metona:model-changed', ((e: CustomEvent) => {
+ if (e.detail?.model) {
+ document.title = `${e.detail.model} - Metona Ollama`;
+ } else {
+ document.title = 'Metona Ollama';
+ }
+ }) as EventListener);
+
+ // 桌面端快捷键
+ document.addEventListener('keydown', (e) => {
+ if (e.ctrlKey && e.key === 'n') {
+ e.preventDefault();
+ document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click'));
+ }
+ });
+
+ console.log('[Desktop Integration] 桌面集成完成 ✓');
+}
+
+console.log('[Metona] TypeScript + Electron v2.0.0 ' + new Date().toISOString());
+
+function createNewSession(): ChatSession {
+ const selectedModel = (document.querySelector('#modelSelect') as HTMLSelectElement)?.value || '';
+ const defaultModel = selectedModel || state.get('_defaultModel', '');
+ return {
+ id: generateId(),
+ title: '新会话',
+ model: defaultModel,
+ messages: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+ };
+}
+
+async function startNewSession(): Promise {
+ const db = state.get(KEYS.DB);
+ const currentSession = state.get(KEYS.CURRENT_SESSION);
+ if (db && currentSession && currentSession.messages.length > 0) {
+ currentSession.updatedAt = Date.now();
+ await db.saveSession(currentSession);
+ }
+
+ const api = state.get(KEYS.API);
+ const model = currentSession?.model || state.get('_defaultModel', '');
+ if (api && model) {
+ try {
+ await api.chat({ model, messages: [], keep_alive: 0 } as any);
+ } catch (e) {
+ console.warn('[App] 释放显存失败:', e);
+ }
+ }
+
+ const session = createNewSession();
+ state.set(KEYS.CURRENT_SESSION, session);
+ state.set(KEYS.IS_HISTORY_VIEW, false);
+
+ (document.querySelector('#historyBar') as HTMLElement).style.display = 'none';
+ (document.querySelector('#inputArea') as HTMLElement).style.display = '';
+
+ clearMessages();
+ enableAutoScroll();
+ renderMessages();
+ (document.querySelector('#chatInput') as HTMLElement)?.focus();
+}
+
+async function init(): Promise {
+ let db: ChatDB | undefined;
+ let api: OllamaAPI;
+
+ try {
+ db = new ChatDB();
+ await db.init();
+ state.set(KEYS.DB, db);
+
+ const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
+ api = new OllamaAPI(serverUrl);
+ state.set(KEYS.API, api);
+
+ state.set(KEYS.CURRENT_SESSION, createNewSession());
+ state.set(KEYS.IS_STREAMING, false);
+ state.set(KEYS.IS_HISTORY_VIEW, false);
+ state.set(KEYS.NUM_CTX, 24576);
+
+ initToast();
+ initLightbox();
+ initHeader();
+ initModelBar();
+ initChatArea();
+ initInputArea();
+ initSettingsModal();
+ initHistoryModal();
+ initKBModal();
+ initPresetBar();
+ initPresetModal();
+ initHelpModal();
+ setupDesktopIntegration();
+
+ bindGlobalEvents();
+
+ await checkConnection();
+ await loadModels();
+ await initVectorStore();
+ await initPresetManager();
+
+ const savedModel = await db.getSetting('selectedModel', '');
+ if (savedModel) {
+ setSelectedModel(savedModel);
+ state.set('_defaultModel', savedModel);
+ await db.saveSetting('selectedModel', savedModel);
+ }
+
+ const systemPromptEnabled = await db.getSetting('systemPromptEnabled', false);
+ const systemPrompt = await db.getSetting('systemPrompt', '');
+ const numCtx = await db.getSetting('numCtx', 24576);
+ const temperature = await db.getSetting('temperature', 0.7);
+
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, systemPromptEnabled);
+ state.set(KEYS.SYSTEM_PROMPT, systemPrompt);
+ state.set(KEYS.NUM_CTX, numCtx);
+ state.set('temperature', temperature);
+
+ (document.querySelector('#toggleSystemPrompt') as HTMLInputElement).checked = systemPromptEnabled;
+ (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !systemPromptEnabled;
+ (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).value = systemPrompt;
+ (document.querySelector('#inputNumCtx') as HTMLInputElement).value = String(numCtx);
+ (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
+ document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1);
+
+ (document.querySelector('#chatInput') as HTMLElement)?.focus();
+
+ console.log('[App] 初始化完成');
+ } catch (err) {
+ console.error('[App] 初始化失败:', err);
+ if (!api!) {
+ api = new OllamaAPI();
+ state.set(KEYS.API, api);
+ }
+ state.set(KEYS.CURRENT_SESSION, createNewSession());
+ bindGlobalEvents();
+ initToast();
+ initLightbox();
+ initHeader();
+ initModelBar();
+ initChatArea();
+ initInputArea();
+ initSettingsModal();
+ initHistoryModal();
+ initPresetBar();
+ initPresetModal();
+ setupDesktopIntegration();
+ checkConnection().then(loadModels);
+ }
+}
+
+function bindGlobalEvents(): void {
+ document.querySelector('#btnNewChat')?.addEventListener('click', startNewSession);
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ closeSettingsModal();
+ closeHistoryModal();
+ closeLightbox();
+ (document.querySelector('#helpModal') as HTMLElement).style.display = 'none';
+ (document.querySelector('#kbModal') as HTMLElement).style.display = 'none';
+ }
+ });
+
+ window.addEventListener('error', (e) => {
+ console.error('[App] 未捕获错误:', e.error || e.message);
+ showToast(`发生错误: ${e.message}`, 'error', 5000);
+ });
+
+ window.addEventListener('unhandledrejection', (e) => {
+ console.error('[App] 未处理 Promise 拒绝:', e.reason);
+ const msg = e.reason?.message || String(e.reason);
+ showToast(`操作失败: ${msg}`, 'error', 5000);
+ });
+}
+
+init();
diff --git a/src/renderer/services/crypto.ts b/src/renderer/services/crypto.ts
new file mode 100644
index 0000000..f335d51
--- /dev/null
+++ b/src/renderer/services/crypto.ts
@@ -0,0 +1,163 @@
+/**
+ * Crypto - Metona 会话加密模块
+ */
+
+const MAGIC = new TextEncoder().encode('METONA1\0');
+const PASSPHRASE = 'metona-ollama-v2';
+const PBKDF2_ITERATIONS = 100000;
+
+const SECURE = !!(globalThis.crypto && globalThis.crypto.subtle);
+
+async function sha256(data: Uint8Array): Promise {
+ if (SECURE) {
+ const buf = await crypto.subtle.digest('SHA-256', data);
+ return new Uint8Array(buf);
+ }
+ return sha256js(data);
+}
+
+function sha256js(message: Uint8Array): Uint8Array {
+ const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
+ ]);
+
+ const bytes = message instanceof Uint8Array ? message : new Uint8Array(message);
+ const bitLen = bytes.length * 8;
+ const paddedLen = Math.ceil((bytes.length + 9) / 64) * 64;
+ const padded = new Uint8Array(paddedLen);
+ padded.set(bytes);
+ padded[bytes.length] = 0x80;
+ const view = new DataView(padded.buffer);
+ view.setUint32(paddedLen - 4, bitLen, false);
+
+ let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
+ let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
+
+ const w = new Uint32Array(64);
+
+ for (let offset = 0; offset < paddedLen; offset += 64) {
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false);
+ for (let i = 16; i < 64; i++) {
+ const s0 = (w[i - 15] >>> 7 | w[i - 15] << 25) ^ (w[i - 15] >>> 18 | w[i - 15] << 14) ^ (w[i - 15] >>> 3);
+ const s1 = (w[i - 2] >>> 17 | w[i - 2] << 15) ^ (w[i - 2] >>> 19 | w[i - 2] << 13) ^ (w[i - 2] >>> 10);
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
+ }
+ let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
+ for (let i = 0; i < 64; i++) {
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
+ const ch = (e & f) ^ (~e & g);
+ const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0;
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
+ const maj = (a & b) ^ (a & c) ^ (b & c);
+ const temp2 = (S0 + maj) >>> 0;
+ h = g; g = f; f = e;
+ e = (d + temp1) >>> 0;
+ d = c; c = b; b = a;
+ a = (temp1 + temp2) >>> 0;
+ }
+ h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
+ h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0;
+ }
+
+ const result = new Uint8Array(32);
+ const rv = new DataView(result.buffer);
+ rv.setUint32(0, h0, false); rv.setUint32(4, h1, false);
+ rv.setUint32(8, h2, false); rv.setUint32(12, h3, false);
+ rv.setUint32(16, h4, false); rv.setUint32(20, h5, false);
+ rv.setUint32(24, h6, false); rv.setUint32(28, h7, false);
+ return result;
+}
+
+async function deriveKeyBytes(salt: Uint8Array): Promise {
+ const passBytes = new TextEncoder().encode(PASSPHRASE);
+ const input = new Uint8Array(passBytes.length + salt.length);
+ input.set(passBytes);
+ input.set(salt, passBytes.length);
+
+ if (SECURE) {
+ const keyMaterial = await crypto.subtle.importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']);
+ const key = await crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['encrypt', 'decrypt']
+ );
+ const raw = await crypto.subtle.exportKey('raw', key);
+ return new Uint8Array(raw);
+ }
+
+ let key = input;
+ for (let i = 0; i < 10000; i++) {
+ key = await sha256(key);
+ }
+ return key;
+}
+
+function xorBytes(data: Uint8Array, keyBytes: Uint8Array): Uint8Array {
+ const out = new Uint8Array(data.length);
+ for (let i = 0; i < data.length; i++) {
+ out[i] = data[i] ^ keyBytes[i % keyBytes.length];
+ }
+ return out;
+}
+
+export async function encryptData(data: unknown): Promise {
+ const json = new TextEncoder().encode(JSON.stringify(data));
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const keyBytes = await deriveKeyBytes(salt);
+
+ let payload: Uint8Array;
+ if (SECURE) {
+ const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json);
+ payload = new Uint8Array(ciphertext);
+ } else {
+ const lenBytes = new Uint32Array([json.length]);
+ const withLen = new Uint8Array(4 + json.length);
+ withLen.set(new Uint8Array(lenBytes.buffer));
+ withLen.set(json, 4);
+ payload = xorBytes(withLen, keyBytes);
+ }
+
+ const flag = new Uint8Array([SECURE ? 1 : 0]);
+ return new Blob([MAGIC, salt, iv, flag, payload]);
+}
+
+export async function decryptData(buffer: ArrayBuffer): Promise {
+ const data = new Uint8Array(buffer);
+ for (let i = 0; i < 8; i++) {
+ if (data[i] !== MAGIC[i]) throw new Error('不是有效的 .metona 文件');
+ }
+
+ const salt = data.slice(8, 24);
+ const iv = data.slice(24, 36);
+ const mode = data[36];
+ const payload = data.slice(37);
+ const keyBytes = await deriveKeyBytes(salt);
+
+ let jsonBytes: Uint8Array;
+ if (mode === 1) {
+ const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload);
+ jsonBytes = new Uint8Array(decrypted);
+ } else {
+ const withLen = xorBytes(payload, keyBytes);
+ const len = new DataView(withLen.buffer).getUint32(0, true);
+ jsonBytes = withLen.slice(4, 4 + len);
+ }
+
+ return JSON.parse(new TextDecoder().decode(jsonBytes));
+}
+
+export function isMetonaFile(file: File): boolean {
+ return file.name.endsWith('.metona');
+}
diff --git a/src/renderer/services/document-processor.ts b/src/renderer/services/document-processor.ts
new file mode 100644
index 0000000..81ac8a3
--- /dev/null
+++ b/src/renderer/services/document-processor.ts
@@ -0,0 +1,74 @@
+/**
+ * DocumentProcessor - 文档分块处理
+ */
+
+export function chunkText(text: string, { maxChunkSize = 1500, overlap = 200 } = {}): string[] {
+ if (!text || text.trim().length === 0) return [];
+
+ const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
+ const chunks: string[] = [];
+ let current = '';
+
+ for (const para of paragraphs) {
+ const trimmed = para.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.length > maxChunkSize) {
+ if (current) {
+ chunks.push(current.trim());
+ current = '';
+ }
+ const sentences = splitSentences(trimmed);
+ let sentenceBuf = '';
+ for (const sent of sentences) {
+ if ((sentenceBuf + sent).length > maxChunkSize && sentenceBuf) {
+ chunks.push(sentenceBuf.trim());
+ sentenceBuf = overlap > 0 ? sentenceBuf.slice(-overlap) + sent : sent;
+ } else {
+ sentenceBuf += sent;
+ }
+ }
+ if (sentenceBuf.trim()) {
+ current = sentenceBuf;
+ }
+ continue;
+ }
+
+ if ((current + '\n\n' + trimmed).length > maxChunkSize && current) {
+ chunks.push(current.trim());
+ if (overlap > 0 && current.length > overlap) {
+ current = current.slice(-overlap) + '\n\n' + trimmed;
+ } else {
+ current = trimmed;
+ }
+ } else {
+ current = current ? current + '\n\n' + trimmed : trimmed;
+ }
+ }
+
+ if (current.trim()) {
+ chunks.push(current.trim());
+ }
+
+ return chunks;
+}
+
+function splitSentences(text: string): string[] {
+ const parts = text.split(/(?<=[。!?.!?])\s*/);
+ return parts.filter(p => p.trim()).map(p => p.trim() + ' ');
+}
+
+export function extractDocument(content: string, filename: string): { text: string; filename: string } {
+ return { text: content, filename };
+}
+
+export function createChunkMetadata(chunk: string, index: number, docId: string, filename: string) {
+ return {
+ id: `${docId}_chunk_${index}`,
+ docId,
+ filename,
+ chunkIndex: index,
+ text: chunk,
+ charCount: chunk.length
+ };
+}
diff --git a/src/renderer/services/preset-manager.ts b/src/renderer/services/preset-manager.ts
new file mode 100644
index 0000000..49b1280
--- /dev/null
+++ b/src/renderer/services/preset-manager.ts
@@ -0,0 +1,179 @@
+/**
+ * PresetManager - Agent 预设管理
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { generateId } from '../utils/utils.js';
+import type { Preset, ChatDB } from '../types.js';
+
+const PRESETS_KEY = 'agentPresets';
+const ACTIVE_PRESET_KEY = 'activePresetId';
+
+const BUILT_IN_PRESETS: Preset[] = [
+ { id: '_default', name: '默认', icon: '💬', systemPrompt: '', temperature: 0.7, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
+ { id: '_translator', name: '翻译官', icon: '🌐', systemPrompt: '你是一位专业翻译官。你的任务是将用户输入的内容准确翻译为目标语言。\n\n规则:\n1. 自动识别源语言,翻译为用户指定的目标语言(如未指定则翻译为中文)\n2. 保持原文的格式、语气和风格\n3. 专有名词保留原文并给出通用译名\n4. 对于有多种含义的词语,结合上下文选择最合适的翻译\n5. 直接输出翻译结果,不需要额外解释(除非用户要求)', temperature: 0.3, numCtx: 16384, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
+ { id: '_code_review', name: '代码审查', icon: '🔍', systemPrompt: '你是一位资深代码审查专家。对用户提交的代码进行全面审查。\n\n审查维度:\n1. **Bug 与逻辑错误** — 潜在的运行时错误、边界条件、空指针等\n2. **安全性** — SQL注入、XSS、敏感信息泄露等安全隐患\n3. **性能** — 时间/空间复杂度、不必要的计算、内存泄漏\n4. **可读性** — 命名规范、代码结构、注释质量\n5. **最佳实践** — 设计模式、语言惯用法、框架约定\n\n输出格式:\n- 按严重程度排列问题(严重 → 轻微)\n- 每个问题给出:位置、问题描述、修复建议、改进后的代码\n- 最后给出整体评价和改进建议', temperature: 0.2, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 },
+ { id: '_writer', name: '写作助手', icon: '✍️', systemPrompt: '你是一位才华横溢的写作助手。擅长多种文体和写作风格。\n\n能力范围:\n1. 文章撰写 — 议论文、说明文、散文、故事\n2. 文案创作 — 广告文案、产品描述、社交媒体文案\n3. 内容润色 — 修改语法、优化措辞、提升表达力\n4. 风格调整 — 正式/口语/文艺/简洁等多种风格\n5. 创意写作 — 小说、诗歌、剧本、对话\n\n工作方式:\n- 仔细理解用户的写作需求(读者、用途、风格偏好)\n- 如需信息不全,主动询问确认\n- 提供多种版本供选择\n- 解释重要的写作决策', temperature: 0.9, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
+ { id: '_analyst', name: '数据分析师', icon: '📊', systemPrompt: '你是一位专业的数据分析师。擅长从数据中提取洞察并给出可执行的建议。\n\n分析方法:\n1. 数据概览 — 数据量、字段类型、缺失值、异常值\n2. 统计分析 — 描述性统计、相关性分析、趋势分析\n3. 可视化建议 — 推荐合适的图表类型和展示方式\n4. 洞察提取 — 关键发现、模式识别、因果推断\n5. 行动建议 — 基于数据的具体可执行建议\n\n输出格式:\n- 先给出核心结论(Executive Summary)\n- 再展开详细分析过程\n- 使用表格和列表组织数据\n- 给出代码(Python/SQL)时附带注释', temperature: 0.4, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 },
+ { id: '_tutor', name: '学习导师', icon: '🎓', systemPrompt: '你是一位耐心且善于启发的学习导师。用苏格拉底式教学法帮助用户深入理解知识。\n\n教学原则:\n1. **先问后答** — 通过引导性问题帮助用户自己找到答案\n2. **由浅入深** — 从基础概念开始,逐步构建知识体系\n3. **类比教学** — 用生活中的类比解释抽象概念\n4. **实践导向** — 每个知识点配一个动手练习\n5. **鼓励探索** — 提供延伸阅读方向和思考题', temperature: 0.6, numCtx: 24576, think: true, builtIn: true, createdAt: 0, updatedAt: 0 }
+];
+
+let presets: Preset[] = [];
+let activePresetId: string | null = null;
+
+export async function initPresetManager(): Promise {
+ const db = state.get(KEYS.DB);
+
+ let userPresets: Preset[] = [];
+ if (db) {
+ userPresets = await db.getSetting(PRESETS_KEY, []);
+ }
+
+ presets = [...BUILT_IN_PRESETS, ...userPresets];
+
+ if (db) {
+ activePresetId = await db.getSetting(ACTIVE_PRESET_KEY, '_default');
+ } else {
+ activePresetId = '_default';
+ }
+
+ state.set('presets', presets);
+ state.set('activePresetId', activePresetId);
+
+ const activePreset = presets.find(p => p.id === activePresetId);
+ if (activePreset && activePreset.id !== '_default') {
+ applyPresetToState(activePreset, false);
+ }
+}
+
+export function getPresets(): Preset[] {
+ return presets;
+}
+
+export function getActivePreset(): Preset {
+ return presets.find(p => p.id === activePresetId) || presets[0];
+}
+
+export function getActivePresetId(): string | null {
+ return activePresetId;
+}
+
+export async function activatePreset(presetId: string): Promise {
+ const preset = presets.find(p => p.id === presetId);
+ if (!preset) return;
+
+ activePresetId = presetId;
+ state.set('activePresetId', presetId);
+ applyPresetToState(preset, true);
+
+ const db = state.get(KEYS.DB);
+ if (db) {
+ await db.saveSetting(ACTIVE_PRESET_KEY, presetId);
+ }
+}
+
+function applyPresetToState(preset: Preset, saveToDb: boolean): void {
+ const db = state.get(KEYS.DB);
+
+ if (preset.systemPrompt) {
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, true);
+ state.set(KEYS.SYSTEM_PROMPT, preset.systemPrompt);
+ } else {
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, false);
+ state.set(KEYS.SYSTEM_PROMPT, '');
+ }
+
+ if (preset.numCtx) state.set(KEYS.NUM_CTX, preset.numCtx);
+ state.set('temperature', preset.temperature ?? 0.7);
+ state.set('thinkEnabled', preset.think ?? false);
+
+ syncPresetToUI(preset);
+
+ if (saveToDb && db) {
+ db.saveSetting('systemPromptEnabled', !!preset.systemPrompt);
+ db.saveSetting('systemPrompt', preset.systemPrompt || '');
+ db.saveSetting('numCtx', preset.numCtx || 24576);
+ db.saveSetting('temperature', preset.temperature ?? 0.7);
+ }
+}
+
+function syncPresetToUI(preset: Preset): void {
+ const toggleSP = document.querySelector('#toggleSystemPrompt');
+ const inputSP = document.querySelector('#inputSystemPrompt');
+ if (toggleSP) toggleSP.checked = !!preset.systemPrompt;
+ if (inputSP) { inputSP.disabled = !preset.systemPrompt; inputSP.value = preset.systemPrompt || ''; }
+
+ const inputCtx = document.querySelector('#inputNumCtx');
+ if (inputCtx) inputCtx.value = String(preset.numCtx || 24576);
+
+ const tempSlider = document.querySelector('#inputTemperature');
+ const tempDisplay = document.querySelector('#tempValue');
+ if (tempSlider) tempSlider.value = String(preset.temperature ?? 0.7);
+ if (tempDisplay) tempDisplay.textContent = (preset.temperature ?? 0.7).toFixed(1);
+
+ const thinkCheckbox = document.querySelector('#toggleThink');
+ if (thinkCheckbox) {
+ const modelSupportsThink = !thinkCheckbox.disabled;
+ thinkCheckbox.checked = modelSupportsThink && (preset.think ?? false);
+ }
+}
+
+export async function createPreset(data: { name: string; icon: string; systemPrompt: string; temperature: number; numCtx: number; think: boolean }): Promise {
+ const preset: Preset = {
+ id: `preset_${generateId()}`,
+ name: data.name || '新预设',
+ icon: data.icon || '🤖',
+ systemPrompt: data.systemPrompt || '',
+ temperature: data.temperature ?? 0.7,
+ numCtx: data.numCtx ?? 24576,
+ think: data.think ?? false,
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+ };
+
+ presets.push(preset);
+ state.set('presets', [...presets]);
+ await saveUserPresets();
+ return preset;
+}
+
+export async function updatePreset(presetId: string, updates: Partial): Promise {
+ const idx = presets.findIndex(p => p.id === presetId);
+ if (idx === -1) return;
+ if (presets[idx].builtIn) return;
+
+ presets[idx] = { ...presets[idx], ...updates, updatedAt: Date.now() };
+ state.set('presets', [...presets]);
+
+ if (activePresetId === presetId) {
+ applyPresetToState(presets[idx], true);
+ }
+
+ await saveUserPresets();
+}
+
+export async function deletePreset(presetId: string): Promise {
+ const idx = presets.findIndex(p => p.id === presetId);
+ if (idx === -1) return;
+ if (presets[idx].builtIn) return;
+
+ presets.splice(idx, 1);
+ state.set('presets', [...presets]);
+
+ if (activePresetId === presetId) {
+ await activatePreset('_default');
+ }
+
+ await saveUserPresets();
+}
+
+async function saveUserPresets(): Promise {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+
+ const userPresets = presets
+ .filter(p => !p.builtIn)
+ .map(({ builtIn, ...rest }) => rest);
+
+ await db.saveSetting(PRESETS_KEY, userPresets);
+}
diff --git a/src/renderer/services/rag.ts b/src/renderer/services/rag.ts
new file mode 100644
index 0000000..de9c3c0
--- /dev/null
+++ b/src/renderer/services/rag.ts
@@ -0,0 +1,145 @@
+/**
+ * RAG - 检索增强生成管线
+ */
+
+import { state, KEYS } from '../state/state.js';
+import { VectorStore } from './vector-store.js';
+import { chunkText, createChunkMetadata } from './document-processor.js';
+import type { OllamaAPI, SearchResult, VectorItem } from '../types.js';
+
+let vectorStore: VectorStore | null = null;
+
+export async function initVectorStore(): Promise {
+ if (vectorStore) return vectorStore;
+ vectorStore = new VectorStore();
+ await vectorStore.init();
+ return vectorStore;
+}
+
+export function getVectorStore(): VectorStore | null {
+ return vectorStore;
+}
+
+export async function embedText(text: string, model: string): Promise {
+ const api = state.get(KEYS.API);
+ if (!api) throw new Error('Ollama API 未连接');
+
+ const result = await api.embed(model, text);
+ if (result.embedding) return result.embedding;
+ if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
+ throw new Error('嵌入向量返回格式异常');
+}
+
+export async function embedBatch(texts: string[], model: string, onProgress?: (done: number, total: number) => void): Promise {
+ const results: number[][] = [];
+ const batchSize = 5;
+ for (let i = 0; i < texts.length; i += batchSize) {
+ const batch = texts.slice(i, i + batchSize);
+ const embeddings = await Promise.all(batch.map(t => embedText(t, model)));
+ results.push(...embeddings);
+ if (onProgress) onProgress(Math.min(i + batchSize, texts.length), texts.length);
+ }
+ return results;
+}
+
+export async function addDocumentToKB(
+ colId: string, filename: string, content: string, embedModel: string,
+ onProgress?: (done: number, total: number, msg: string) => void
+): Promise<{ docId: string; chunkCount: number }> {
+ const vs = await initVectorStore();
+
+ const textChunks = chunkText(content);
+ if (textChunks.length === 0) throw new Error('文档内容为空,无法处理');
+
+ const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
+
+ if (onProgress) onProgress(0, textChunks.length, '正在生成向量...');
+ const embeddings = await embedBatch(textChunks, embedModel, (done, total) => {
+ if (onProgress) onProgress(done, total, '正在生成向量...');
+ });
+
+ const vectors: VectorItem[] = textChunks.map((chunk, i) => {
+ const meta = createChunkMetadata(chunk, i, docId, filename);
+ return {
+ ...meta,
+ collectionId: colId,
+ embedding: embeddings[i]
+ };
+ });
+
+ if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...');
+ await vs.addVectors(vectors);
+
+ const col = await vs.getCollection(colId);
+ if (col) {
+ col.docCount = (col.docCount || 0) + 1;
+ col.chunkCount = (col.chunkCount || 0) + textChunks.length;
+ col.embeddingModel = embedModel;
+ await vs.updateCollection(col);
+ }
+
+ return { docId, chunkCount: textChunks.length };
+}
+
+export async function removeDocumentFromKB(colId: string, docId: string): Promise {
+ const vs = await initVectorStore();
+ const allVectors = await vs.getVectorsByCollection(colId);
+ const docVectors = allVectors.filter(v => v.docId === docId);
+
+ await vs.deleteVectorsByDocument(colId, docId);
+
+ const col = await vs.getCollection(colId);
+ if (col) {
+ col.chunkCount = Math.max(0, (col.chunkCount || 0) - docVectors.length);
+ col.docCount = Math.max(0, (col.docCount || 0) - 1);
+ await vs.updateCollection(col);
+ }
+}
+
+export async function getDocumentsInCollection(colId: string): Promise> {
+ const vs = await initVectorStore();
+ const vectors = await vs.getVectorsByCollection(colId);
+ const docMap = new Map();
+ for (const v of vectors) {
+ if (!docMap.has(v.docId)) {
+ docMap.set(v.docId, { docId: v.docId, filename: v.filename, chunkCount: 0 });
+ }
+ docMap.get(v.docId)!.chunkCount++;
+ }
+ return Array.from(docMap.values());
+}
+
+export async function retrieveContext(query: string, colId: string, topK = 5): Promise<{ context: string; results: SearchResult[] }> {
+ const vs = await initVectorStore();
+ const col = await vs.getCollection(colId);
+ if (!col) throw new Error('知识库集合不存在');
+
+ const embedModel = col.embeddingModel;
+ if (!embedModel) throw new Error('未配置嵌入模型');
+
+ const queryEmbedding = await embedText(query, embedModel);
+ const results = await vs.search(colId, queryEmbedding, topK);
+ if (results.length === 0) return { context: '', results: [] };
+
+ const contextParts = results.map((r, i) =>
+ `[来源 ${i + 1}: ${r.filename}]\n${r.text}`
+ );
+ const context = contextParts.join('\n\n---\n\n');
+
+ return { context, results };
+}
+
+export function buildRagSystemPrompt(context: string, originalSystemPrompt = ''): string {
+ const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
+
+=== 检索到的相关内容 ===
+${context}
+=== 内容结束 ===
+
+请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
+
+ if (originalSystemPrompt) {
+ return originalSystemPrompt + '\n\n' + ragPrompt;
+ }
+ return ragPrompt;
+}
diff --git a/src/renderer/services/vector-store.ts b/src/renderer/services/vector-store.ts
new file mode 100644
index 0000000..c482773
--- /dev/null
+++ b/src/renderer/services/vector-store.ts
@@ -0,0 +1,368 @@
+/**
+ * VectorStore - 向量存储与相似度检索
+ */
+
+import type { VectorCollection, VectorItem, SearchResult } from '../types.js';
+
+const MIN_IVF_SIZE = 200;
+const DEFAULT_K = 20;
+const DEFAULT_NPROBE = 5;
+const KMEANS_ITERS = 10;
+
+interface IVFIndex {
+ centroids: number[][];
+ invertedLists: Map;
+ collectionId?: string;
+ version?: string;
+}
+
+export class VectorStore {
+ private dbName: string;
+ private db: IDBDatabase | null = null;
+ private _indexCache = new Map();
+ private _vectorCache = new Map>();
+
+ constructor(dbName = 'metona-ollama-vectors') {
+ this.dbName = dbName;
+ }
+
+ async init(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(this.dbName, 2);
+ req.onerror = () => reject(req.error);
+ req.onsuccess = () => { this.db = req.result; resolve(); };
+ req.onupgradeneeded = (e) => {
+ const db = (e.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains('vectors')) {
+ const store = db.createObjectStore('vectors', { keyPath: 'id' });
+ store.createIndex('collectionId', 'collectionId', { unique: false });
+ }
+ if (!db.objectStoreNames.contains('collections')) {
+ db.createObjectStore('collections', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('indexes')) {
+ db.createObjectStore('indexes', { keyPath: 'collectionId' });
+ }
+ };
+ });
+ }
+
+ private _tx(store: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
+ return this.db!.transaction(store, mode).objectStore(store);
+ }
+
+ async createCollection(name: string, embeddingModel = ''): Promise {
+ const col: VectorCollection = {
+ id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
+ name, embeddingModel,
+ docCount: 0, chunkCount: 0,
+ createdAt: Date.now(), updatedAt: Date.now()
+ };
+ return new Promise((resolve, reject) => {
+ const req = this._tx('collections', 'readwrite').put(col);
+ req.onsuccess = () => resolve(col);
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async getCollections(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = this._tx('collections').getAll();
+ req.onsuccess = () => resolve(req.result || []);
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async getCollection(id: string): Promise {
+ return new Promise((resolve, reject) => {
+ const req = this._tx('collections').get(id);
+ req.onsuccess = () => resolve(req.result || null);
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async updateCollection(col: VectorCollection): Promise {
+ col.updatedAt = Date.now();
+ return new Promise((resolve, reject) => {
+ const req = this._tx('collections', 'readwrite').put(col);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async deleteCollection(colId: string): Promise {
+ const vectors = await this.getVectorsByCollection(colId);
+ await new Promise((resolve, reject) => {
+ const tx = this.db!.transaction(['vectors', 'collections', 'indexes'], 'readwrite');
+ const vStore = tx.objectStore('vectors');
+ for (const v of vectors) vStore.delete(v.id);
+ tx.objectStore('collections').delete(colId);
+ tx.objectStore('indexes').delete(colId);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ this._indexCache.delete(colId);
+ this._vectorCache.delete(colId);
+ }
+
+ async addVectors(items: VectorItem[]): Promise {
+ if (items.length === 0) return;
+ await new Promise((resolve, reject) => {
+ const tx = this.db!.transaction('vectors', 'readwrite');
+ const store = tx.objectStore('vectors');
+ for (const item of items) store.put(item);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ const colId = items[0].collectionId;
+ if (!this._vectorCache.has(colId)) {
+ this._vectorCache.set(colId, new Map());
+ }
+ const cache = this._vectorCache.get(colId)!;
+ for (const item of items) cache.set(item.id, item);
+ this._indexCache.delete(colId);
+ }
+
+ async getVectorsByCollection(colId: string): Promise {
+ if (this._vectorCache.has(colId)) {
+ return Array.from(this._vectorCache.get(colId)!.values());
+ }
+ const vectors = await new Promise((resolve, reject) => {
+ const idx = this._tx('vectors').index('collectionId');
+ const req = idx.getAll(colId);
+ req.onsuccess = () => resolve(req.result || []);
+ req.onerror = () => reject(req.error);
+ });
+ const cache = new Map();
+ for (const v of vectors) cache.set(v.id, v);
+ this._vectorCache.set(colId, cache);
+ return vectors;
+ }
+
+ async deleteVectorsByDocument(colId: string, docId: string): Promise {
+ const vectors = await this.getVectorsByCollection(colId);
+ const docVectors = vectors.filter(v => v.docId === docId);
+ await new Promise((resolve, reject) => {
+ const tx = this.db!.transaction('vectors', 'readwrite');
+ const store = tx.objectStore('vectors');
+ for (const v of docVectors) store.delete(v.id);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ if (this._vectorCache.has(colId)) {
+ const cache = this._vectorCache.get(colId)!;
+ for (const v of docVectors) cache.delete(v.id);
+ }
+ this._indexCache.delete(colId);
+ }
+
+ async search(colId: string, queryEmbedding: number[], topK = 5): Promise {
+ const vectors = await this.getVectorsByCollection(colId);
+ if (vectors.length === 0) return [];
+
+ if (vectors.length < MIN_IVF_SIZE) {
+ return this._bruteForceSearch(vectors, queryEmbedding, topK);
+ }
+
+ const index = await this._getIndex(colId, vectors);
+ if (!index) {
+ return this._bruteForceSearch(vectors, queryEmbedding, topK);
+ }
+ return this._ivfSearch(vectors, index, queryEmbedding, topK);
+ }
+
+ private _bruteForceSearch(vectors: VectorItem[], queryEmbedding: number[], topK: number): SearchResult[] {
+ return vectors
+ .map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
+ .sort((a, b) => b.score - a.score)
+ .slice(0, topK);
+ }
+
+ private _ivfSearch(vectors: VectorItem[], index: IVFIndex, queryEmbedding: number[], topK: number): SearchResult[] {
+ const { centroids, invertedLists } = index;
+ const clusterScores = centroids.map((c, i) => ({
+ id: i,
+ score: VectorStore.cosineSimilarity(queryEmbedding, c)
+ }));
+ clusterScores.sort((a, b) => b.score - a.score);
+
+ const nprobe = Math.min(DEFAULT_NPROBE, centroids.length);
+ const targetClusters = clusterScores.slice(0, nprobe);
+
+ const candidateIds = new Set();
+ for (const { id: clusterId } of targetClusters) {
+ const list = invertedLists.get(clusterId);
+ if (list) for (const id of list) candidateIds.add(id);
+ }
+
+ const vectorMap = new Map();
+ for (const v of vectors) vectorMap.set(v.id, v);
+
+ const candidates: VectorItem[] = [];
+ for (const id of candidateIds) {
+ const v = vectorMap.get(id);
+ if (v) candidates.push(v);
+ }
+
+ return candidates
+ .map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
+ .sort((a, b) => b.score - a.score)
+ .slice(0, topK);
+ }
+
+ private async _getIndex(colId: string, vectors: VectorItem[]): Promise {
+ if (this._indexCache.has(colId)) {
+ const cached = this._indexCache.get(colId)!;
+ if (cached.version === this._indexVersion(vectors)) return cached;
+ }
+
+ const saved = await new Promise } | null>((resolve, reject) => {
+ const req = this._tx('indexes').get(colId);
+ req.onsuccess = () => resolve(req.result || null);
+ req.onerror = () => reject(req.error);
+ });
+
+ if (saved && saved.version === this._indexVersion(vectors)) {
+ if (!(saved.invertedLists instanceof Map)) {
+ saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}));
+ }
+ this._indexCache.set(colId, saved);
+ return saved;
+ }
+
+ console.log(`[VectorStore] 构建 IVF 索引: ${colId} (${vectors.length} 向量)`);
+ const index = this._buildIVFIndex(vectors);
+ index.collectionId = colId;
+ index.version = this._indexVersion(vectors);
+
+ const toSave = {
+ collectionId: index.collectionId,
+ version: index.version,
+ centroids: index.centroids,
+ invertedListsObj: Object.fromEntries(index.invertedLists)
+ };
+ await new Promise((resolve, reject) => {
+ const req = this._tx('indexes', 'readwrite').put(toSave);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+
+ this._indexCache.set(colId, index);
+ return index;
+ }
+
+ private _indexVersion(vectors: VectorItem[]): string {
+ if (vectors.length === 0) return '0';
+ return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`;
+ }
+
+ private _buildIVFIndex(vectors: VectorItem[]): IVFIndex {
+ const N = vectors.length;
+ const dim = vectors[0].embedding.length;
+ const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10)));
+
+ const centroids = this._kmeansPPInit(vectors, K, dim);
+ const assignments = new Array(N);
+
+ for (let iter = 0; iter < KMEANS_ITERS; iter++) {
+ for (let i = 0; i < N; i++) {
+ let bestCluster = 0;
+ let bestScore = -Infinity;
+ for (let k = 0; k < K; k++) {
+ const score = VectorStore.cosineSimilarity(vectors[i].embedding, centroids[k]);
+ if (score > bestScore) { bestScore = score; bestCluster = k; }
+ }
+ assignments[i] = bestCluster;
+ }
+
+ const newCentroids = Array.from({ length: K }, () => new Array(dim).fill(0));
+ const counts = new Array(K).fill(0);
+
+ for (let i = 0; i < N; i++) {
+ const k = assignments[i];
+ counts[k]++;
+ const emb = vectors[i].embedding;
+ for (let d = 0; d < dim; d++) newCentroids[k][d] += emb[d];
+ }
+
+ for (let k = 0; k < K; k++) {
+ if (counts[k] > 0) {
+ for (let d = 0; d < dim; d++) newCentroids[k][d] /= counts[k];
+ this._normalize(newCentroids[k]);
+ } else {
+ newCentroids[k] = [...vectors[Math.floor(Math.random() * N)].embedding];
+ }
+ }
+
+ let converged = true;
+ for (let k = 0; k < K; k++) {
+ if (VectorStore.cosineSimilarity(centroids[k], newCentroids[k]) < 0.999) {
+ converged = false;
+ break;
+ }
+ }
+
+ for (let k = 0; k < K; k++) centroids[k] = newCentroids[k];
+ if (converged) break;
+ }
+
+ const invertedLists = new Map();
+ for (let i = 0; i < N; i++) {
+ const k = assignments[i];
+ if (!invertedLists.has(k)) invertedLists.set(k, []);
+ invertedLists.get(k)!.push(vectors[i].id);
+ }
+
+ return { centroids, invertedLists };
+ }
+
+ private _kmeansPPInit(vectors: VectorItem[], K: number, dim: number): number[][] {
+ const centroids: number[][] = [];
+ const first = Math.floor(Math.random() * vectors.length);
+ centroids.push([...vectors[first].embedding]);
+
+ for (let k = 1; k < K; k++) {
+ const dists = vectors.map(v => {
+ let minDist = Infinity;
+ for (const c of centroids) {
+ const sim = VectorStore.cosineSimilarity(v.embedding, c);
+ const dist = 1 - sim;
+ if (dist < minDist) minDist = dist;
+ }
+ return minDist;
+ });
+
+ const total = dists.reduce((s, d) => s + d, 0);
+ if (total === 0) {
+ centroids.push([...vectors[Math.floor(Math.random() * vectors.length)].embedding]);
+ continue;
+ }
+
+ let r = Math.random() * total;
+ for (let i = 0; i < vectors.length; i++) {
+ r -= dists[i];
+ if (r <= 0) { centroids.push([...vectors[i].embedding]); break; }
+ }
+ }
+ return centroids;
+ }
+
+ private _normalize(vec: number[]): void {
+ let norm = 0;
+ for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
+ norm = Math.sqrt(norm);
+ if (norm > 0) for (let i = 0; i < vec.length; i++) vec[i] /= norm;
+ }
+
+ static cosineSimilarity(a: number[], b: number[]): number {
+ if (!a || !b || a.length !== b.length) return 0;
+ let dot = 0, normA = 0, normB = 0;
+ for (let i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ normA += a[i] * a[i];
+ normB += b[i] * b[i];
+ }
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
+ return denom === 0 ? 0 : dot / denom;
+ }
+}
diff --git a/src/renderer/state/state.ts b/src/renderer/state/state.ts
new file mode 100644
index 0000000..65df6d5
--- /dev/null
+++ b/src/renderer/state/state.ts
@@ -0,0 +1,151 @@
+/**
+ * State - 轻量级响应式状态管理
+ */
+
+import type { StateKey, ChatSession, ChatDB, OllamaAPI, Preset } from '../types.js';
+
+function shallowEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (a == null || b == null) return a === b;
+ if (typeof a !== 'object' || typeof b !== 'object') return false;
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+ }
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ const keysA = Object.keys(a as object);
+ const keysB = Object.keys(b as object);
+ if (keysA.length !== keysB.length) return false;
+ for (const key of keysA) {
+ if (!Object.prototype.hasOwnProperty.call(b, key) || (a as Record)[key] !== (b as Record)[key]) return false;
+ }
+ return true;
+}
+
+type Listener = (value: unknown, old: unknown) => void;
+
+class AppState {
+ private _state: Record = {};
+ private _listeners = new Map>();
+ private _batching = false;
+ private _batchQueue: Set | null = null;
+
+ set(key: string, value: unknown): void {
+ const old = this._state[key];
+ this._state[key] = value;
+ if (!shallowEqual(old, value)) {
+ if (this._batching) {
+ this._batchQueue!.add(key);
+ } else {
+ this._notify(key, value, old);
+ }
+ }
+ }
+
+ update(key: string, updater: (old: unknown) => unknown): void {
+ const old = this._state[key];
+ const value = updater(old);
+ this._state[key] = value;
+ if (!shallowEqual(old, value)) {
+ if (this._batching) {
+ this._batchQueue!.add(key);
+ } else {
+ this._notify(key, value, old);
+ }
+ }
+ }
+
+ setIn(key: string, path: (string | number)[], value: unknown): void {
+ if (!path || path.length === 0) {
+ this.set(key, value);
+ return;
+ }
+ const old = this._state[key];
+ if (old == null || typeof old !== 'object') {
+ console.warn(`[State] setIn 失败: ${key} 不是对象`);
+ return;
+ }
+ const newRoot = Array.isArray(old) ? [...(old as unknown[])] : { ...(old as Record) };
+ let current: Record = newRoot as Record;
+ for (let i = 0; i < path.length - 1; i++) {
+ const segment = path[i];
+ const next = current[segment as string];
+ if (next == null || typeof next !== 'object') {
+ const isIndex = typeof path[i + 1] === 'number';
+ current[segment as string] = isIndex ? [] : {};
+ } else {
+ current[segment as string] = Array.isArray(next) ? [...next] : { ...(next as Record) };
+ }
+ current = current[segment as string] as Record;
+ }
+ current[path[path.length - 1] as string] = value;
+ this._state[key] = newRoot;
+ if (!shallowEqual(old, newRoot)) {
+ if (this._batching) {
+ this._batchQueue!.add(key);
+ } else {
+ this._notify(key, newRoot, old);
+ }
+ }
+ }
+
+ batch(fn: () => void): void {
+ this._batching = true;
+ this._batchQueue = new Set();
+ try {
+ fn();
+ } finally {
+ const keys = this._batchQueue;
+ this._batching = false;
+ this._batchQueue = null;
+ for (const k of keys) {
+ this._notify(k, this._state[k], undefined);
+ }
+ }
+ }
+
+ private _notify(key: string, value: unknown, old: unknown): void {
+ if (!this._listeners.has(key)) return;
+ for (const cb of this._listeners.get(key)!) {
+ try { cb(value, old); } catch (e) {
+ console.error(`[State] 监听器错误 (${key}):`, e);
+ }
+ }
+ }
+
+ get(key: string, defaultValue?: T): T {
+ return (key in this._state ? this._state[key] : defaultValue) as T;
+ }
+
+ on(key: string, callback: Listener): () => void {
+ if (!this._listeners.has(key)) {
+ this._listeners.set(key, new Set());
+ }
+ this._listeners.get(key)!.add(callback);
+ return () => this._listeners.get(key)?.delete(callback);
+ }
+
+ init(initial: Record): void {
+ Object.assign(this._state, initial);
+ }
+}
+
+export const state = new AppState();
+
+export const KEYS = {
+ DB: 'db',
+ API: 'api',
+ CURRENT_SESSION: 'currentSession',
+ IS_STREAMING: 'isStreaming',
+ IS_HISTORY_VIEW: 'isHistoryView',
+ PENDING_IMAGES: 'pendingImages',
+ ABORT_CONTROLLER: 'abortController',
+ SYSTEM_PROMPT: 'systemPrompt',
+ SYSTEM_PROMPT_ENABLED: 'systemPromptEnabled',
+ NUM_CTX: 'numCtx',
+ HISTORY_PAGE: 'historyPage',
+ HISTORY_SEARCH: 'historySearchQuery',
+} as const;
diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css
new file mode 100644
index 0000000..9198df5
--- /dev/null
+++ b/src/renderer/styles/style.css
@@ -0,0 +1,2183 @@
+/* ═══════════════════════════════════════════════════════════════
+ Metona Ollama — Windows 11 Fluent Design 暗色主题
+ Mica 材质 · Segoe UI Variable · WinUI 3 控件风格
+ ═══════════════════════════════════════════════════════════════ */
+
+/* ── CSS 变量 ── */
+:root {
+ /* Windows 11 暗色调色板 */
+ --bg-solid: #202020;
+ --bg-card: #2d2d2d;
+ --bg-card-hover: #323232;
+ --bg-layer: #383838;
+ --bg-layer-alt: #2a2a2a;
+ --bg-smoke: rgba(0, 0, 0, 0.3);
+ --bg-mica: rgba(32, 32, 32, 0.72);
+ --bg-acrylic: rgba(32, 32, 32, 0.8);
+
+ --border-subtle: rgba(255, 255, 255, 0.06);
+ --border-default: rgba(255, 255, 255, 0.08);
+ --border-strong: rgba(255, 255, 255, 0.12);
+ --border-focus: #60CDFF;
+
+ --text-primary: #FFFFFF;
+ --text-secondary: #999999;
+ --text-tertiary: #666666;
+ --text-disabled: #505050;
+
+ --accent: #60CDFF;
+ --accent-hover: #7AD8FF;
+ --accent-subtle: rgba(96, 205, 255, 0.06);
+ --accent-subtle-hover: rgba(96, 205, 255, 0.1);
+
+ --critical: #F1707B;
+ --critical-bg: rgba(241, 112, 123, 0.06);
+ --success: #6CCB5F;
+ --success-bg: rgba(108, 203, 95, 0.06);
+ --caution: #FCE100;
+ --caution-bg: rgba(252, 225, 0, 0.06);
+
+ /* 圆角 — Windows 11 标准 */
+ --radius-control: 4px;
+ --radius-sm: 8px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+
+ /* 阴影 — Fluent Design 深度 */
+ --shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.14);
+ --shadow-dialog: 0 24px 48px rgba(0, 0, 0, 0.18);
+ --shadow-tooltip: 0 4px 8px rgba(0, 0, 0, 0.14);
+
+ /* 字体 */
+ --font: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+ --font-mono: 'Cascadia Mono', 'Cascadia Code', 'Consolas', monospace;
+
+ --transition: 167ms cubic-bezier(0, 0, 0, 1);
+ --transition-fast: 83ms cubic-bezier(0, 0, 0, 1);
+}
+
+/* ── 全局重置 ── */
+*, *::before, *::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg-solid);
+ color: var(--text-primary);
+ font-family: var(--font);
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* ═══ 顶部导航 ═══ */
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: var(--bg-mica);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+ z-index: 10;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ flex: 1;
+}
+
+.logo {
+ font-size: 20px;
+ flex-shrink: 0;
+}
+
+.app-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+}
+
+.app-version {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: 12px;
+ padding: 1px 8px;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+/* ── 连接状态 ── */
+.conn-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: var(--radius-control);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ transition: var(--transition);
+}
+
+.status-label {
+ white-space: nowrap;
+}
+
+.conn-status.connected .status-dot {
+ background: var(--success);
+ box-shadow: 0 0 4px var(--success);
+}
+
+.conn-status.disconnected .status-dot {
+ background: var(--critical);
+}
+
+.conn-status.pending .status-dot {
+ background: var(--caution);
+ animation: blink 1s ease-in-out infinite;
+}
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ── 图标按钮 (WinUI 3 风格) ── */
+.icon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ border-radius: var(--radius-control);
+ cursor: pointer;
+ transition: var(--transition);
+ position: relative;
+}
+
+.icon-btn::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: var(--radius-control);
+ background: currentColor;
+ opacity: 0;
+ transition: var(--transition);
+}
+
+.icon-btn:hover::before {
+ opacity: 0.06;
+}
+
+.icon-btn:active::before {
+ opacity: 0.04;
+}
+
+.icon-btn:hover {
+ color: var(--text-primary);
+}
+
+.icon-btn:active {
+ transform: scale(0.97);
+}
+
+.icon-btn svg {
+ width: 18px;
+ height: 18px;
+ position: relative;
+ z-index: 1;
+}
+
+.icon-btn.disabled,
+.icon-btn:disabled {
+ opacity: 0.36;
+ cursor: not-allowed;
+}
+
+/* 图片按钮禁用时保留 pointer-events 以显示 tooltip 和 cursor */
+#btnAttachImg.disabled {
+ pointer-events: auto;
+}
+
+#btnAttachImg.disabled:hover {
+ opacity: 0.3;
+ background: rgba(241, 112, 123, 0.08);
+}
+
+.icon-btn.sm {
+ width: 28px;
+ height: 28px;
+}
+
+.icon-btn.sm svg {
+ width: 14px;
+ height: 14px;
+}
+
+.icon-btn.danger:hover {
+ color: var(--critical);
+}
+
+.help-btn {
+ margin-left: 2px;
+ opacity: 0.5;
+}
+
+.help-btn:hover {
+ opacity: 1;
+}
+
+/* ═══ 模型选择栏 ═══ */
+.model-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 16px;
+ background: var(--bg-layer-alt);
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.model-bar-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-control);
+ background: var(--accent-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.model-bar-icon svg {
+ width: 14px;
+ height: 14px;
+ color: var(--accent);
+}
+
+.model-select-wrap {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+}
+
+.model-select {
+ width: 100%;
+ padding: 6px 32px 6px 12px;
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: var(--font);
+ outline: none;
+ cursor: pointer;
+ transition: var(--transition);
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+.model-select:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+}
+
+.model-select:hover {
+ border-color: var(--border-strong);
+}
+
+.model-select option {
+ background: var(--bg-card);
+ color: var(--text-primary);
+}
+
+.model-select-arrow {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ color: var(--text-tertiary);
+}
+
+.model-select-arrow svg {
+ width: 14px;
+ height: 14px;
+}
+
+.model-badges {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.model-badge {
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ padding: 3px 8px;
+ border-radius: var(--radius-control);
+ border: 1px solid;
+}
+
+.think-badge {
+ color: var(--accent);
+ background: var(--accent-subtle);
+ border-color: rgba(96, 205, 255, 0.15);
+}
+
+.vision-badge {
+ color: var(--success);
+ background: var(--success-bg);
+ border-color: rgba(108, 203, 95, 0.15);
+}
+
+.rag-badge {
+ color: #B388FF;
+ background: rgba(179, 136, 255, 0.06);
+ border-color: rgba(179, 136, 255, 0.15);
+}
+
+/* ── 预设栏 ── */
+.preset-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 16px;
+ background: var(--bg-layer-alt);
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.preset-list {
+ display: flex;
+ gap: 6px;
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ padding: 2px 0;
+}
+
+.preset-list::-webkit-scrollbar { display: none; }
+
+.preset-chip {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 12px;
+ border-radius: var(--radius-control);
+ border: 1px solid var(--border-default);
+ background: var(--bg-layer);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-family: var(--font);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: var(--transition);
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.preset-chip:hover {
+ background: var(--bg-card-hover);
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+}
+
+.preset-chip.active {
+ background: var(--accent-subtle);
+ border-color: var(--accent);
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.preset-chip.active:hover {
+ background: var(--accent-subtle-hover);
+}
+
+.preset-chip-icon {
+ font-size: 13px;
+ line-height: 1;
+}
+
+.preset-chip-name {
+ line-height: 1;
+}
+
+.preset-add-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-control);
+ border: 1px dashed var(--border-default);
+ background: transparent;
+ color: var(--text-tertiary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: var(--transition);
+ flex-shrink: 0;
+}
+
+.preset-add-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--accent-subtle);
+}
+
+.preset-add-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+/* ── 预设模态框 ── */
+.preset-name-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.preset-icon-input {
+ width: 52px !important;
+ text-align: center;
+ font-size: 16px;
+ flex-shrink: 0;
+ padding: 6px 4px !important;
+}
+
+.preset-temp-slider {
+ width: 100%;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--bg-layer);
+ outline: none;
+ margin: 8px 0;
+}
+
+.preset-temp-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+ box-shadow: 0 0 0 4px transparent;
+ transition: box-shadow var(--transition);
+}
+
+.preset-temp-slider::-webkit-slider-thumb:hover {
+ box-shadow: 0 0 0 3px var(--accent-subtle);
+}
+
+.preset-temp-slider::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+ border: none;
+}
+
+.preset-modal-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-top: 16px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-subtle);
+}
+
+/* ── Think 按钮 ── */
+.think-toggle {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.think-toggle input { display: none; }
+
+.think-btn {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-control);
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition);
+ color: var(--text-tertiary);
+}
+
+.think-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+.think-toggle:not(.unavailable):hover .think-btn {
+ border-color: var(--border-strong);
+ color: var(--text-secondary);
+}
+
+.think-toggle input:checked + .think-btn {
+ background: var(--accent-subtle);
+ border-color: var(--accent);
+ color: var(--accent);
+ box-shadow: 0 0 12px rgba(96, 205, 255, 0.4), 0 0 4px rgba(96, 205, 255, 0.2);
+}
+
+.think-toggle input:checked + .think-btn:hover {
+ background: var(--accent-subtle-hover);
+ box-shadow: 0 0 18px rgba(96, 205, 255, 0.55), 0 0 6px rgba(96, 205, 255, 0.3);
+}
+
+.think-toggle.unavailable .think-btn {
+ opacity: 0.36;
+ cursor: not-allowed;
+}
+
+.think-toggle.unavailable:hover .think-btn {
+ opacity: 0.3;
+ background: rgba(241, 112, 123, 0.08);
+}
+
+/* ── 开关控件 (WinUI ToggleSwitch) ── */
+.toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.toggle-label input { display: none; }
+
+.toggle-slider {
+ position: relative;
+ width: 40px;
+ height: 20px;
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: 10px;
+ transition: var(--transition);
+}
+
+.toggle-slider::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ background: var(--text-secondary);
+ border-radius: 50%;
+ transition: var(--transition);
+}
+
+.toggle-label input:checked + .toggle-slider {
+ background: var(--accent);
+ border-color: var(--accent);
+}
+
+.toggle-label input:checked + .toggle-slider::after {
+ transform: translateX(20px);
+ background: var(--bg-solid);
+}
+
+/* ═══ 聊天区域 ═══ */
+.chat-area {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 16px;
+ scroll-behavior: smooth;
+}
+
+.chat-area::-webkit-scrollbar {
+ width: 8px;
+}
+
+.chat-area::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.chat-area::-webkit-scrollbar-thumb {
+ background: var(--border-default);
+ border-radius: 4px;
+}
+
+.chat-area::-webkit-scrollbar-thumb:hover {
+ background: var(--border-strong);
+}
+
+/* ── 回到底部按钮 ── */
+.scroll-to-bottom {
+ position: sticky;
+ bottom: 16px;
+ float: right;
+ margin-right: 4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border: 1px solid var(--border-default);
+ background: var(--bg-card);
+ backdrop-filter: blur(20px);
+ border-radius: var(--radius-control);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-family: var(--font);
+ cursor: pointer;
+ z-index: 5;
+ box-shadow: var(--shadow-flyout);
+ transition: var(--transition);
+ animation: fadeSlideUp 167ms ease;
+}
+
+.scroll-to-bottom:hover {
+ background: var(--bg-card-hover);
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+}
+
+.scroll-to-bottom:active {
+ transform: scale(0.98);
+}
+
+.scroll-to-bottom svg {
+ width: 14px;
+ height: 14px;
+}
+
+@keyframes fadeSlideUp {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── 空状态 ── */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ gap: 12px;
+ color: var(--text-tertiary);
+}
+
+.empty-icon svg {
+ width: 80px;
+ height: 80px;
+ opacity: 0.3;
+ color: var(--text-tertiary);
+}
+
+.empty-state h2 {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.empty-state p {
+ font-size: 14px;
+ color: var(--text-tertiary);
+}
+
+/* ── 消息气泡 ── */
+.messages {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-width: 780px;
+ margin: 0 auto;
+}
+
+.message {
+ display: flex;
+ gap: 12px;
+ animation: fadeSlideUp 167ms ease;
+}
+
+.msg-avatar {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ border-radius: var(--radius-control);
+ background: var(--bg-layer);
+ border: 1px solid var(--border-subtle);
+}
+
+.msg-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.msg-role {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-tertiary);
+ margin-bottom: 4px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.model-tag {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--accent);
+ background: var(--accent-subtle);
+ border: 1px solid rgba(96, 205, 255, 0.15);
+ border-radius: var(--radius-control);
+ padding: 1px 8px;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.message.user .msg-body {
+ background: var(--bg-layer);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+}
+
+.message.assistant .msg-body {
+ background: var(--bg-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+}
+
+.msg-content {
+ font-size: 14px;
+ line-height: 1.65;
+ word-break: break-word;
+}
+
+.msg-content pre {
+ background: var(--bg-solid);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ padding: 12px 14px;
+ overflow-x: auto;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ margin: 8px 0;
+}
+
+.msg-content code {
+ background: rgba(255, 255, 255, 0.06);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+.msg-content pre code {
+ background: none;
+ padding: 0;
+}
+
+.msg-content p { margin-bottom: 8px; }
+.msg-content p:last-child { margin-bottom: 0; }
+
+/* 思考块 */
+.think-block {
+ margin-bottom: 8px;
+ border: 1px solid rgba(252, 225, 0, 0.12);
+ border-radius: var(--radius-control);
+ overflow: hidden;
+}
+
+.think-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ background: var(--caution-bg);
+ color: var(--caution);
+ font-size: 12px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.think-header .chevron {
+ margin-left: auto;
+ transition: transform var(--transition);
+}
+
+.think-header .chevron.expanded {
+ transform: rotate(180deg);
+}
+
+.think-content {
+ padding: 8px 12px;
+ background: rgba(252, 225, 0, 0.02);
+}
+
+.think-content pre {
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+/* 打字光标 */
+.typing-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 1em;
+ background: var(--accent);
+ animation: caretBlink 1s step-end infinite;
+ vertical-align: text-bottom;
+ margin-left: 1px;
+}
+
+@keyframes caretBlink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* ═══ Loading 动画 ═══ */
+.loading-dots {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 8px 0;
+}
+
+.loading-dots span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-tertiary);
+ animation: dotPulse 1.4s ease-in-out infinite;
+}
+
+.loading-dots span:nth-child(1) { animation-delay: 0s; }
+.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
+.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes dotPulse {
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
+ 40% { transform: scale(1); opacity: 1; }
+}
+
+.loading-text {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ animation: loadingFade 1.5s ease-in-out infinite;
+}
+
+@keyframes loadingFade {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.8; }
+}
+
+/* 消息图片 */
+.msg-images {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.msg-img {
+ max-width: 200px;
+ max-height: 200px;
+ border-radius: var(--radius-control);
+ border: 1px solid var(--border-subtle);
+ object-fit: cover;
+}
+
+.msg-img[data-lightbox] {
+ cursor: zoom-in;
+ transition: var(--transition);
+}
+
+.msg-img[data-lightbox]:hover {
+ opacity: 0.85;
+}
+
+/* 文件 chip */
+.msg-files {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.msg-files .file-chip {
+ background: var(--accent-subtle);
+ border-color: rgba(96, 205, 255, 0.12);
+}
+
+/* 统计信息 */
+.msg-stats {
+ display: flex;
+ gap: 12px;
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+/* RAG 知识库来源 */
+.rag-sources {
+ margin-top: 8px;
+ padding: 8px 12px;
+ background: var(--accent-subtle);
+ border: 1px solid rgba(96, 205, 255, 0.1);
+ border-radius: var(--radius-control);
+ font-size: 12px;
+}
+
+.rag-sources-header {
+ color: var(--accent);
+ font-weight: 600;
+ margin-bottom: 4px;
+ font-size: 11px;
+}
+
+.rag-source-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 3px 0;
+ color: var(--text-tertiary);
+}
+
+.rag-source-item + .rag-source-item {
+ border-top: 1px solid var(--border-subtle);
+}
+
+.rag-source-name {
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rag-source-score {
+ color: var(--success);
+ font-weight: 500;
+ margin-left: 8px;
+ flex-shrink: 0;
+}
+
+/* ═══ 输入区域 ═══ */
+.input-area {
+ flex-shrink: 0;
+ padding: 10px 16px 14px;
+ background: var(--bg-mica);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+ border-top: 1px solid var(--border-subtle);
+}
+
+.image-preview {
+ display: flex;
+ gap: 8px;
+ padding: 8px 0;
+ flex-wrap: wrap;
+}
+
+.preview-thumb {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border-radius: var(--radius-control);
+ overflow: hidden;
+ border: 1px solid var(--border-default);
+}
+
+.preview-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.remove-img {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 18px;
+ height: 18px;
+ border: none;
+ background: var(--critical);
+ color: white;
+ border-radius: 50%;
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 文件预览 */
+.file-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px 0;
+}
+
+.file-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: var(--radius-control);
+ background: var(--accent-subtle);
+ border: 1px solid rgba(96, 205, 255, 0.12);
+ font-size: 12px;
+ line-height: 1;
+ transition: var(--transition);
+ animation: fadeSlideUp 167ms ease;
+}
+
+.file-chip:hover {
+ background: var(--accent-subtle-hover);
+ border-color: rgba(96, 205, 255, 0.2);
+}
+
+.file-icon {
+ font-size: 13px;
+ flex-shrink: 0;
+}
+
+.file-name {
+ font-weight: 600;
+ color: var(--text-primary);
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.file-size {
+ color: var(--text-tertiary);
+ flex-shrink: 0;
+}
+
+.remove-file {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0 2px;
+ border-radius: 3px;
+ transition: var(--transition);
+}
+
+.remove-file:hover {
+ color: var(--critical);
+ background: var(--critical-bg);
+}
+
+.input-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.attach-btn {
+ flex-shrink: 0;
+}
+
+.chat-input {
+ flex: 1;
+ padding: 8px 12px;
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ color: var(--text-primary);
+ font-family: var(--font);
+ font-size: 14px;
+ resize: none;
+ outline: none;
+ min-height: 36px;
+ max-height: 120px;
+ line-height: 1.5;
+ transition: var(--transition);
+}
+
+.chat-input:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+}
+
+.chat-input::placeholder {
+ color: var(--text-disabled);
+}
+
+.send-btn {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: var(--radius-control);
+ background: var(--accent);
+ color: #1a1a1a;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition);
+}
+
+.send-btn:hover {
+ background: var(--accent-hover);
+}
+
+.send-btn:active {
+ transform: scale(0.96);
+}
+
+.send-btn.disabled {
+ opacity: 0.36;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.send-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.send-btn.stop-btn {
+ background: var(--critical);
+ color: white;
+}
+
+.send-btn.stop-btn:hover {
+ opacity: 0.9;
+}
+
+.spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ═══ 历史只读提示栏 ═══ */
+.history-view-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 16px;
+ background: rgba(96, 205, 255, 0.04);
+ border-top: 1px solid rgba(96, 205, 255, 0.1);
+ font-size: 13px;
+ color: var(--accent);
+ flex-shrink: 0;
+}
+
+/* ═══ 模态框 (WinUI ContentDialog 风格) ═══ */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ animation: overlayIn 167ms ease;
+ padding: 20px;
+}
+
+@keyframes overlayIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-dialog);
+ width: 100%;
+ max-width: 480px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ animation: dialogIn 200ms cubic-bezier(0, 0, 0, 1);
+ overflow: hidden;
+}
+
+@keyframes dialogIn {
+ from { opacity: 0; transform: scale(0.96) translateY(8px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+
+.modal-lg {
+ max-width: 860px;
+ width: 96vw;
+ max-height: 85vh;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.modal-header h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.modal-body {
+ padding: 16px 20px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+/* ── 设置项 ── */
+.setting-group {
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.setting-group:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+.setting-label {
+ display: flex;
+ align-items: center;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+}
+
+.setting-input {
+ width: 100%;
+ padding: 8px 10px;
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: var(--font);
+ outline: none;
+ margin-bottom: 6px;
+ transition: var(--transition);
+}
+
+.setting-input:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+}
+
+.setting-input::placeholder {
+ color: var(--text-disabled);
+}
+
+/* ── 按钮 (WinUI Button 风格) ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 16px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-control);
+ font-size: 13px;
+ font-weight: 400;
+ font-family: var(--font);
+ cursor: pointer;
+ transition: var(--transition);
+ gap: 6px;
+ position: relative;
+ outline: none;
+}
+
+.btn::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: currentColor;
+ opacity: 0;
+ transition: var(--transition);
+}
+
+.btn:hover::before { opacity: 0.06; }
+.btn:active::before { opacity: 0.04; }
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #1a1a1a;
+ border-color: transparent;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-primary::before { display: none; }
+
+.btn-outline {
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ color: var(--text-secondary);
+}
+
+.btn-outline:hover {
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+}
+
+.btn-danger {
+ background: var(--critical-bg);
+ border: 1px solid rgba(241, 112, 123, 0.15);
+ color: var(--critical);
+}
+
+.btn-danger:hover {
+ background: rgba(241, 112, 123, 0.1);
+}
+
+.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+
+/* ── 连接信息 ── */
+.connection-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.status-badge {
+ padding: 3px 10px;
+ border-radius: var(--radius-control);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.status-badge.success {
+ background: var(--success-bg);
+ color: var(--success);
+ border: 1px solid rgba(108, 203, 95, 0.15);
+}
+
+.status-badge.error {
+ background: var(--critical-bg);
+ color: var(--critical);
+ border: 1px solid rgba(241, 112, 123, 0.15);
+}
+
+.status-badge.warning {
+ background: var(--caution-bg);
+ color: var(--caution);
+ border: 1px solid rgba(252, 225, 0, 0.15);
+}
+
+.server-version {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ font-family: var(--font-mono);
+}
+
+.cors-hint {
+ padding: 10px;
+ background: var(--caution-bg);
+ border: 1px solid rgba(252, 225, 0, 0.1);
+ border-radius: var(--radius-control);
+ margin-top: 8px;
+}
+
+.cors-hint p {
+ font-size: 12px;
+ color: var(--caution);
+ margin-bottom: 6px;
+}
+
+.cors-hint code {
+ display: block;
+ padding: 8px;
+ background: var(--bg-solid);
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--accent);
+ user-select: all;
+}
+
+/* ── 运行中的模型 ── */
+.running-models {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.running-model {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ background: var(--bg-layer);
+ border-radius: var(--radius-control);
+ border: 1px solid var(--border-subtle);
+}
+
+.running-model .model-name {
+ flex: 1;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.running-model .model-vram {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ font-family: var(--font-mono);
+}
+
+.text-muted {
+ color: var(--text-tertiary);
+ font-size: 13px;
+}
+
+/* ── 历史记录模态框加宽 ── */
+#historyModal .modal {
+ max-width: 680px;
+ max-height: 88vh;
+}
+
+/* ── 历史记录 ── */
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.history-search-wrap {
+ position: relative;
+ margin-bottom: 10px;
+}
+
+.history-search-input {
+ width: 100%;
+ padding: 8px 32px 8px 12px;
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: var(--font);
+ outline: none;
+ transition: var(--transition);
+}
+
+.history-search-input::placeholder {
+ color: var(--text-disabled);
+}
+
+.history-search-input:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 1px var(--border-focus);
+}
+
+.history-search-clear {
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 4px;
+ line-height: 1;
+}
+
+.history-search-clear:hover {
+ color: var(--text-primary);
+}
+
+.history-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 10px;
+ border-radius: var(--radius-control);
+ transition: var(--transition);
+}
+
+.history-item:hover {
+ background: var(--bg-layer);
+}
+
+.history-info {
+ flex: 1;
+ min-width: 0;
+ cursor: pointer;
+}
+
+.history-title {
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.history-meta {
+ display: flex;
+ gap: 10px;
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-top: 2px;
+}
+
+.history-actions {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+/* ── 历史分页 ── */
+.history-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0 4px;
+ border-top: 1px solid var(--border-subtle);
+ margin-top: 8px;
+}
+
+.page-info {
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+.page-buttons {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.page-btn {
+ min-width: 30px;
+ height: 30px;
+ border: 1px solid var(--border-default);
+ background: var(--bg-layer);
+ color: var(--text-secondary);
+ border-radius: var(--radius-control);
+ font-size: 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition);
+}
+
+.page-btn:hover:not(.disabled):not(.active) {
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+}
+
+.page-btn.active {
+ background: var(--accent-subtle);
+ border-color: var(--accent);
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.page-btn.disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.page-ellipsis {
+ color: var(--text-disabled);
+ font-size: 12px;
+ padding: 0 2px;
+}
+
+/* ═══ Lightbox ═══ */
+.lightbox-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 300;
+ cursor: zoom-out;
+ animation: overlayIn 167ms ease;
+}
+
+.lightbox-img {
+ max-width: 90vw;
+ max-height: 90vh;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-dialog);
+ object-fit: contain;
+ cursor: default;
+}
+
+.lightbox-close {
+ position: fixed;
+ top: 16px;
+ right: 20px;
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--border-default);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ font-size: 16px;
+ border-radius: var(--radius-control);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition);
+ z-index: 301;
+}
+
+.lightbox-close:hover {
+ background: var(--bg-card-hover);
+}
+
+/* ═══ 帮助弹框 ═══ */
+#helpModal .modal {
+ max-width: 600px;
+ max-height: 85vh;
+}
+
+.help-section {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ background: var(--bg-layer);
+ border-radius: var(--radius-control);
+ border-left: 2px solid var(--border-strong);
+ transition: border-color var(--transition);
+}
+
+.help-section:hover {
+ border-left-color: var(--accent);
+}
+
+.help-section:last-child {
+ margin-bottom: 0;
+}
+
+.help-section h4 {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: var(--text-primary);
+}
+
+.help-section ul,
+.help-section ol {
+ padding-left: 18px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.7;
+}
+
+.help-section li {
+ margin-bottom: 3px;
+}
+
+.help-section code {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--accent);
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 12px;
+}
+
+.help-section kbd {
+ background: var(--bg-layer);
+ border: 1px solid var(--border-default);
+ border-bottom-width: 2px;
+ border-radius: 3px;
+ padding: 1px 6px;
+ font-size: 12px;
+ font-family: inherit;
+ color: var(--text-primary);
+}
+
+.help-shortcuts {
+ width: 100%;
+ font-size: 13px;
+ border-collapse: collapse;
+ border-radius: var(--radius-control);
+ overflow: hidden;
+ border: 1px solid var(--border-default);
+}
+
+.help-shortcuts td {
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.help-shortcuts tr:last-child td {
+ border-bottom: none;
+}
+
+.help-shortcuts td:first-child {
+ width: 140px;
+ text-align: center;
+ background: var(--bg-layer);
+}
+
+.help-shortcuts tr:hover td {
+ background: var(--bg-layer-alt);
+}
+
+/* ═══ 知识库管理 ═══ */
+#kbModal .modal {
+ max-width: 95vw;
+ width: 1100px;
+ max-height: 88vh;
+}
+
+#kbModal .modal-body {
+ padding: 16px 20px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.kb-layout {
+ display: flex;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.kb-sidebar {
+ width: 240px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ overflow: hidden;
+ padding-right: 16px;
+ border-right: 1px solid var(--border-subtle);
+}
+
+.kb-new-collection {
+ display: flex;
+ gap: 6px;
+}
+
+.kb-new-collection .setting-input {
+ flex: 1;
+ margin-bottom: 0;
+ font-size: 13px;
+ padding: 6px 10px;
+}
+
+.kb-collection-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-height: 0;
+}
+
+.kb-collection-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-radius: var(--radius-control);
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.kb-collection-item:hover {
+ background: var(--bg-layer);
+}
+
+.kb-collection-item.active {
+ background: var(--accent-subtle);
+ border: 1px solid rgba(96, 205, 255, 0.12);
+}
+
+.kb-col-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.kb-col-name {
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.kb-col-stats {
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.kb-col-delete {
+ opacity: 0;
+ transition: opacity var(--transition);
+}
+
+.kb-collection-item:hover .kb-col-delete {
+ opacity: 1;
+}
+
+.kb-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.kb-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ padding: 8px 12px;
+ background: var(--bg-layer);
+ border-radius: var(--radius-control);
+ border: 1px solid var(--border-subtle);
+}
+
+.kb-embed-select {
+ flex: 1;
+ min-width: 0;
+ max-width: 200px;
+ padding: 5px 8px;
+ font-size: 12px;
+ background: var(--bg-layer-alt);
+ color: var(--text-primary);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-control);
+ outline: none;
+ cursor: pointer;
+ font-family: var(--font);
+}
+
+.kb-embed-select:focus {
+ border-color: var(--border-focus);
+}
+
+.kb-rag-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ white-space: nowrap;
+ padding-left: 8px;
+ border-left: 1px solid var(--border-subtle);
+}
+
+.kb-progress {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ padding: 3px 8px;
+ background: var(--accent-subtle);
+ border-radius: var(--radius-control);
+}
+
+.kb-doc-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-height: 0;
+}
+
+.kb-doc-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: var(--radius-control);
+ transition: var(--transition);
+}
+
+.kb-doc-item:hover {
+ background: var(--bg-layer);
+}
+
+.kb-doc-icon {
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.kb-doc-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.kb-doc-name {
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.kb-doc-meta {
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.kb-doc-delete {
+ opacity: 0;
+ transition: opacity var(--transition);
+}
+
+.kb-doc-item:hover .kb-doc-delete {
+ opacity: 1;
+}
+
+/* ── 移动端响应式 ── */
+@media (max-width: 640px) {
+ .kb-layout {
+ flex-direction: column;
+ }
+
+ .kb-sidebar {
+ width: 100%;
+ max-height: 160px;
+ border-right: none;
+ padding-right: 0;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border-subtle);
+ }
+
+ .kb-main {
+ min-height: 180px;
+ }
+
+ .modal-lg {
+ max-height: 92vh;
+ }
+}
+
+/* ═══ Toast 通知 ═══ */
+.toast-container {
+ position: fixed;
+ top: 56px;
+ right: 16px;
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ pointer-events: none;
+}
+
+.toast {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border-radius: var(--radius-control);
+ background: var(--bg-card);
+ border: 1px solid var(--border-default);
+ box-shadow: var(--shadow-flyout);
+ font-size: 13px;
+ color: var(--text-primary);
+ pointer-events: auto;
+ animation: toastIn 167ms ease;
+ max-width: 320px;
+}
+
+.toast.removing {
+ animation: toastOut 167ms ease forwards;
+}
+
+.toast-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
+.toast.success { border-left: 3px solid var(--success); }
+.toast.error { border-left: 3px solid var(--critical); }
+.toast.warning { border-left: 3px solid var(--caution); }
+.toast.info { border-left: 3px solid var(--accent); }
+
+@keyframes toastIn {
+ from { opacity: 0; transform: translateX(24px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes toastOut {
+ from { opacity: 1; transform: translateX(0); }
+ to { opacity: 0; transform: translateX(24px); }
+}
+
+/* ═══ 响应式 ═══ */
+@media (min-width: 768px) {
+ .app-header { padding: 8px 24px; }
+ .model-bar { padding: 8px 24px; }
+ .chat-area { padding: 20px 24px; }
+ .messages { max-width: 820px; }
+ .input-area { padding: 10px 24px 16px; }
+ .modal { max-width: 500px; }
+}
+
+@media (min-width: 1024px) {
+ .messages { max-width: 900px; }
+}
+
+/* ── 选中文本 ── */
+::selection {
+ background: rgba(96, 205, 255, 0.3);
+}
+
+/* ── 链接 ── */
+.msg-content a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.msg-content a:hover {
+ text-decoration: underline;
+}
+
+.msg-content .blocked-link {
+ color: var(--critical);
+ text-decoration: line-through;
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+/* ── 列表 ── */
+.msg-content ul, .msg-content ol {
+ padding-left: 20px;
+ margin: 8px 0;
+}
+
+.msg-content li { margin-bottom: 3px; }
+
+/* ── 表格 ── */
+.msg-content table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 8px 0;
+ font-size: 13px;
+}
+
+.msg-content th, .msg-content td {
+ padding: 6px 10px;
+ border: 1px solid var(--border-default);
+ text-align: left;
+}
+
+.msg-content th {
+ background: var(--bg-layer);
+ font-weight: 600;
+}
+
+/* ── 引用块 ── */
+.msg-content blockquote {
+ border-left: 2px solid var(--border-strong);
+ padding-left: 12px;
+ margin: 8px 0;
+ color: var(--text-secondary);
+}
diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts
new file mode 100644
index 0000000..2ac288b
--- /dev/null
+++ b/src/renderer/types.d.ts
@@ -0,0 +1,250 @@
+// ── Ollama API 类型 ──
+
+export interface OllamaMessage {
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ images?: string[];
+ thinking?: string;
+ reasoning_content?: string;
+}
+
+export interface OllamaChatParams {
+ model: string;
+ messages: OllamaMessage[];
+ stream?: boolean;
+ think?: boolean;
+ system?: string;
+ keep_alive?: number | string;
+ options?: {
+ num_ctx?: number;
+ temperature?: number;
+ [key: string]: unknown;
+ };
+}
+
+export interface OllamaStreamChunk {
+ model?: string;
+ message?: {
+ role: string;
+ content?: string;
+ thinking?: string;
+ reasoning_content?: string;
+ };
+ done?: boolean;
+ eval_count?: number;
+ total_duration?: number;
+}
+
+export interface OllamaModelInfo {
+ name: string;
+ size?: number;
+ digest?: string;
+ modified_at?: string;
+}
+
+export interface OllamaModelDetail {
+ capabilities?: string[];
+ [key: string]: unknown;
+}
+
+export interface OllamaVersionResponse {
+ version: string;
+}
+
+export interface OllamaModelsResponse {
+ models: OllamaModelInfo[];
+}
+
+export interface OllamaPsResponse {
+ models?: Array<{
+ name: string;
+ size_vram?: number;
+ [key: string]: unknown;
+ }>;
+}
+
+export interface OllamaEmbedResponse {
+ embedding?: number[];
+ embeddings?: number[][];
+}
+
+// ── 会话类型 ──
+
+export interface ChatFile {
+ name: string;
+ language?: string;
+ size?: number;
+}
+
+export interface FileContent {
+ language: string;
+ content: string;
+}
+
+export interface RagSource {
+ filename: string;
+ score: number;
+ text: string;
+}
+
+export interface ChatMessage {
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: number;
+ model?: string;
+ think?: string;
+ eval_count?: number;
+ total_duration?: number;
+ images?: string[];
+ files?: ChatFile[];
+ _fileContents?: FileContent[];
+ ragSources?: RagSource[];
+ stopped?: boolean;
+}
+
+export interface ChatSession {
+ id: string;
+ title: string;
+ model: string;
+ messages: ChatMessage[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+// ── 预设类型 ──
+
+export interface Preset {
+ id: string;
+ name: string;
+ icon: string;
+ systemPrompt: string;
+ temperature: number;
+ numCtx: number;
+ think: boolean;
+ builtIn?: boolean;
+ createdAt: number;
+ updatedAt: number;
+}
+
+// ── 知识库类型 ──
+
+export interface VectorCollection {
+ id: string;
+ name: string;
+ embeddingModel: string;
+ docCount: number;
+ chunkCount: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface VectorItem {
+ id: string;
+ collectionId: string;
+ docId: string;
+ filename: string;
+ chunkIndex: number;
+ text: string;
+ charCount: number;
+ embedding: number[];
+}
+
+export interface SearchResult extends VectorItem {
+ score: number;
+}
+
+export interface DocInfo {
+ docId: string;
+ filename: string;
+ chunkCount: number;
+}
+
+// ── 桌面 Bridge 类型 ──
+
+export interface MetonaDesktopAPI {
+ isDesktop: boolean;
+ info: () => Promise;
+ dialog: {
+ openFile: (options?: FileFilterOptions) => Promise;
+ saveFile: (options?: SaveFileOptions) => Promise;
+ };
+ fs: {
+ readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>;
+ writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>;
+ };
+ notify: (title: string, body: string) => void;
+ window: {
+ minimize: () => void;
+ maximize: () => void;
+ close: () => void;
+ };
+ openExternal: (url: string) => void;
+ onMenuAction: (callback: (action: string) => void) => void;
+ onTrayAction: (callback: (action: string) => void) => void;
+ removeAllListeners: (channel: string) => void;
+}
+
+export interface AppInfo {
+ version: string;
+ platform: string;
+ arch: string;
+ electronVersion: string;
+ userDataPath: string;
+}
+
+export interface FileFilterOptions {
+ filters?: Array<{ name: string; extensions: string[] }>;
+}
+
+export interface SaveFileOptions {
+ defaultPath?: string;
+ filters?: Array<{ name: string; extensions: string[] }>;
+}
+
+// ── 桌面 Bridge 接口 ──
+
+export interface DesktopBridge {
+ isDesktop: boolean;
+ getInfo: () => Promise<{ platform: string; version: string; arch?: string; electronVersion?: string; userDataPath?: string }>;
+ openFile: (filters?: Array<{ name: string; extensions: string[] }>) => Promise;
+ saveFile: (options?: SaveFileOptions) => Promise;
+ readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>;
+ writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>;
+ notify: (title: string, body: string) => void;
+ minimize: () => void;
+ maximize: () => void;
+ close: () => void;
+ openExternal: (url: string) => void;
+ onMenuAction: (callback: (action: string) => void) => void;
+ onTrayAction: (callback: (action: string) => void) => void;
+ off: (channel: string) => void;
+}
+
+// ── State Keys ──
+
+export type StateKey =
+ | 'db'
+ | 'api'
+ | 'currentSession'
+ | 'isStreaming'
+ | 'isHistoryView'
+ | 'pendingImages'
+ | 'abortController'
+ | 'systemPrompt'
+ | 'systemPromptEnabled'
+ | 'numCtx'
+ | 'historyPage'
+ | 'historySearchQuery'
+ | 'temperature'
+ | 'thinkEnabled'
+ | 'presets'
+ | 'activePresetId';
+
+// ── Window global 声明 ──
+
+declare global {
+ interface Window {
+ metonaDesktop?: MetonaDesktopAPI;
+ __metonaBridge?: DesktopBridge;
+ }
+}
diff --git a/src/renderer/utils/marked-config.ts b/src/renderer/utils/marked-config.ts
new file mode 100644
index 0000000..f4e488a
--- /dev/null
+++ b/src/renderer/utils/marked-config.ts
@@ -0,0 +1,83 @@
+/**
+ * Markdown 渲染器配置
+ */
+
+import { SAFE_URI_SCHEMES } from './sanitizer.js';
+
+// 简化的 Markdown 解析器(内联,不依赖外部 marked 库)
+// 支持基本 GFM 语法:标题、粗体、斜体、代码块、链接、列表、表格
+
+function parseMarkdown(md: string): string {
+ if (!md) return '';
+ let html = escapeForMd(md);
+
+ // 代码块 ```...```
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => {
+ return `${code.trim()} `;
+ });
+
+ // 行内代码
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // 标题
+ html = html.replace(/^######\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^#####\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^####\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^###\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^##\s+(.+)$/gm, '$1 ');
+ html = html.replace(/^#\s+(.+)$/gm, '$1 ');
+
+ // 粗体+斜体
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1 ');
+ // 粗体
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+ // 斜体
+ html = html.replace(/\*(.+?)\*/g, '$1 ');
+ // 删除线
+ html = html.replace(/~~(.+?)~~/g, '$1');
+
+ // 链接 [text](url)
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => {
+ try {
+ const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol);
+ if (!safe) return `${text} `;
+ } catch { /* allow relative */ }
+ return `${text} `;
+ });
+
+ // 图片 
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' ');
+
+ // 引用块
+ html = html.replace(/^>\s+(.+)$/gm, '$1
');
+
+ // 水平线
+ html = html.replace(/^---+$/gm, ' ');
+
+ // 无序列表
+ html = html.replace(/^[\-\*]\s+(.+)$/gm, '$1 ');
+ html = html.replace(/((?:.*<\/li>\n?)+)/g, '');
+
+ // 有序列表
+ html = html.replace(/^\d+\.\s+(.+)$/gm, ' $1 ');
+
+ // 换行
+ html = html.replace(/\n\n/g, '
');
+ html = html.replace(/\n/g, ' ');
+
+ // 包裹在 p 标签中
+ if (!html.startsWith('<')) {
+ html = '
' + html + '
';
+ }
+
+ return html;
+}
+
+function escapeForMd(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+export { parseMarkdown as marked };
diff --git a/src/renderer/utils/sanitizer.ts b/src/renderer/utils/sanitizer.ts
new file mode 100644
index 0000000..cf51362
--- /dev/null
+++ b/src/renderer/utils/sanitizer.ts
@@ -0,0 +1,74 @@
+/**
+ * Sanitizer - HTML 净化器
+ */
+
+export const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']);
+export const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']);
+
+const ALLOWED_TAGS = new Set([
+ 'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div',
+ 'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
+ 'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
+ 'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub',
+ 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead',
+ 'tr', 'u', 'ul', 'var', 'input',
+]);
+
+const ALLOWED_ATTRS = new Set([
+ 'href', 'src', 'alt', 'title', 'width', 'height', 'align',
+ 'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled',
+ 'start', 'value', 'scope', 'rowspan', 'colspan', 'lang',
+]);
+
+const DANGEROUS_PREFIXES = ['on', 'xlink:href', 'xmlns:'];
+
+export function sanitize(html: string): string {
+ try {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ const root = template.content;
+
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
+ const comments: Comment[] = [];
+ while (walker.nextNode()) comments.push(walker.currentNode as Comment);
+ comments.forEach(c => c.remove());
+
+ const elements = [...root.querySelectorAll('*')];
+ for (const el of elements) {
+ if (!el.parentNode) continue;
+ const tag = el.tagName.toLowerCase();
+
+ if (!ALLOWED_TAGS.has(tag)) {
+ while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
+ el.remove();
+ continue;
+ }
+
+ for (const attr of [...el.attributes]) {
+ const name = attr.name.toLowerCase();
+ if (DANGEROUS_PREFIXES.some(p => name.startsWith(p))) {
+ el.removeAttribute(attr.name);
+ continue;
+ }
+ if (!ALLOWED_ATTRS.has(name)) {
+ el.removeAttribute(attr.name);
+ continue;
+ }
+ if ((name === 'href' || name === 'src' || name === 'action') && attr.value) {
+ const val = attr.value.trim().toLowerCase();
+ if (/^(javascript|vbscript|data|file):/i.test(val)) {
+ const mimeMatch = val.match(/^data:([^;,]+)/i);
+ if (!mimeMatch || !SAFE_DATA_TYPES.has(mimeMatch[1].toLowerCase())) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ }
+ }
+ }
+
+ return root.innerHTML;
+ } catch (e) {
+ console.warn('[Sanitizer] 净化失败:', e);
+ return html;
+ }
+}
diff --git a/src/renderer/utils/utils.ts b/src/renderer/utils/utils.ts
new file mode 100644
index 0000000..82ff153
--- /dev/null
+++ b/src/renderer/utils/utils.ts
@@ -0,0 +1,115 @@
+/**
+ * Utils - 通用工具函数
+ */
+
+/** 生成唯一 ID */
+export function generateId(): string {
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+/** 格式化时间戳 */
+export function formatTime(ts: number): string {
+ const d = new Date(ts);
+ const pad = (n: number): string => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+/** 截断文本 */
+export function truncate(str: string, len = 50): string {
+ if (!str) return '';
+ return str.length > len ? str.substring(0, len) + '...' : str;
+}
+
+/** 防抖 */
+export function debounce unknown>(fn: T, ms = 300): (...args: Parameters) => void {
+ let timer: ReturnType;
+ return (...args: Parameters) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn(...args), ms);
+ };
+}
+
+/** 格式化字节大小 */
+export function formatSize(bytes: number): string {
+ if (!bytes) return '';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let size = bytes;
+ while (size >= 1024 && i < units.length - 1) {
+ size /= 1024;
+ i++;
+ }
+ return `${size.toFixed(1)} ${units[i]}`;
+}
+
+/** HTML 转义 */
+export function escapeHtml(str: string): string {
+ const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(str).replace(/[&<>"']/g, (c) => map[c]);
+}
+
+/** 下载文件 */
+export function downloadFile(filename: string, content: string, type: string): void {
+ const blob = new Blob([content], { type: type + ';charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+/** File → Base64(去前缀) */
+export function fileToBase64(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result as string;
+ const base64 = result.split(',')[1];
+ resolve(base64);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+}
+
+/** File → 文本内容 */
+export function fileToText(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsText(file);
+ });
+}
+
+/** 根据文件名检测语言标识(用于 Markdown 代码块) */
+export function detectLanguage(filename: string): string {
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+ const map: Record = {
+ py: 'python', pyw: 'python', pyi: 'python',
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
+ ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
+ java: 'java', class: 'java',
+ c: 'c', h: 'c',
+ cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
+ go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
+ sql: 'sql', html: 'html', htm: 'html', css: 'css',
+ json: 'json', jsonl: 'json',
+ xml: 'xml', svg: 'svg',
+ yaml: 'yaml', yml: 'yaml', toml: 'toml',
+ ini: 'ini', cfg: 'ini', conf: 'ini',
+ md: 'markdown', markdown: 'markdown', rst: 'rst',
+ txt: '', csv: 'csv', tsv: 'csv', log: '',
+ swift: 'swift', kt: 'kotlin', kts: 'kotlin',
+ scala: 'scala', lua: 'lua', pl: 'perl', r: 'r',
+ m: 'matlab', mm: 'objectivec',
+ cs: 'csharp', fs: 'fsharp', vb: 'vbnet',
+ ex: 'elixir', exs: 'elixir', erl: 'erlang',
+ hs: 'haskell', clj: 'clojure', lisp: 'lisp',
+ dockerfile: 'dockerfile', makefile: 'makefile',
+ diff: 'diff', patch: 'diff',
+ };
+ return map[ext] ?? ext;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..86464f5
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "baseUrl": ".",
+ "paths": {
+ "@main/*": ["src/main/*"],
+ "@renderer/*": ["src/renderer/*"]
+ },
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts"],
+ "exclude": ["node_modules", "dist", "release"]
+}