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:
+12
-15
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "1.1.0",
|
||||
"description": "Metona Ollama - 桌面端 AI 聊天客户端",
|
||||
"main": "electron/main.js",
|
||||
"version": "2.0.0",
|
||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||
"main": "dist/main/main.js",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite && electron .",
|
||||
"build": "tsc && vite build",
|
||||
"start": "electron . --no-sandbox",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder --win",
|
||||
@@ -19,27 +21,19 @@
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"js/**/*",
|
||||
"css/**/*",
|
||||
"dist/**/*",
|
||||
"assets/**/*",
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"!node_modules/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icons/llama.ico",
|
||||
@@ -60,7 +54,10 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.0",
|
||||
"electron": "^33.4.11",
|
||||
"electron-builder": "^25.0.0"
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - IPC 处理器
|
||||
*/
|
||||
|
||||
import { ipcMain, dialog, shell } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { mainWindow } from './main.js';
|
||||
import { showNotification } from './utils.js';
|
||||
|
||||
export function setupIPC(): void {
|
||||
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
|
||||
});
|
||||
if (result.canceled) return null;
|
||||
return result.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:saveFile', async (_, options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: options?.defaultPath || 'export',
|
||||
filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }]
|
||||
});
|
||||
if (result.canceled) return null;
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, 'utf-8');
|
||||
return { success: true, content, name: path.basename(filePath) };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, content, 'utf-8');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('notify', (_: unknown, title: string, body: string) => {
|
||||
showNotification(title, body);
|
||||
});
|
||||
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: require('electron').app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
userDataPath: require('electron').app.getPath('userData')
|
||||
}));
|
||||
|
||||
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
||||
else mainWindow?.maximize();
|
||||
});
|
||||
ipcMain.handle('window:close', () => mainWindow?.close());
|
||||
|
||||
ipcMain.handle('shell:openExternal', (_, url: string) => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 主进程入口
|
||||
*/
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { setupIPC } from './ipc.js';
|
||||
import { createTray } from './tray.js';
|
||||
import { createMenu } from './menu.js';
|
||||
import { showNotification } from './utils.js';
|
||||
|
||||
const APP_NAME = 'Metona Ollama';
|
||||
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
|
||||
const ICO_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.ico');
|
||||
const IS_DEV = !app.isPackaged;
|
||||
|
||||
export let mainWindow: BrowserWindow | null = null;
|
||||
export let isQuitting = false;
|
||||
|
||||
export function getIconPath(): string {
|
||||
return process.platform === 'win32' ? ICO_PATH : ICON_PATH;
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const configPath = path.join(userDataPath, 'window-state.json');
|
||||
let windowState = { width: 1200, height: 800, x: undefined as number | undefined, y: undefined as number | undefined, maximized: false };
|
||||
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: windowState.width || 1200,
|
||||
height: windowState.height || 800,
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH,
|
||||
title: APP_NAME,
|
||||
backgroundColor: '#202020',
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: false
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, '..', '..', 'index.html'));
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (windowState.maximized) {
|
||||
mainWindow!.maximize();
|
||||
}
|
||||
mainWindow!.show();
|
||||
});
|
||||
|
||||
const saveWindowState = (): void => {
|
||||
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
|
||||
const bounds = mainWindow.getBounds();
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(bounds));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
mainWindow.on('resize', saveWindowState);
|
||||
mainWindow.on('move', saveWindowState);
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
state.maximized = true;
|
||||
fs.writeFileSync(configPath, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
state.maximized = false;
|
||||
fs.writeFileSync(configPath, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting) {
|
||||
e.preventDefault();
|
||||
mainWindow!.hide();
|
||||
if (!(global as Record<string, unknown>)._trayHintShown) {
|
||||
(global as Record<string, unknown>)._trayHintShown = true;
|
||||
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const { shell } = require('electron');
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' as const };
|
||||
}
|
||||
return { action: 'allow' as const };
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ── 单实例锁 ──
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
setupIPC();
|
||||
createMainWindow();
|
||||
createTray();
|
||||
createMenu();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
// keep running with tray
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 原生菜单
|
||||
*/
|
||||
|
||||
import { Menu, dialog, shell } from 'electron';
|
||||
import { mainWindow, isQuitting, getIconPath } from './main.js';
|
||||
|
||||
export function createMenu(): void {
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: '文件',
|
||||
submenu: [
|
||||
{
|
||||
label: '新建会话',
|
||||
accelerator: 'Ctrl+N',
|
||||
click: () => mainWindow?.webContents.send('menu-action', 'new-chat')
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '导入会话',
|
||||
click: () => mainWindow?.webContents.send('menu-action', 'import')
|
||||
},
|
||||
{
|
||||
label: '导出会话',
|
||||
accelerator: 'Ctrl+Shift+E',
|
||||
click: () => mainWindow?.webContents.send('menu-action', 'export')
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
accelerator: 'Ctrl+Q',
|
||||
click: () => {
|
||||
const { app } = require('electron');
|
||||
(require('./main.js') as Record<string, unknown>).isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
submenu: [
|
||||
{ role: 'undo', label: '撤销' },
|
||||
{ role: 'redo', label: '重做' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut', label: '剪切' },
|
||||
{ role: 'copy', label: '复制' },
|
||||
{ role: 'paste', label: '粘贴' },
|
||||
{ role: 'selectAll', label: '全选' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '视图',
|
||||
submenu: [
|
||||
{ role: 'reload', label: '刷新' },
|
||||
{ role: 'forceReload', label: '强制刷新' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'zoomIn', label: '放大' },
|
||||
{ role: 'zoomOut', label: '缩小' },
|
||||
{ role: 'resetZoom', label: '重置缩放' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen', label: '全屏' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '窗口',
|
||||
submenu: [
|
||||
{ role: 'minimize', label: '最小化' },
|
||||
{
|
||||
label: '最大化/还原',
|
||||
click: () => {
|
||||
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
||||
else mainWindow?.maximize();
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '置顶',
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
click: (menuItem: Electron.MenuItem) => {
|
||||
mainWindow?.setAlwaysOnTop(menuItem.checked);
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '帮助',
|
||||
submenu: [
|
||||
{
|
||||
label: '使用帮助',
|
||||
click: () => mainWindow?.webContents.send('menu-action', 'help')
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '关于 Ollama',
|
||||
click: () => shell.openExternal('https://ollama.com')
|
||||
},
|
||||
{
|
||||
label: '关于 Metona',
|
||||
click: () => {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 Metona Ollama',
|
||||
message: 'Metona Ollama Desktop v2.0.0',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const IS_DEV = !require('electron').app.isPackaged;
|
||||
if (IS_DEV) {
|
||||
template.push({
|
||||
label: 'Dev',
|
||||
submenu: [
|
||||
{
|
||||
label: '切换开发者工具',
|
||||
accelerator: 'F12',
|
||||
click: () => mainWindow?.webContents.toggleDevTools()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - Preload 脚本
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('metonaDesktop', {
|
||||
isDesktop: true,
|
||||
info: () => ipcRenderer.invoke('app:info'),
|
||||
dialog: {
|
||||
openFile: (filters?: unknown) => ipcRenderer.invoke('dialog:openFile', { filters }),
|
||||
saveFile: (options?: unknown) => ipcRenderer.invoke('dialog:saveFile', options)
|
||||
},
|
||||
fs: {
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
|
||||
writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content)
|
||||
},
|
||||
notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body),
|
||||
window: {
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close')
|
||||
},
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
onMenuAction: (callback: (action: string) => void) => {
|
||||
ipcRenderer.on('menu-action', (_: unknown, action: string) => callback(action));
|
||||
},
|
||||
onTrayAction: (callback: (action: string) => void) => {
|
||||
ipcRenderer.on('tray-action', (_: unknown, action: string) => callback(action));
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 系统托盘
|
||||
*/
|
||||
|
||||
import { Tray, Menu, nativeImage } from 'electron';
|
||||
import { mainWindow, isQuitting, getIconPath } from './main.js';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
export function createTray(): void {
|
||||
const icon = nativeImage.createFromPath(getIconPath());
|
||||
tray = new Tray(icon.resize({ width: 16, height: 16 }));
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '打开 Metona',
|
||||
click: () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '新建会话',
|
||||
click: () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
mainWindow?.webContents.send('tray-action', 'new-chat');
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
const main = require('./main.js');
|
||||
main.isQuitting = true;
|
||||
require('electron').app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
tray.setToolTip('Metona Ollama');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on('double-click', () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 主进程工具函数
|
||||
*/
|
||||
|
||||
import { Notification } from 'electron';
|
||||
import * as path from 'path';
|
||||
|
||||
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
|
||||
|
||||
export function showNotification(title: string, body: string): void {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body, icon: ICON_PATH }).show();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Vendored
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>`;
|
||||
});
|
||||
|
||||
// 图片 
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export { parseMarkdown as marked };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user