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

- 全面迁移到 TypeScript,严格类型定义
- 放弃 Web 版,专注 Electron 桌面应用
- 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts
- 渲染进程完整迁移所有功能组件
- 删除 PWA 相关文件 (sw.js, manifest.json)
- 删除 Web 版降级逻辑
- 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理
- 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
thzxx
2026-04-06 03:04:20 +08:00
parent 0924497cd6
commit 7ca0e33d77
33 changed files with 7265 additions and 15 deletions
+12 -15
View File
@@ -1,11 +1,13 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "1.1.0", "version": "2.0.0",
"description": "Metona Ollama - 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "electron/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite && electron .",
"build": "tsc && vite build",
"start": "electron . --no-sandbox", "start": "electron . --no-sandbox",
"pack": "electron-builder --dir", "pack": "electron-builder --dir",
"dist": "electron-builder --win", "dist": "electron-builder --win",
@@ -19,27 +21,19 @@
"output": "release" "output": "release"
}, },
"files": [ "files": [
"electron/**/*", "dist/**/*",
"js/**/*",
"css/**/*",
"assets/**/*", "assets/**/*",
"index.html",
"manifest.json",
"!node_modules/**/*" "!node_modules/**/*"
], ],
"win": { "win": {
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
"arch": [ "arch": ["x64"]
"x64"
]
}, },
{ {
"target": "portable", "target": "portable",
"arch": [ "arch": ["x64"]
"x64"
]
} }
], ],
"icon": "assets/icons/llama.ico", "icon": "assets/icons/llama.ico",
@@ -60,7 +54,10 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.17.0",
"electron": "^33.4.11", "electron": "^33.4.11",
"electron-builder": "^25.0.0" "electron-builder": "^25.1.8",
"typescript": "^5.7.0",
"vite": "^5.4.0"
} }
} }
+72
View File
@@ -0,0 +1,72 @@
/**
* Metona Ollama Desktop - IPC 处理器
*/
import { ipcMain, dialog, shell } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { mainWindow } from './main.js';
import { showNotification } from './utils.js';
export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile', 'multiSelections'],
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
});
if (result.canceled) return null;
return result.filePaths;
});
ipcMain.handle('dialog:saveFile', async (_, options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => {
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: options?.defaultPath || 'export',
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
});
if (result.canceled) return null;
return result.filePath;
});
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try {
const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, content, name: path.basename(filePath) };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
try {
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
ipcMain.handle('notify', (_: unknown, title: string, body: string) => {
showNotification(title, body);
});
ipcMain.handle('app:info', () => ({
version: require('electron').app.getVersion(),
platform: process.platform,
arch: process.arch,
electronVersion: process.versions.electron,
userDataPath: require('electron').app.getPath('userData')
}));
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
ipcMain.handle('window:maximize', () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
else mainWindow?.maximize();
});
ipcMain.handle('window:close', () => mainWindow?.close());
ipcMain.handle('shell:openExternal', (_, url: string) => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
shell.openExternal(url);
}
});
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Metona Ollama Desktop - 主进程入口
*/
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { setupIPC } from './ipc.js';
import { createTray } from './tray.js';
import { createMenu } from './menu.js';
import { showNotification } from './utils.js';
const APP_NAME = 'Metona Ollama';
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
const ICO_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.ico');
const IS_DEV = !app.isPackaged;
export let mainWindow: BrowserWindow | null = null;
export let isQuitting = false;
export function getIconPath(): string {
return process.platform === 'win32' ? ICO_PATH : ICON_PATH;
}
function createMainWindow(): BrowserWindow {
const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'window-state.json');
let windowState = { width: 1200, height: 800, x: undefined as number | undefined, y: undefined as number | undefined, maximized: false };
try {
if (fs.existsSync(configPath)) {
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch { /* use defaults */ }
mainWindow = new BrowserWindow({
width: windowState.width || 1200,
height: windowState.height || 800,
x: windowState.x,
y: windowState.y,
minWidth: 800,
minHeight: 600,
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH,
title: APP_NAME,
backgroundColor: '#202020',
show: false,
autoHideMenuBar: true,
menuBarVisible: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: false,
allowRunningInsecureContent: false
}
});
mainWindow.loadFile(path.join(__dirname, '..', '..', 'index.html'));
mainWindow.setMenuBarVisibility(false);
mainWindow.once('ready-to-show', () => {
if (windowState.maximized) {
mainWindow!.maximize();
}
mainWindow!.show();
});
const saveWindowState = (): void => {
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
const bounds = mainWindow.getBounds();
try {
fs.writeFileSync(configPath, JSON.stringify(bounds));
} catch { /* ignore */ }
};
mainWindow.on('resize', saveWindowState);
mainWindow.on('move', saveWindowState);
mainWindow.on('maximize', () => {
try {
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
state.maximized = true;
fs.writeFileSync(configPath, JSON.stringify(state));
} catch { /* ignore */ }
});
mainWindow.on('unmaximize', () => {
try {
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
state.maximized = false;
fs.writeFileSync(configPath, JSON.stringify(state));
} catch { /* ignore */ }
});
mainWindow.on('close', (e) => {
if (!isQuitting) {
e.preventDefault();
mainWindow!.hide();
if (!(global as Record<string, unknown>)._trayHintShown) {
(global as Record<string, unknown>)._trayHintShown = true;
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
}
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
const { shell } = require('electron');
shell.openExternal(url);
return { action: 'deny' as const };
}
return { action: 'allow' as const };
});
return mainWindow;
}
// ── 单实例锁 ──
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
app.whenReady().then(() => {
setupIPC();
createMainWindow();
createTray();
createMenu();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// keep running with tray
}
});
app.on('before-quit', () => {
isQuitting = true;
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Metona Ollama Desktop - 原生菜单
*/
import { Menu, dialog, shell } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js';
export function createMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: '文件',
submenu: [
{
label: '新建会话',
accelerator: 'Ctrl+N',
click: () => mainWindow?.webContents.send('menu-action', 'new-chat')
},
{ type: 'separator' },
{
label: '导入会话',
click: () => mainWindow?.webContents.send('menu-action', 'import')
},
{
label: '导出会话',
accelerator: 'Ctrl+Shift+E',
click: () => mainWindow?.webContents.send('menu-action', 'export')
},
{ type: 'separator' },
{
label: '退出',
accelerator: 'Ctrl+Q',
click: () => {
const { app } = require('electron');
(require('./main.js') as Record<string, unknown>).isQuitting = true;
app.quit();
}
}
]
},
{
label: '编辑',
submenu: [
{ role: 'undo', label: '撤销' },
{ role: 'redo', label: '重做' },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' }
]
},
{
label: '视图',
submenu: [
{ role: 'reload', label: '刷新' },
{ role: 'forceReload', label: '强制刷新' },
{ type: 'separator' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' },
{ role: 'resetZoom', label: '重置缩放' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: '全屏' }
]
},
{
label: '窗口',
submenu: [
{ role: 'minimize', label: '最小化' },
{
label: '最大化/还原',
click: () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
else mainWindow?.maximize();
}
},
{ type: 'separator' },
{
label: '置顶',
type: 'checkbox',
checked: false,
click: (menuItem: Electron.MenuItem) => {
mainWindow?.setAlwaysOnTop(menuItem.checked);
}
}
]
},
{
label: '帮助',
submenu: [
{
label: '使用帮助',
click: () => mainWindow?.webContents.send('menu-action', 'help')
},
{ type: 'separator' },
{
label: '关于 Ollama',
click: () => shell.openExternal('https://ollama.com')
},
{
label: '关于 Metona',
click: () => {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v2.0.0',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
}
}
]
}
];
const IS_DEV = !require('electron').app.isPackaged;
if (IS_DEV) {
template.push({
label: 'Dev',
submenu: [
{
label: '切换开发者工具',
accelerator: 'F12',
click: () => mainWindow?.webContents.toggleDevTools()
}
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Metona Ollama Desktop - Preload 脚本
*/
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('metonaDesktop', {
isDesktop: true,
info: () => ipcRenderer.invoke('app:info'),
dialog: {
openFile: (filters?: unknown) => ipcRenderer.invoke('dialog:openFile', { filters }),
saveFile: (options?: unknown) => ipcRenderer.invoke('dialog:saveFile', options)
},
fs: {
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content)
},
notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body),
window: {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close')
},
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
onMenuAction: (callback: (action: string) => void) => {
ipcRenderer.on('menu-action', (_: unknown, action: string) => callback(action));
},
onTrayAction: (callback: (action: string) => void) => {
ipcRenderer.on('tray-action', (_: unknown, action: string) => callback(action));
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
}
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Metona Ollama Desktop - 系统托盘
*/
import { Tray, Menu, nativeImage } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js';
let tray: Tray | null = null;
export function createTray(): void {
const icon = nativeImage.createFromPath(getIconPath());
tray = new Tray(icon.resize({ width: 16, height: 16 }));
const contextMenu = Menu.buildFromTemplate([
{
label: '打开 Metona',
click: () => {
mainWindow?.show();
mainWindow?.focus();
}
},
{ type: 'separator' },
{
label: '新建会话',
click: () => {
mainWindow?.show();
mainWindow?.focus();
mainWindow?.webContents.send('tray-action', 'new-chat');
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
const main = require('./main.js');
main.isQuitting = true;
require('electron').app.quit();
}
}
]);
tray.setToolTip('Metona Ollama');
tray.setContextMenu(contextMenu);
tray.on('double-click', () => {
mainWindow?.show();
mainWindow?.focus();
});
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Metona Ollama Desktop - 主进程工具函数
*/
import { Notification } from 'electron';
import * as path from 'path';
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
export function showNotification(title: string, body: string): void {
if (Notification.isSupported()) {
new Notification({ title, body, icon: ICON_PATH }).show();
}
}
+136
View File
@@ -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<Response> {
const url = `${this.baseUrl}${path}`;
const config: RequestInit = {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers as Record<string, string> || {}) }
};
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<T = unknown>(path: string, body: unknown = null): Promise<T> {
const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {};
const response = await this._request(path, options);
return response.json() as Promise<T>;
}
async listModels(): Promise<OllamaModelsResponse> {
return this._json('/api/tags');
}
async psModels(): Promise<OllamaPsResponse> {
return this._json('/api/ps');
}
async getVersion(): Promise<OllamaVersionResponse> {
return this._json('/api/version');
}
async showModel(model: string): Promise<OllamaModelDetail> {
return this._json('/api/show', { model });
}
async chat(params: Omit<OllamaChatParams, 'stream'>): Promise<unknown> {
const body: Record<string, unknown> = {
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<void> {
const body: Record<string, unknown> = {
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<OllamaEmbedResponse> {
return this._json('/api/embed', { model, input });
}
}
+331
View File
@@ -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<string, string> = {
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<ChatSession | null>(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)
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
: '';
let contentHtml = '';
const safeContent = (msg.content != null) ? String(msg.content) : '';
if (msg.role === 'assistant') {
if (msg.think) {
contentHtml += `
<div class="think-block">
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content"><pre>${escapeHtml(msg.think)}</pre></div>
</div>`;
}
if (safeContent) {
contentHtml += `<div class="msg-content">${safeMarkdown(safeContent)}</div>`;
} else {
contentHtml += `<div class="msg-content"></div>`;
}
} else {
contentHtml += `<div class="msg-content">${escapeHtml(safeContent)}</div>`;
}
if (msg.images && msg.images.length > 0) {
contentHtml += '<div class="msg-images">';
for (const img of msg.images) {
contentHtml += `<img class="msg-img" src="data:image/png;base64,${img}" alt="用户图片" data-lightbox="true">`;
}
contentHtml += '</div>';
}
if (msg.files && msg.files.length > 0) {
contentHtml += '<div class="msg-files">';
for (const f of msg.files) {
const icon = getFileIcon(f.name);
contentHtml += `<div class="file-chip"><span class="file-icon">${icon}</span><span class="file-name">${escapeHtml(f.name)}</span></div>`;
}
contentHtml += '</div>';
}
if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && (msg.images?.length || 0) > 0) {
contentHtml = contentHtml.replace('<div class="msg-content"></div>', '');
}
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 += `<div class="msg-stats">${stats.map(s => `<span>${s}</span>`).join(' · ')}</div>`;
}
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 `<div class="rag-source-item">
<span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>
${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}
</div>`;
}).join('');
contentHtml += `<div class="rag-sources"><div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}</div>`;
}
div.innerHTML = `
<div class="msg-avatar">${avatar}</div>
<div class="msg-body">
<div class="msg-role">${roleLabel}${modelTag}</div>
${contentHtml}
</div>
`;
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 = `
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content"><pre></pre></div>
`;
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 = `
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-role">AI</div>
<div class="msg-content">
<div class="loading-dots"><span></span><span></span><span></span></div>
<span class="loading-text">正在思考...</span>
</div>
</div>
`;
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 = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${escapeHtml(session.title)}</title>
<style>body{max-width:800px;margin:0 auto;padding:20px;font-family:system-ui;line-height:1.6;background:#202020;color:#fff;}
.user{background:rgba(25,25,60,0.5);padding:12px;border-radius:8px;margin:8px 0;}
.assistant{background:rgba(96,205,255,0.03);padding:12px;border-radius:8px;margin:8px 0;border:1px solid rgba(96,205,255,0.08);}
pre{background:rgba(0,0,0,0.3);padding:12px;border-radius:8px;overflow-x:auto;}
code{background:rgba(0,0,0,0.2);padding:2px 6px;border-radius:4px;}</style></head><body>
<h1>${escapeHtml(session.title)}</h1><p>${formatTime(session.createdAt)} · ${session.model}</p><hr>`;
session.messages.forEach(m => {
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${(m.content || '').replace(/\n/g, '<br>')}</div>`;
});
html += '</body></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');
}
+87
View File
@@ -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<OllamaAPI>(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<void> {
const api = state.get<OllamaAPI>(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<void> {
const api = state.get<OllamaAPI>(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 = '<p class="text-muted">无运行中的模型</p>';
return;
}
container.innerHTML = data.models.map(m => `
<div class="running-model">
<span class="model-name">${m.name}</span>
<span class="model-vram">${formatSize(m.size_vram || 0)} VRAM</span>
</div>
`).join('');
} catch {
container.innerHTML = '<p class="text-muted">无法获取运行信息</p>';
}
}
+191
View File
@@ -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<ChatDB>(KEYS.DB);
if (target.classList.contains('btn-delete-session')) {
if (confirm('确定删除此会话?')) {
await db!.deleteSession(sessionId);
const currentSession = state.get<ChatSession | null>(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<void> {
const db = state.get<ChatDB>(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 = `<div class="empty-history"><p class="text-muted">暂无历史记录</p></div>`;
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 => `
<div class="history-item" data-id="${s.id}">
<div class="history-info" data-id="${s.id}">
<div class="history-title">${escapeHtml(s.title)}</div>
<div class="history-meta">
<span>${formatTime(s.updatedAt)}</span>
<span>${s.messages.length} 条消息</span>
<span>${s.model || '无模型'}</span>
</div>
</div>
<div class="history-actions">
<button class="icon-btn sm btn-export-md" data-id="${s.id}" title="导出为 Markdown">📄</button>
<button class="icon-btn sm btn-export-html" data-id="${s.id}" title="导出为 HTML">🌐</button>
<button class="icon-btn sm btn-export-txt" data-id="${s.id}" title="导出为 TXT">📝</button>
<button class="icon-btn sm danger btn-delete-session" data-id="${s.id}" title="删除">🗑️</button>
</div>
</div>
`).join('');
if (totalPages <= 1) {
historyPaginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
return;
}
let html = `<span class="page-info">共 ${allSessions.length} 条</span><div class="page-buttons">`;
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}"></button>`;
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 += '<span class="page-ellipsis">…</span>';
html += `<button class="page-btn${p === historyPage ? ' active' : ''}" data-page="${p}">${p}</button>`;
prev = p;
}
html += `<button class="page-btn${historyPage >= totalPages ? ' disabled' : ''}" data-page="${historyPage + 1}"></button></div>`;
historyPaginationEl.innerHTML = html;
}
async function loadHistorySession(sessionId: string): Promise<void> {
const db = state.get<ChatDB>(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';
}
+534
View File
@@ -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<string, string> = { 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<boolean>(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<void> {
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) => `
<div class="preview-thumb">
<img src="data:image/png;base64,${img.base64}" alt="${escapeHtml(img.name)}">
<button class="remove-img" data-index="${i}">×</button>
</div>
`).join('');
}
async function handleTextFileSelect(files: FileList): Promise<void> {
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) => `
<div class="file-chip">
<span class="file-icon">${getFileIcon(f.name)}</span>
<span class="file-name">${escapeHtml(f.name)}</span>
<span class="file-size">${formatSize(f.size)}</span>
<button class="remove-file" data-index="${i}" title="移除">×</button>
</div>
`).join('');
}
function updateSendButton(streaming: boolean): void {
if (streaming) {
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
btnSendEl.classList.add('stop-btn');
btnSendEl.classList.remove('disabled');
btnSendEl.title = '停止生成';
} else {
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
btnSendEl.classList.remove('stop-btn');
btnSendEl.classList.remove('disabled');
btnSendEl.title = '发送';
}
}
function stopGeneration(): void {
const abortController = state.get<AbortController | null>(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<void> {
const text = chatInputEl.value.trim();
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
if (state.get<boolean>(KEYS.IS_STREAMING)) return;
const model = getSelectedModel();
if (!model) {
appendSystemMessage('⚠️ 请先选择一个模型');
return;
}
if (pendingImages.length > 0 && !isVisionAvailable()) {
appendSystemMessage('⚠️ 当前模型不支持图片分析,请移除图片后重试');
return;
}
const currentSession = state.get<ChatSession | null>(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<ChatSession>(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<OllamaAPI>(KEYS.API)!;
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
const thinkMode = isThinkEnabled();
const systemPromptEnabled = state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED);
const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT);
const numCtx = state.get<number>(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<number>('temperature', 0.7);
const chatParams: Record<string, unknown> = {
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 `<div class="rag-source-item"><span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}</div>`;
}).join('');
const ragDiv = document.createElement('div');
ragDiv.className = 'rag-sources';
ragDiv.innerHTML = `<div class="rag-sources-header">🧠 基于知识库回答</div>${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 += '<p><code>[已停止]</code></p>';
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) + '<p><code>[已中断]</code></p>';
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<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const isHistoryView = state.get<boolean>(KEYS.IS_HISTORY_VIEW);
if (!currentSession || !db || isHistoryView) return;
currentSession.updatedAt = Date.now();
try {
await db.saveSession(currentSession);
} catch (err) {
console.error('[InputArea] 保存会话失败:', err);
}
}
+307
View File
@@ -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<void> {
kbModalEl.style.display = '';
await refreshCollections();
await populateEmbedModels();
}
function closeKBModal(): void {
kbModalEl.style.display = 'none';
}
async function refreshCollections(): Promise<void> {
const vs = await initVectorStore();
const collections = await vs.getCollections();
const listEl = document.querySelector('#kbCollectionList')!;
if (collections.length === 0) {
listEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库,点击上方「新建集合」创建</p>';
currentColId = null;
refreshDocList();
return;
}
listEl.innerHTML = collections.map(c => `
<div class="kb-collection-item ${c.id === currentColId ? 'active' : ''}" data-id="${c.id}">
<div class="kb-col-info">
<span class="kb-col-name">${escapeHtml(c.name)}</span>
<span class="kb-col-stats">${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块</span>
</div>
<button class="kb-col-delete icon-btn" data-id="${c.id}" title="删除集合">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
`).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<void> {
const docListEl = document.querySelector('#kbDocList')!;
if (!currentColId) {
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>';
return;
}
const docs = await getDocumentsInCollection(currentColId);
if (docs.length === 0) {
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">此集合暂无文档,上传文件开始构建知识库</p>';
return;
}
docListEl.innerHTML = docs.map(d => `
<div class="kb-doc-item">
<span class="kb-doc-icon">📄</span>
<div class="kb-doc-info">
<span class="kb-doc-name">${escapeHtml(d.filename)}</span>
<span class="kb-doc-meta">${d.chunkCount} 个分块</span>
</div>
<button class="kb-doc-delete icon-btn" data-docid="${d.docId}" title="删除文档">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
`).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<void> {
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<void> {
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<void> {
const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement;
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) return;
try {
const data = await api.listModels();
select.innerHTML = '<option value="">选择嵌入模型...</option>';
if (!data.models || data.models.length === 0) {
select.innerHTML = '<option value="">未安装任何模型</option>';
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 = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
}
} catch (err) {
console.warn('[KB] 加载模型列表失败:', err);
}
}
+32
View File
@@ -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 = '';
}
+195
View File
@@ -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<string, { think: boolean; vision: boolean; completion: boolean }>();
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<ChatDB | null>(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<void> {
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) return;
try {
const data = await api.listModels();
modelSelectEl.innerHTML = '';
if (!data.models || data.models.length === 0) {
modelSelectEl.innerHTML = '<option value="">未安装任何模型</option>';
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<string>('_defaultModel', '');
if (defaultModel) modelSelectEl.value = defaultModel;
if (modelSelectEl.value) {
await checkModelCapability(modelSelectEl.value);
}
filterEmbedModels(data.models);
} catch (err) {
console.error('[ModelBar] 加载模型失败:', err);
modelSelectEl.innerHTML = '<option value="">加载失败 - 检查连接</option>';
badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
setThinkAvailable(false); setVisionAvailable(false);
}
}
async function filterEmbedModels(models: Array<{ name: string }>): Promise<void> {
const api = state.get<OllamaAPI>(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<ChatDB | null>(KEYS.DB);
if (db) await db.saveSetting('selectedModel', modelSelectEl.value);
await checkModelCapability(modelSelectEl.value);
}
}
async function checkModelCapability(modelName: string): Promise<void> {
if (modelCapabilityCache.has(modelName)) {
applyCapability(modelName, modelCapabilityCache.get(modelName)!);
return;
}
const api = state.get<OllamaAPI>(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;
}
+187
View File
@@ -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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 = `
<div class="preset-list" id="presetList"></div>
<button class="preset-add-btn" id="btnAddPreset" title="管理预设">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
`;
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 = `<span class="preset-chip-icon">${preset.icon}</span><span class="preset-chip-name">${escapeHtml(preset.name)}</span>`;
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<void> {
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;
}
});
}
+179
View File
@@ -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<ChatDB | null>(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<ChatDB | null>(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<ChatDB | null>(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<ChatDB | null>(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<ChatDB | null>(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<OllamaAPI>(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<ChatDB | null>(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<OllamaAPI>(KEYS.API)?.baseUrl || '';
updateConnectionInfo();
updateRunningModels();
}
export function closeSettingsModal(): void {
settingsModalEl.style.display = 'none';
}
async function exportAllSessions(): Promise<void> {
const db = state.get<ChatDB | null>(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<void> {
const db = state.get<ChatDB | null>(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');
}
}
+24
View File
@@ -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<string, string> = { success: '✓', error: '✗', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span class="toast-icon">${iconMap[type] || ''}</span><span>${text}</span>`;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
toast.addEventListener('animationend', () => toast.remove());
}, duration);
}
+154
View File
@@ -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<void> {
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<string> {
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<ChatSession | null> {
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<ChatSession[]> {
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<void> {
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<void> {
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<ChatSession[]> {
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<void> {
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<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
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);
});
}
}
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#202020">
<meta name="description" content="Metona Ollama Desktop - TypeScript + Electron AI 聊天客户端">
<link rel="icon" href="../assets/icons/llama.ico" type="image/x-icon">
<title>Metona Ollama</title>
</head>
<body>
<div id="app">
<!-- ═══════════════ 顶部导航 ═══════════════ -->
<header class="app-header">
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v2.0.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</button>
</div>
<div class="header-right">
<div class="conn-status pending" id="connStatus" title="连接状态">
<span class="status-dot"></span>
<span class="status-label">未连接</span>
</div>
<button class="icon-btn" id="btnNewChat" title="新建会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="icon-btn" id="btnKB" title="知识库">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<line x1="8" y1="7" x2="16" y2="7"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
</button>
<button class="icon-btn" id="btnHistory" title="历史记录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</button>
<button class="icon-btn" id="btnSettings" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
</header>
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
<div class="model-bar">
<div class="model-bar-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"/>
<path d="M9 9h6M9 12h6M9 15h4"/>
</svg>
</div>
<div class="model-select-wrap">
<select class="model-select" id="modelSelect">
<option value="">正在加载模型...</option>
</select>
<span class="model-select-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</span>
</div>
<div class="model-badges" id="modelBadges">
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
<span class="model-badge rag-badge" id="badgeRAG" style="display:none;">📚 RAG</span>
</div>
</div>
<!-- ═══════════════ 聊天区域 ═══════════════ -->
<main class="chat-area" id="chatArea">
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="80" stroke="rgba(0,245,212,0.2)" stroke-width="2"/>
<circle cx="100" cy="100" r="60" stroke="rgba(123,47,247,0.2)" stroke-width="2"/>
<path d="M70 90c0-16.57 13.43-30 30-30s30 13.43 30 30" stroke="rgba(0,245,212,0.5)" stroke-width="2" stroke-linecap="round"/>
<circle cx="85" cy="100" r="5" fill="rgba(0,245,212,0.6)"/>
<circle cx="115" cy="100" r="5" fill="rgba(0,245,212,0.6)"/>
<path d="M88 120c4 4 20 4 24 0" stroke="rgba(0,245,212,0.4)" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h2>开始对话</h2>
<p>选择一个模型,输入消息开始聊天</p>
</div>
<div class="messages" id="messagesContainer" style="display:none;"></div>
<button class="scroll-to-bottom" id="scrollToBottom" style="display:none;" title="回到最新">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<span>回到底部</span>
</button>
</main>
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
<div class="history-view-bar" id="historyBar" style="display:none;">
<span>📖 正在查看历史记录(只读模式)</span>
<button class="btn btn-sm btn-outline" id="btnExitHistory">返回新会话</button>
</div>
<!-- ═══════════════ 输入区域 ═══════════════ -->
<div class="input-area" id="inputArea">
<div class="image-preview" id="imagePreview" style="display:none;"></div>
<div class="file-preview" id="filePreview" style="display:none;"></div>
<div class="input-row">
<button class="icon-btn attach-btn" id="btnAttachImg" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<button class="icon-btn attach-btn" id="btnAttachFile" title="上传文件(文本/代码)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</button>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
<input type="file" id="textFileInput" accept=".txt,.md,.log,.csv,.py,.pyw,.js,.mjs,.cjs,.ts,.tsx,.jsx,.java,.c,.h,.cpp,.cc,.hpp,.go,.rs,.rb,.php,.sh,.bash,.zsh,.sql,.html,.htm,.css,.json,.jsonl,.xml,.svg,.yaml,.yml,.toml,.ini,.cfg,.conf,.swift,.kt,.kts,.scala,.lua,.r,.m,.cs,.fs,.ex,.exs,.erl,.hs,.clj,.lisp,.diff,.patch,Dockerfile,Makefile,.rst,.tsv,.vb,.pl,.mm,.pyi,.fsharp,.makefile,.dockerfile" multiple style="display:none;">
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
<label class="think-toggle" id="thinkToggleWrap" title="Think 模式未可用">
<input type="checkbox" id="toggleThink" disabled>
<span class="think-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
<line x1="9" y1="21" x2="15" y2="21"/>
</svg>
</span>
</label>
<button class="send-btn" id="btnSend">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- ═══════════════ 设置模态框 ═══════════════ -->
<div class="modal-overlay" id="settingsModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>⚙️ 设置</h3>
<button class="icon-btn" id="btnCloseSettings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="setting-group">
<label class="setting-label">Ollama 服务地址</label>
<input class="setting-input" id="inputServerUrl" type="url" placeholder="http://127.0.0.1:11434">
<div class="connection-info" id="connInfo">
<span class="status-badge" id="connBadge">检测中...</span>
<span class="server-version" id="serverVersion"></span>
</div>
<div class="cors-hint" id="corsHint" style="display:none;">
<p>⚠️ 连接失败,可能是 CORS 问题。请在启动 Ollama 时设置环境变量:</p>
<code>OLLAMA_ORIGINS="*" ollama serve</code>
</div>
</div>
<div class="setting-group">
<label class="setting-label">
系统提示词(System Prompt
<label class="toggle-label" style="margin-left:auto;display:inline-flex;">
<input type="checkbox" id="toggleSystemPrompt">
<span class="toggle-slider"></span>
</label>
</label>
<textarea class="setting-input" id="inputSystemPrompt" rows="4" placeholder="例如:你是一个专业的编程助手,用中文回答。" style="resize:vertical;min-height:80px;" disabled></textarea>
<label class="setting-label" style="margin-top:12px;">上下文长度(num_ctx</label>
<div style="display:flex;gap:8px;align-items:center;">
<input class="setting-input" id="inputNumCtx" type="number" min="512" max="131072" step="512" value="24576" style="margin-bottom:0;">
<span class="text-muted" style="white-space:nowrap;">tokens</span>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。</p>
<label class="setting-label" style="margin-top:12px;">温度(Temperature):<span id="tempValue">0.7</span></label>
<input type="range" id="inputTemperature" class="preset-temp-slider" min="0" max="2" step="0.1" value="0.7">
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
</div>
<div class="setting-group">
<label class="setting-label">运行中的模型</label>
<div class="running-models" id="runningModels">
<p class="text-muted">无运行中的模型</p>
</div>
</div>
<div class="setting-group">
<label class="setting-label">显存管理</label>
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
</div>
<div class="setting-group">
<label class="setting-label">数据管理</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-outline" id="btnExportAll">📦 导出全部会话</button>
<button class="btn btn-outline" id="btnImportSessions">📥 导入会话</button>
<input type="file" id="importFileInput" accept=".metona" style="display:none;">
<button class="btn btn-danger" id="btnClearAllHistory">清空所有历史</button>
</div>
<p class="text-muted" style="margin-top:6px;font-size:11px;">导出格式为 JSON,可备份后在其他设备导入恢复。</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════ 历史记录模态框 ═══════════════ -->
<div class="modal-overlay" id="historyModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>🕐 历史记录</h3>
<button class="icon-btn" id="btnCloseHistory">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="history-search-wrap">
<input class="history-search-input" id="historySearchInput" type="text" placeholder="搜索会话标题或内容...">
<button class="history-search-clear" id="historySearchClear" style="display:none;"></button>
</div>
<div class="history-list" id="historyList"></div>
<div class="history-pagination" id="historyPagination"></div>
</div>
</div>
</div>
<!-- ═══════════════ 帮助模态框 ═══════════════ -->
<div class="modal-overlay" id="helpModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>📖 使用帮助</h3>
<button class="icon-btn" id="btnCloseHelp">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code></li><li>顶部下拉框选择一个模型</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 🧠 按钮开启深度思考(需模型支持)</li><li><strong>上下文长度</strong> — 设置中调整 <code>num_ctx</code>,越大记忆越长</li><li><strong>系统提示词</strong> — 设置中开启,定义 AI 的角色和行为</li></ul></div>
<div class="help-section"><h4>📎 文件与图片</h4><ul><li><strong>📷 上传图片</strong> — 图标按钮上传,模型需支持 Vision</li><li><strong>📄 上传文件</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB</li><li>文件内容自动以代码块格式发送给模型分析</li></ul></div>
<div class="help-section"><h4>📚 知识库 (RAG)</h4><ul><li>点击顶部 📚 按钮打开知识库管理</li><li>创建集合 → 选择嵌入模型(推荐 <code>nomic-embed-text</code> 等)</li><li>上传文档,自动分块并生成向量索引</li><li>开启 RAG 检索后,聊天时自动检索相关文档注入回答</li></ul></div>
<div class="help-section"><h4>🤖 Agent 预设</h4><ul><li>模型栏下方显示<strong>预设栏</strong>,点击胶囊按钮一键切换角色/工作模式</li><li><strong>内置预设</strong> — 💬默认、🌐翻译官、🔍代码审查、✍️写作助手、📊数据分析师、🎓学习导师</li><li>切换预设自动应用:系统提示词、温度、上下文长度、Think 开关</li><li>点击 <strong></strong> 按钮可创建自定义预设</li></ul></div>
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到 IndexedDB</li><li>点击 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 <code>.metona</code> 加密备份文件</li></ul></div>
<div class="help-section"><h4>⚡ 快捷键</h4><table class="help-shortcuts"><tr><td><kbd>Enter</kbd></td><td>发送消息</td></tr><tr><td><kbd>Shift + Enter</kbd></td><td>换行</td></tr><tr><td><kbd>Esc</kbd></td><td>关闭弹窗</td></tr></table></div>
</div>
</div>
</div>
<!-- ═══════════════ 知识库管理模态框 ═══════════════ -->
<div class="modal-overlay" id="kbModal" style="display:none;">
<div class="modal modal-lg">
<div class="modal-header">
<h3>📚 知识库 (RAG)</h3>
<button class="icon-btn" id="btnCloseKB">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="kb-layout">
<div class="kb-sidebar">
<div class="kb-new-collection">
<input class="setting-input" id="inputKBName" type="text" placeholder="新建知识库名称...">
<button class="btn btn-sm btn-primary" id="btnNewCollection">创建</button>
</div>
<div class="kb-collection-list" id="kbCollectionList">
<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库</p>
</div>
</div>
<div class="kb-main">
<div class="kb-toolbar">
<button class="btn btn-sm btn-outline" id="btnUploadDoc">📄 上传文档</button>
<input type="file" id="kbFileInput" accept=".txt,.md,.py,.js,.ts,.java,.go,.rs,.cpp,.c,.h,.hpp,.rb,.php,.sh,.html,.css,.json,.yaml,.yml,.toml,.xml,.sql,.csv" multiple style="display:none;">
<span id="kbProgress" style="display:none;" class="kb-progress">
<span class="spinner"></span>
<span id="kbProgressText">处理中...</span>
</span>
<select class="kb-embed-select" id="selectEmbedModel">
<option value="">嵌入模型...</option>
</select>
<div class="kb-rag-toggle">
<label class="setting-label" style="margin:0;font-size:12px;white-space:nowrap;">RAG</label>
<label class="toggle-label" style="margin:0;">
<input type="checkbox" id="toggleRAG">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="kb-doc-list" id="kbDocList">
<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════ 预设管理模态框 ═══════════════ -->
<div class="modal-overlay" id="presetModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3 id="presetModalTitle">✨ 新建预设</h3>
<button class="icon-btn" id="btnClosePresetModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="setting-group">
<label class="setting-label">名称</label>
<div class="preset-name-row">
<input class="setting-input preset-icon-input" id="inputPresetIcon" type="text" maxlength="4" placeholder="🤖">
<input class="setting-input" id="inputPresetName" type="text" placeholder="例如:翻译官" style="flex:1;">
</div>
</div>
<div class="setting-group">
<label class="setting-label">系统提示词(System Prompt</label>
<textarea class="setting-input" id="inputPresetSystemPrompt" rows="6" placeholder="定义这个角色的行为、能力和回答风格..." style="resize:vertical;min-height:120px;"></textarea>
</div>
<div class="setting-group">
<label class="setting-label">温度(Temperature):<span id="presetTempValue">0.7</span></label>
<input type="range" id="inputPresetTemp" class="preset-temp-slider" min="0" max="2" step="0.1" value="0.7">
</div>
<div class="setting-group">
<label class="setting-label">上下文长度(num_ctx</label>
<div style="display:flex;gap:8px;align-items:center;">
<input class="setting-input" id="inputPresetNumCtx" type="number" min="512" max="131072" step="512" value="24576" style="margin-bottom:0;">
<span class="text-muted" style="white-space:nowrap;">tokens</span>
</div>
</div>
<div class="setting-group">
<label class="setting-label">Think 深度推理</label>
<label class="toggle-label" style="display:inline-flex;align-items:center;gap:8px;">
<input type="checkbox" id="togglePresetThink">
<span class="toggle-slider"></span>
<span class="text-muted" style="font-size:12px;">需要模型支持</span>
</label>
</div>
<div class="preset-modal-actions">
<button class="btn btn-danger" id="btnDeletePreset" style="display:none;">🗑️ 删除</button>
<div style="flex:1;"></div>
<button class="btn btn-outline" id="btnClosePresetModal2" onclick="document.querySelector('#presetModal').style.display='none';">取消</button>
<button class="btn btn-primary" id="btnSavePreset">创建</button>
</div>
</div>
</div>
</div>
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
<div class="lightbox-overlay" id="lightbox" style="display:none;">
<button class="lightbox-close" id="lightboxClose"></button>
<img class="lightbox-img" id="lightboxImg" src="" alt="预览">
</div>
<!-- ═══════════════ Toast 通知容器 ═══════════════ -->
<div class="toast-container" id="toastContainer"></div>
<!-- ═══════════════ 应用入口 ════════════════ -->
<script type="module" src="./main.ts"></script>
</body>
</html>
+243
View File
@@ -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<string>('_defaultModel', '');
return {
id: generateId(),
title: '新会话',
model: defaultModel,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
}
async function startNewSession(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (db && currentSession && currentSession.messages.length > 0) {
currentSession.updatedAt = Date.now();
await db.saveSession(currentSession);
}
const api = state.get<OllamaAPI>(KEYS.API);
const model = currentSession?.model || state.get<string>('_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<void> {
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();
+163
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
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<Blob> {
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<unknown> {
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');
}
@@ -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
};
}
+179
View File
@@ -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<void> {
const db = state.get<ChatDB | null>(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<void> {
const preset = presets.find(p => p.id === presetId);
if (!preset) return;
activePresetId = presetId;
state.set('activePresetId', presetId);
applyPresetToState(preset, true);
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) {
await db.saveSetting(ACTIVE_PRESET_KEY, presetId);
}
}
function applyPresetToState(preset: Preset, saveToDb: boolean): void {
const db = state.get<ChatDB | null>(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<HTMLInputElement>('#toggleSystemPrompt');
const inputSP = document.querySelector<HTMLTextAreaElement>('#inputSystemPrompt');
if (toggleSP) toggleSP.checked = !!preset.systemPrompt;
if (inputSP) { inputSP.disabled = !preset.systemPrompt; inputSP.value = preset.systemPrompt || ''; }
const inputCtx = document.querySelector<HTMLInputElement>('#inputNumCtx');
if (inputCtx) inputCtx.value = String(preset.numCtx || 24576);
const tempSlider = document.querySelector<HTMLInputElement>('#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<HTMLInputElement>('#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<Preset> {
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<Preset>): Promise<void> {
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<void> {
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<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const userPresets = presets
.filter(p => !p.builtIn)
.map(({ builtIn, ...rest }) => rest);
await db.saveSetting(PRESETS_KEY, userPresets);
}
+145
View File
@@ -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<VectorStore> {
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<number[]> {
const api = state.get<OllamaAPI>(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<number[][]> {
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<void> {
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<Array<{ docId: string; filename: string; chunkCount: number }>> {
const vs = await initVectorStore();
const vectors = await vs.getVectorsByCollection(colId);
const docMap = new Map<string, { docId: string; filename: string; chunkCount: number }>();
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;
}
+368
View File
@@ -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<number, string[]>;
collectionId?: string;
version?: string;
}
export class VectorStore {
private dbName: string;
private db: IDBDatabase | null = null;
private _indexCache = new Map<string, IVFIndex>();
private _vectorCache = new Map<string, Map<string, VectorItem>>();
constructor(dbName = 'metona-ollama-vectors') {
this.dbName = dbName;
}
async init(): Promise<void> {
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<VectorCollection> {
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<VectorCollection[]> {
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<VectorCollection | null> {
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<void> {
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<void> {
const vectors = await this.getVectorsByCollection(colId);
await new Promise<void>((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<void> {
if (items.length === 0) return;
await new Promise<void>((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<VectorItem[]> {
if (this._vectorCache.has(colId)) {
return Array.from(this._vectorCache.get(colId)!.values());
}
const vectors = await new Promise<VectorItem[]>((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<string, VectorItem>();
for (const v of vectors) cache.set(v.id, v);
this._vectorCache.set(colId, cache);
return vectors;
}
async deleteVectorsByDocument(colId: string, docId: string): Promise<void> {
const vectors = await this.getVectorsByCollection(colId);
const docVectors = vectors.filter(v => v.docId === docId);
await new Promise<void>((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<SearchResult[]> {
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<string>();
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<string, VectorItem>();
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<IVFIndex | null> {
if (this._indexCache.has(colId)) {
const cached = this._indexCache.get(colId)!;
if (cached.version === this._indexVersion(vectors)) return cached;
}
const saved = await new Promise<IVFIndex & { invertedListsObj?: Record<string, string[]> } | 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<void>((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<number>(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<number>(dim).fill(0));
const counts = new Array<number>(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<number, string[]>();
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;
}
}
+151
View File
@@ -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<string, unknown>)[key] !== (b as Record<string, unknown>)[key]) return false;
}
return true;
}
type Listener = (value: unknown, old: unknown) => void;
class AppState {
private _state: Record<string, unknown> = {};
private _listeners = new Map<string, Set<Listener>>();
private _batching = false;
private _batchQueue: Set<string> | 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<string, unknown>) };
let current: Record<string, unknown> = newRoot as Record<string, unknown>;
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<string, unknown>) };
}
current = current[segment as string] as Record<string, unknown>;
}
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<T = unknown>(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<string, unknown>): 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;
File diff suppressed because it is too large Load Diff
+250
View File
@@ -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<AppInfo>;
dialog: {
openFile: (options?: FileFilterOptions) => Promise<string[] | null>;
saveFile: (options?: SaveFileOptions) => Promise<string | null>;
};
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<string[] | null>;
saveFile: (options?: SaveFileOptions) => Promise<string | null>;
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;
}
}
+83
View File
@@ -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 `<pre><code class="language-${lang}">${code.trim()}</code></pre>`;
});
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 标题
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// 粗体+斜体
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// 粗体
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 斜体
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// 删除线
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [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 `<span class="blocked-link">${text}</span>`;
} catch { /* allow relative */ }
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
});
// 图片 ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy">');
// 引用块
html = html.replace(/^>\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
// 水平线
html = html.replace(/^---+$/gm, '<hr>');
// 无序列表
html = html.replace(/^[\-\*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// 有序列表
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
// 换行
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
// 包裹在 p 标签中
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
function escapeForMd(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export { parseMarkdown as marked };
+74
View File
@@ -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;
}
}
+115
View File
@@ -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<T extends (...args: unknown[]) => unknown>(fn: T, ms = 300): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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<string> {
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<string> {
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<string, string> = {
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;
}
+30
View File
@@ -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"]
}