feat: TypeScript + Electron v2.0.0 构建系统完善

- 添加 vite.config.ts 构建配置
- 添加 tsconfig.main.json(主进程单独编译)
- 修复主进程模块化(menu/tray 使用 setQuitting)
- 修复渲染进程导入路径
- 删除旧版 JS/Web 文件(js/、electron/、sw.js、manifest.json)
- 构建验证通过:NSIS 安装包 + 便携版
This commit is contained in:
thzxx
2026-04-06 03:21:05 +08:00
parent 7ca0e33d77
commit 0ec6e902de
43 changed files with 1932 additions and 11104 deletions
-144
View File
@@ -1,144 +0,0 @@
# Windows 安装包构建指南
## 环境要求
- Ubuntu 24.04 (amd64)
- Node.js v22+
- Wine 9.0+(用于 electron-builder 交叉编译)
- i386 架构已启用(`dpkg --add-architecture i386`
## 构建步骤
### 1. 克隆项目
```bash
git clone https://gitee.com/thzxx/metona-ollama.git
cd metona-ollama
git checkout metona-ollama-desktop-v1.1
```
### 2. 安装依赖(使用国内镜像)
```bash
npm config set registry https://registry.npmmirror.com
npm install
```
> ⚠️ **不要用默认 npm 源**,国内环境下大概率超时。npmmirror 速度快且稳定。
### 3. 构建 Windows 安装包
```bash
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
> ⚠️ **必须设置 `ELECTRON_MIRROR`**,否则 electron-builder 会从 GitHub 下载 Electron 二进制(115MB),国内基本连不上。
## 遇到的问题与解决方案
### 问题 1:apt 源不可用(阿里云镜像超时)
**现象**`apt-get update` 报错 `mirrors.cloud.aliyuncs.com` 连接超时。
**解决**:替换为 Ubuntu 官方源:
```bash
sed -i 's|http://mirrors.cloud.aliyuncs.com/ubuntu|http://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
```
### 问题 2npm install 超时
**现象**:使用默认 npm registry 下载依赖超时。
**解决**:切换到 npmmirror 国内镜像:
```bash
npm config set registry https://registry.npmmirror.com
```
### 问题 3Electron 下载超时(GitHub 连不上)
**现象**electron-builder 打包时卡在 `downloading electron-v33.4.11-win32-x64.zip`115MB 从 GitHub 下载,国内超时。
**解决**:设置环境变量使用 npmmirror 的 Electron 镜像:
```bash
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
设置后 4.22 秒下载完成,vs 之前直接超时。
### 问题 4winCodeSign 下载慢
**现象**electron-builder 需要从 GitHub 下载 `winCodeSign-2.6.0`5.6MB)。
**说明**:这个文件较小,即使从 GitHub 下载通常也能完成(可能需要几分钟)。如果超时,可以手动下载放到缓存目录:
```bash
# electron-builder 缓存目录
~/.cache/electron-builder/winCodeSign/winCodeSign-2.6.0/
```
## 常用命令
```bash
# 开发运行
npm start
# 仅构建目录(不打包,用于测试)
npm run pack
# 构建 NSIS 安装包
npm run dist:nsis
# 构建绿色便携版
npm run dist:portable
# 构建全部(NSIS + Portable
npm run dist
```
## 构建产物
| 文件 | 说明 |
|------|------|
| `release/Metona Ollama Setup X.X.X.exe` | NSIS 安装包(可选目录、创建快捷方式) |
| `release/MetonaOllama-Portable-X.X.X.exe` | 绿色便携版(免安装,双击即用) |
## 发布到 Gitee
### 上传附件
```bash
TOKEN="your_access_token"
RELEASE_ID="600168" # v1.1.0 release ID
curl -X POST \
"https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "file=@release/Metona Ollama Setup 1.1.0.exe"
curl -X POST \
"https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "file=@release/MetonaOllama-Portable-1.1.0.exe"
```
### 清理重复附件
```bash
# 查看附件列表
curl -s "https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases/$RELEASE_ID/attach_files?access_token=$TOKEN"
# 删除指定附件
curl -X DELETE "https://gitee.com/api/v5/repos/thzxx/metona-ollama/releases/$RELEASE_ID/attach_files/$ASSET_ID?access_token=$TOKEN"
```
## 提速清单
| 环节 | 默认方案 | 加速方案 | 效果 |
|------|----------|----------|------|
| apt 源 | archive.ubuntu.com | 保持官方源即可 | 稳定可用 |
| npm 依赖 | registry.npmjs.org | registry.npmmirror.com | 59s 完成 |
| Electron 下载 | github.com | npmmirror.com/mirrors/electron | 4s 完成(vs 超时) |
| winCodeSign | github.com | 小文件可等待 / 手动缓存 | 通常可完成 |
-119
View File
@@ -1,119 +0,0 @@
# Metona Ollama Desktop v1.0
基于 [Metona Ollama](https://gitee.com/thzxx/metona-ollama) Web 版封装的 **Electron Windows 桌面客户端**
## ✨ 桌面版特性
| 特性 | 说明 |
|------|------|
| 原生安装包 | NSIS 安装程序 + 绿色便携版 |
| 系统托盘 | 最小化到托盘,双击恢复,托盘右键菜单 |
| 原生菜单 | 完整的文件/编辑/视图/窗口/帮助菜单 |
| 原生文件对话框 | Windows 文件选择器替代浏览器 input |
| 系统通知 | 通过 Windows 原生通知中心推送 |
| 窗口管理 | 记忆窗口大小和位置,支持置顶 |
| 单实例锁 | 防止重复启动 |
| 无 CORS 限制 | 关闭 webSecurity,直接连接本地 Ollama |
## 🚀 开发
### 前置条件
- Node.js 18+
- npm
### 启动开发环境
```bash
# 安装依赖
npm install
# 启动开发模式
npm start
```
### 构建
```bash
# 构建目录(不打包,用于测试)
npm run pack
# 构建 Windows 安装包 (NSIS + Portable)
npm run dist
# 仅构建 NSIS 安装包
npm run dist:nsis
# 仅构建便携版
npm run dist:portable
```
构建产物在 `release/` 目录。
## 📁 Desktop 项目结构
```
metona-ollama/
├── electron/
│ ├── main.js # 主进程:窗口、菜单、托盘、IPC
│ ├── preload.js # 预加载:contextBridge 安全 API 暴露
│ ├── desktop-bridge.js # 渲染进程桥接:统一 API + 降级处理
│ └── desktop-integration.js # 桌面集成:菜单响应、原生文件选择、快捷键
├── index.html # 入口(已集成桌面脚本)
├── js/ # 原有 Web 应用代码(未修改)
├── css/ # 原有样式(未修改)
├── assets/ # 图标资源
├── package.json # Electron + electron-builder 配置
└── ...
```
## 🔌 架构设计
```
┌──────────────────────────────────────────────┐
│ Main Process │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Browser │ │ Tray │ │ Menu │ │
│ │ Window │ │ │ │ │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌────┴─────────────┴──────────────┴───────┐ │
│ │ IPC Main │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼──────────────────────────┘
│ contextBridge
┌───────────────────┼──────────────────────────┐
│ Renderer │ │
│ ┌────────────────┴───────────────────────┐ │
│ │ window.metonaDesktop (preload.js) │ │
│ └────────────────┬───────────────────────┘ │
│ ┌────────────────┴───────────────────────┐ │
│ │ window.__metonaBridge (bridge.js) │ │
│ │ 统一接口 + 降级 (Web环境可用) │ │
│ └────────────────┬───────────────────────┘ │
│ ┌────────────────┴───────────────────────┐ │
│ │ desktop-integration.js │ │
│ │ 菜单响应 · 原生文件 · 快捷键 │ │
│ └────────────────┬───────────────────────┘ │
│ ┌────────────────┴───────────────────────┐ │
│ │ 原有 Web 应用 (app.js + components) │ │
│ │ 零修改,完全复用 │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
## 🔒 安全策略
- `contextIsolation: true` — 渲染进程无 Node.js 访问
- `nodeIntegration: false` — 通过 preload 安全暴露 API
- `sandbox: false` — 允许文件系统访问(受 IPC 白名单控制)
- `webSecurity: false` — 仅用于连接本地 Ollama API
## 📝 与 Web 版的兼容性
桌面版完全兼容 Web 版代码,通过降级处理实现:
- `window.__metonaBridge.isDesktop` 判断当前环境
- Web 环境下所有 desktop API 自动降级为浏览器原生行为
- `index.html` 无条件加载桌面脚本,非 Electron 环境静默跳过
- 原有 JS/HTML/CSS 代码**零修改**
-2183
View File
File diff suppressed because it is too large Load Diff
-107
View File
@@ -1,107 +0,0 @@
/**
* Metona Ollama Desktop - 渲染进程桥接模块
*
* 在 Web 应用启动前加载,将桌面能力注入到 window 对象
* 提供优雅降级:Web 环境下所有 desktop API 返回 null/空函数
*/
// Desktop API 由 preload.js 通过 contextBridge 注入
// 这里做一层封装,提供统一的降级处理
const desktop = window.metonaDesktop || null;
const bridge = {
/** 是否在 Electron 桌面环境中 */
isDesktop: !!desktop,
/** 应用信息 */
async getInfo() {
if (!desktop) return { platform: 'web', version: '1.0.0' };
return desktop.info();
},
/** 打开原生文件选择对话框 */
async openFile(filters) {
if (!desktop) return null;
return desktop.dialog.openFile(filters);
},
/** 打开原生保存对话框 */
async saveFile(options) {
if (!desktop) return null;
return desktop.dialog.saveFile(options);
},
/** 读取文件内容 */
async readFile(filePath) {
if (!desktop) return { success: false, error: 'Not in desktop' };
return desktop.fs.readFile(filePath);
},
/** 写入文件 */
async writeFile(filePath, content) {
if (!desktop) return { success: false, error: 'Not in desktop' };
return desktop.fs.writeFile(filePath, content);
},
/** 发送系统通知 */
notify(title, body) {
if (desktop) {
desktop.notify(title, body);
} else if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body });
}
},
/** 最小化窗口 */
minimize() {
desktop?.window.minimize();
},
/** 最大化/还原 */
maximize() {
desktop?.window.maximize();
},
/** 关闭窗口 */
close() {
desktop?.window.close();
},
/** 打开外部链接 */
openExternal(url) {
if (desktop) {
desktop.openExternal(url);
} else {
window.open(url, '_blank');
}
},
/** 监听主进程菜单事件 */
onMenuAction(callback) {
desktop?.onMenuAction(callback);
},
/** 监听托盘事件 */
onTrayAction(callback) {
desktop?.onTrayAction(callback);
},
/** 注销监听 */
off(channel) {
desktop?.removeAllListeners(channel);
}
};
// 暴露到全局
window.__metonaBridge = bridge;
// 打印环境信息
if (bridge.isDesktop) {
bridge.getInfo().then(info => {
console.log('[Desktop] Metona Ollama Desktop');
console.log('[Desktop] Platform:', info.platform, info.arch);
console.log('[Desktop] Electron:', info.electronVersion);
console.log('[Desktop] User Data:', info.userDataPath);
});
}
-121
View File
@@ -1,121 +0,0 @@
/**
* Metona Ollama Desktop - 桌面端集成
*
* 在 DOM 加载后执行,为现有 Web 应用添加桌面特性:
* 1. 主进程菜单/托盘事件响应
* 2. 原生文件对话框替换浏览器 input[type=file]
* 3. 桌面端快捷键增强
* 4. 标题栏集成
*/
(function () {
'use strict';
const bridge = window.__metonaBridge;
if (!bridge || !bridge.isDesktop) {
// 非桌面环境,跳过
return;
}
console.log('[Desktop Integration] 初始化桌面集成...');
// ── 1. 菜单/托盘事件响应 ──
bridge.onMenuAction((action) => {
switch (action) {
case 'new-chat':
document.querySelector('#btnNewChat')?.click();
break;
case 'import':
document.querySelector('#btnImportSessions')?.click();
break;
case 'export':
document.querySelector('#btnExportAll')?.click();
break;
case 'help':
document.querySelector('#btnHelp')?.click();
break;
}
});
bridge.onTrayAction((action) => {
switch (action) {
case 'new-chat':
document.querySelector('#btnNewChat')?.click();
break;
}
});
// ── 2. 增强文件上传 - 使用原生文件对话框 ──
function setupNativeFilePicker(btnId, inputId, fileFilters) {
const btn = document.querySelector(btnId);
const input = document.querySelector(inputId);
if (!btn || !input) return;
// 拦截点击事件,使用原生对话框
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const filePaths = await bridge.openFile(fileFilters);
if (!filePaths || filePaths.length === 0) return;
// 读取所有文件
for (const filePath of filePaths) {
const result = await bridge.readFile(filePath);
if (result.success) {
// 创建 File 对象注入到原有流程
const blob = new Blob([result.content], { type: 'text/plain' });
const file = new File([blob], result.name, { type: 'text/plain' });
// 使用 DataTransfer 注入文件到 input,触发 change 事件
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}, true); // capture phase, 优先于原有事件
}
// DOM 就绪后设置
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupEnhancements);
} else {
setupEnhancements();
}
function setupEnhancements() {
// 代码/文本文件上传
setupNativeFilePicker(
'#btnAttachFile',
'#textFileInput',
[
{ name: '代码文件', extensions: ['py', 'js', 'ts', 'tsx', 'java', 'go', 'rs', 'cpp', 'c', 'h', 'rb', 'php', 'sh', 'swift', 'kt', 'lua'] },
{ name: '配置文件', extensions: ['json', 'yaml', 'yml', 'toml', 'xml', 'ini', 'conf'] },
{ name: '文档', extensions: ['txt', 'md', 'log', 'csv', 'html', 'css'] },
{ name: '所有文件', extensions: ['*'] }
]
);
}
// ── 3. 标题更新 - 显示连接/模型状态 ──
const origTitle = document.title;
window.addEventListener('metona:model-changed', (e) => {
if (e.detail?.model) {
document.title = `${e.detail.model} - Metona Ollama`;
} else {
document.title = origTitle;
}
});
// ── 4. 桌面端键盘快捷键 ──
document.addEventListener('keydown', (e) => {
// Ctrl+N 新建会话(与菜单同步)
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
document.querySelector('#btnNewChat')?.click();
}
});
console.log('[Desktop Integration] 桌面集成完成 ✓');
})();
-435
View File
@@ -1,435 +0,0 @@
/**
* Metona Ollama Desktop - 主进程
*
* 职责:
* 1. 应用生命周期管理
* 2. BrowserWindow 创建与配置
* 3. 原生菜单、系统托盘
* 4. IPC 通信桥梁(文件对话框、通知、窗口控制)
* 5. 单实例锁
*/
const { app, BrowserWindow, ipcMain, dialog, Menu, Tray, nativeImage, shell, Notification } = require('electron');
const path = require('path');
const fs = require('fs');
// ── 常量 ──
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;
let mainWindow = null;
let tray = null;
let isQuitting = false;
// ── 单实例锁 ──
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
// ── 创建主窗口 ──
function createMainWindow() {
// 读取上次窗口大小
const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'window-state.json');
let windowState = { width: 1200, height: 800, x: undefined, y: undefined, maximized: false };
try {
if (fs.existsSync(configPath)) {
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch { /* 使用默认值 */ }
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: '#0a0a1a',
show: false, // 等 ready-to-show 再显示,避免闪烁
autoHideMenuBar: true,
menuBarVisible: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
// 关闭同源策略,避免 Ollama CORS 问题
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 = () => {
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
const bounds = mainWindow.getBounds();
try {
fs.writeFileSync(configPath, JSON.stringify(bounds));
} catch { /* 忽略写入错误 */ }
};
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 { /* 忽略 */ }
});
mainWindow.on('unmaximize', () => {
try {
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
state.maximized = false;
fs.writeFileSync(configPath, JSON.stringify(state));
} catch { /* 忽略 */ }
});
// 关闭窗口时:最小化到托盘而非退出
mainWindow.on('close', (e) => {
if (!isQuitting && tray) {
e.preventDefault();
mainWindow.hide();
// 首次最小化到托盘时显示通知
if (!global._trayHintShown) {
global._trayHintShown = true;
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
}
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// 外部链接用系统浏览器打开
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
return mainWindow;
}
// ── 创建系统托盘 ──
function createTray() {
const icon = nativeImage.createFromPath(process.platform === 'win32' ? ICO_PATH : ICON_PATH);
tray = new Tray(icon.resize({ width: 16, height: 16 }));
const contextMenu = Menu.buildFromTemplate([
{
label: '打开 Metona',
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{ type: 'separator' },
{
label: '新建会话',
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send('tray-action', 'new-chat');
}
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true;
app.quit();
}
}
]);
tray.setToolTip(APP_NAME);
tray.setContextMenu(contextMenu);
// 双击托盘图标打开窗口
tray.on('double-click', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
}
// ── 原生菜单 ──
function createMenu() {
const template = [
{
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: () => { 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) => {
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 v1.1.0',
detail: '基于原生 JavaScript 的 Ollama AI 聊天客户端\n桌面版由 Electron 驱动\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH
});
}
}
]
}
];
// 开发环境增加开发者工具菜单
if (IS_DEV) {
template.push({
label: 'Dev',
submenu: [
{
label: '切换开发者工具',
accelerator: 'F12',
click: () => mainWindow?.webContents.toggleDevTools()
}
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
// ── 系统通知 ──
function showNotification(title, body) {
if (Notification.isSupported()) {
new Notification({ title, body, icon: ICON_PATH }).show();
}
}
// ── IPC 处理 ──
function setupIPC() {
// 打开文件对话框
ipcMain.handle('dialog:openFile', async (_, options) => {
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) => {
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) => {
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.message };
}
});
// 写入文件
ipcMain.handle('fs:writeFile', async (_, filePath, content) => {
try {
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
});
// 发送系统通知
ipcMain.handle('notify', (_, title, body) => {
showNotification(title, body);
});
// 获取应用信息
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
electronVersion: process.versions.electron,
userDataPath: 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) => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
shell.openExternal(url);
}
});
}
// ── 应用生命周期 ──
app.whenReady().then(() => {
setupIPC();
createMainWindow();
createTray();
createMenu();
// macOS: 点击 Dock 图标时重新创建窗口
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
});
// 所有窗口关闭时不退出(有托盘)
app.on('window-all-closed', () => {
// macOS 习惯保留
if (process.platform !== 'darwin') {
// 有托盘时不退出
if (!tray) app.quit();
}
});
app.on('before-quit', () => {
isQuitting = true;
});
// 移除默认菜单(Windows 生产环境)
if (process.platform === 'win32' && !IS_DEV) {
app.on('browser-window-created', (_, win) => {
// 保留自定义菜单
});
}
-54
View File
@@ -1,54 +0,0 @@
/**
* Metona Ollama Desktop - Preload 脚本
*
* 通过 contextBridge 安全暴露 IPC 接口给渲染进程
* 所有 API 都是白名单模式,不暴露 Node.js 全局对象
*/
const { contextBridge, ipcRenderer } = require('electron');
// ── 暴露 Desktop API ──
contextBridge.exposeInMainWorld('metonaDesktop', {
// 是否在桌面环境中
isDesktop: true,
// 版本信息
info: () => ipcRenderer.invoke('app:info'),
// ── 文件对话框 ──
dialog: {
openFile: (filters) => ipcRenderer.invoke('dialog:openFile', { filters }),
saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options)
},
// ── 文件系统 ──
fs: {
readFile: (filePath) => ipcRenderer.invoke('fs:readFile', filePath),
writeFile: (filePath, content) => ipcRenderer.invoke('fs:writeFile', filePath, content)
},
// ── 系统通知 ──
notify: (title, body) => ipcRenderer.invoke('notify', title, body),
// ── 窗口控制 ──
window: {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close')
},
// ── 外部链接 ──
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
// ── 主进程事件监听 ──
onMenuAction: (callback) => {
ipcRenderer.on('menu-action', (_, action) => callback(action));
},
onTrayAction: (callback) => {
ipcRenderer.on('tray-action', (_, action) => callback(action));
},
// 移除监听
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
}
});
-456
View File
@@ -1,456 +0,0 @@
<!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="#0a0a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="Metona Ollama Client - 流式聊天、多模态、历史管理">
<link rel="manifest" href="manifest.json">
<link rel="icon" href="assets/icons/llama.ico" type="image/x-icon">
<title>Metona Ollama</title>
<link rel="stylesheet" href="css/style.css">
</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">v1.1.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,.pyw,.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>
<li>右键自定义预设胶囊可编辑/删除</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>
<ul>
<li><strong>服务地址</strong> — 修改 Ollama API 端点</li>
<li><strong>系统提示词</strong> — 开启后定义 AI 的角色和行为</li>
<li><strong>上下文长度</strong> — 调整 <code>num_ctx</code>,值越大记忆越长</li>
<li><strong>温度</strong> — 调节回答随机性(0 精确稳定 ~ 2 最大创意)</li>
<li><strong>释放显存</strong> — 卸载当前模型,释放 GPU 内存</li>
<li><strong>跨域问题</strong> — 启动 Ollama 时设置 <code>OLLAMA_ORIGINS="*"</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">
<p class="text-muted" style="font-size:11px;margin-top:4px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
</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>
<!-- ═══════════════ 桌面桥接(Electron Preload 注入) ═══════════════ -->
<script src="./electron/desktop-bridge.js"></script>
<!-- ═══════════════ 桌面集成扩展 ═══════════════ -->
<script src="./electron/desktop-integration.js"></script>
<!-- ═══════════════ 应用入口(ES Module)═══════════════ -->
<script type="module" src="./js/app.js"></script>
</body>
</html>
-242
View File
@@ -1,242 +0,0 @@
/**
* App - 主入口模块
* 负责初始化所有组件、协调事件流、管理生命周期
*/
import { ChatDB } from './chat-db.js';
import { OllamaAPI } from './ollama-api.js';
import { state, KEYS } from './state.js';
import { generateId } from './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 './preset-manager.js';
import { initVectorStore } from './rag.js';
function initHelpModal() {
const helpModal = document.querySelector('#helpModal');
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';
});
}
console.log('[Metona] 模块化版本 v4.0.3 ' + new Date().toISOString());
// ── 会话管理 ──
function createNewSession() {
const selectedModel = document.querySelector('#modelSelect')?.value || '';
const defaultModel = selectedModel || state.get('_defaultModel', '');
return {
id: generateId(),
title: '新会话',
model: defaultModel,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
}
async function startNewSession() {
// 先保存当前会话(如果有的话)
const db = state.get(KEYS.DB);
const currentSession = state.get(KEYS.CURRENT_SESSION);
if (db && currentSession && currentSession.messages.length > 0) {
currentSession.updatedAt = Date.now();
await db.saveSession(currentSession);
}
// 释放显存,清理模型上下文
const api = state.get(KEYS.API);
const model = currentSession?.model || state.get('_defaultModel', '');
if (api && model) {
try {
await api.chat({ model, messages: [], keep_alive: 0 });
} 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').style.display = 'none';
document.querySelector('#inputArea').style.display = '';
clearMessages();
enableAutoScroll();
renderMessages();
document.querySelector('#chatInput')?.focus();
}
// ── 初始化 ──
async function init() {
let db, api;
try {
// 1. 初始化 IndexedDB
db = new ChatDB();
await db.init();
state.set(KEYS.DB, db);
// 2. 读取保存的设置
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
api = new OllamaAPI(serverUrl);
state.set(KEYS.API, api);
// 3. 创建初始会话
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);
// 4. 初始化所有组件
initToast();
initLightbox();
initHeader();
initModelBar();
initChatArea();
initInputArea();
initSettingsModal();
initHistoryModal();
initKBModal();
initPresetBar();
initPresetModal();
initHelpModal();
// 5. 绑定全局事件
bindGlobalEvents();
// 6. 检测连接 + 加载模型
await checkConnection();
await loadModels();
// 6.1 初始化向量存储
await initVectorStore();
// 6.2 初始化预设管理器(会从 DB 加载并应用上次激活的预设)
await initPresetManager();
// 7. 恢复上次选择的模型
const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) {
setSelectedModel(savedModel);
state.set('_defaultModel', savedModel);
await db.saveSetting('selectedModel', savedModel);
}
// 8. 恢复系统提示词、上下文长度、温度
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').checked = systemPromptEnabled;
document.querySelector('#inputSystemPrompt').disabled = !systemPromptEnabled;
document.querySelector('#inputSystemPrompt').value = systemPrompt;
document.querySelector('#inputNumCtx').value = numCtx;
document.querySelector('#inputTemperature').value = temperature;
document.querySelector('#tempValue').textContent = parseFloat(temperature).toFixed(1);
// 9. 聚焦输入框
document.querySelector('#chatInput')?.focus();
// 10. 注册 Service Worker (PWA)
initServiceWorker();
console.log('[App] 初始化完成');
} catch (err) {
console.error('[App] 初始化失败:', err);
// 即使 DB 失败也尝试运行
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();
initPresetManager();
checkConnection().then(loadModels);
}
}
function bindGlobalEvents() {
// 新建会话
document.querySelector('#btnNewChat')?.addEventListener('click', startNewSession);
// ESC 关闭模态框
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettingsModal();
closeHistoryModal();
closeLightbox();
document.querySelector('#helpModal').style.display = 'none';
document.querySelector('#kbModal').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);
});
}
function initServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.getRegistrations()
.then(regs => regs.forEach(r => r.unregister()))
.then(() => caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k)))))
.then(() => navigator.serviceWorker.register('sw.js?v=3', { updateViaCache: 'none' }))
.then(reg => {
reg.addEventListener('updatefound', () => {
const w = reg.installing;
if (w) w.addEventListener('statechange', () => {
if (w.state === 'activated') window.location.reload();
});
});
reg.update();
})
.catch(err => console.warn('[App] Service Worker 注册失败:', err));
}
// 启动
init();
-193
View File
@@ -1,193 +0,0 @@
/**
* ChatDB - IndexedDB 封装层
*
* 用于持久化聊天历史记录和设置
* 使用 IndexedDB 而非 localStorage,原因:
* 1. 支持存储更大的数据(如 base64 图片)
* 2. 异步操作不阻塞主线程
* 3. 支持事务和索引查询
*/
export class ChatDB {
constructor(dbName = 'metona-ollama', version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
/**
* 初始化数据库
* 创建 object stores 和索引
*/
async init() {
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.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 });
console.log('[ChatDB] 创建 sessions 存储');
}
// 设置存储
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' });
console.log('[ChatDB] 创建 settings 存储');
}
};
});
}
/**
* 确保数据库已连接
*/
_ensureDB() {
if (!this.db) throw new Error('数据库未初始化,请先调用 init()');
}
/**
* 获取事务
*/
_tx(storeName, mode = 'readonly') {
this._ensureDB();
const tx = this.db.transaction(storeName, mode);
return tx.objectStore(storeName);
}
// ═══════════════════════════════════════════════════════════════
// 会话 CRUD
// ═══════════════════════════════════════════════════════════════
/** 保存/更新会话 */
async saveSession(session) {
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) {
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() {
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) {
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() {
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) {
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);
// 先检查每个 ID 是否已存在,再 put
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, endTime) {
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);
});
}
// ═══════════════════════════════════════════════════════════════
// 设置 CRUD
// ═══════════════════════════════════════════════════════════════
/** 保存设置 */
async saveSetting(key, value) {
return new Promise((resolve, reject) => {
const store = this._tx('settings', 'readwrite');
const request = store.put({ key, value });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/** 获取设置 */
async getSetting(key, defaultValue = null) {
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);
});
}
}
-395
View File
@@ -1,395 +0,0 @@
/**
* ChatArea - 聊天区域组件
* 负责消息渲染、流式更新、导出功能
*/
import { state, KEYS } from '../state.js';
import { marked } from '../marked-config.js';
import { escapeHtml, formatTime, downloadFile } from '../utils.js';
/** 根据文件名返回合适的图标 */
function getFileIcon(filename) {
const name = filename.toLowerCase();
if (name === 'dockerfile') return '🐳';
if (name === 'makefile' || name === 'gnumakefile') return '🔨';
const ext = filename.split('.').pop().toLowerCase();
const map = {
py: '🐍', pyw: '🐍', pyi: '🐍',
js: '💛', mjs: '💛', cjs: '💛',
ts: '🔷', tsx: '⚛️', jsx: '⚛️',
java: '☕', go: '🐹', rs: '🦀', rb: '💎',
php: '🐘', swift: '🍎', kt: '🤖', scala: '🔴',
lua: '🌙', pl: '🐪', r: '📐', m: '📐',
cs: '🟪', fs: '🔵', vb: '🔷',
ex: '💧', exs: '💧', erl: '📞', hs: '🟣',
clj: '🟢', lisp: '🟢',
cpp: '⚙️', cc: '⚙️', cxx: '⚙️', hpp: '⚙️',
c: '⚙️', h: '⚙️',
sh: '🐚', bash: '🐚', zsh: '🐚', fish: '🐚',
html: '🌐', htm: '🌐', css: '🎨', svg: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
ini: '📋', cfg: '📋', conf: '📋',
xml: '📰', sql: '🗃️',
md: '📝', markdown: '📝', rst: '📝', txt: '📄',
log: '📜', csv: '📊', tsv: '📊',
diff: '🔀', patch: '🔀',
};
return map[ext] || '📄';
}
let chatAreaEl, messagesContainerEl, emptyStateEl, scrollBtnEl;
let autoScroll = true; // 是否自动滚动到底部
let currentPlaceholder = null; // 当前流式消息的 placeholder 引用
export function initChatArea() {
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' });
});
}
/** 判断是否在底部附近(60px 容差) */
function isAtBottom() {
return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60;
}
/** 安全地将 Markdown 转为 HTML */
export function safeMarkdown(text) {
if (!text && text !== 0) return '';
const str = typeof text === 'string' ? text : String(text);
if (!str) return '';
try {
const result = marked.parse(str);
return typeof result === 'string' ? result : escapeHtml(str);
} catch (e) {
return escapeHtml(str);
}
}
/** 滚动到底部(仅当 autoScroll 开启时) */
export function scrollToBottom() {
if (!autoScroll) return;
requestAnimationFrame(() => {
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
});
}
/** 渲染所有消息(增量渲染) */
export function renderMessages() {
const currentSession = state.get(KEYS.CURRENT_SESSION);
const msgs = currentSession ? currentSession.messages : [];
if (msgs.length === 0) {
emptyStateEl.style.display = '';
messagesContainerEl.style.display = 'none';
return;
}
emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = '';
const existingCount = messagesContainerEl.children.length;
const isNewMessage = existingCount > 0 && existingCount < msgs.length;
for (let i = existingCount; i < msgs.length; i++) {
appendMessageDOM(msgs[i], i);
}
// 新消息按 autoScroll 决定;首次渲染(加载历史)强制滚到底部
if (isNewMessage) {
scrollToBottom();
} else if (msgs.length > 0) {
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
}
}
/** 追加单条消息 DOM */
export function appendMessageDOM(msg, index) {
const div = document.createElement('div');
div.className = `message ${msg.role}`;
div.dataset.index = 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>';
}
// 文件 chip(展示用,不暴露文件内容)
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 || msg.files?.length > 0)) {
contentHtml = contentHtml.replace('<div class="msg-content"></div>', '');
} else if (msg.role === 'user' && msg.files?.length > 0 && msg.content) {
// 有文本也有文件,保留文本内容展示
}
// 统计信息
const stats = [];
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>`;
}
// RAG 知识库来源
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);
}
/** 更新最后一条 assistant 消息(流式输出) */
export function updateLastAssistantMessage(content, think, stats, model) {
// 使用 placeholder 引用,完全不依赖 CSS 选择器
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
// 仅首次调用时移除 loading 指示器
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 = [];
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();
}
/** 追加临时的 assistant 消息占位 */
export function appendAssistantPlaceholder() {
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, tempClass) {
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() {
return messagesContainerEl;
}
/** 清空消息 DOM */
export function clearMessages() {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
}
/** 重置自动滚动(用户发送消息、新建会话时调用) */
export function enableAutoScroll() {
autoScroll = true;
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
}
// ── 导出功能 ──
/** 导出为 Markdown */
export function exportAsMarkdown(session) {
let md = `# ${session.title}\n\n`;
md += `**模型:** ${session.model} \n`;
md += `**时间:** ${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');
}
/** 导出为 HTML */
export function exportAsHtml(session) {
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:#0a0a1a;color:#e8e8f0;}
.user{background:rgba(25,25,60,0.5);padding:12px;border-radius:8px;margin:8px 0;}
.assistant{background:rgba(0,245,212,0.03);padding:12px;border-radius:8px;margin:8px 0;border:1px solid rgba(0,245,212,0.08);}
h1{background:linear-gradient(135deg,#00f5d4,#7b2ff7);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
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 => {
const fileChips = (m.files && m.files.length > 0)
? '<br>' + m.files.map(f => `<span style="background:rgba(123,47,247,0.15);padding:2px 8px;border-radius:4px;font-size:12px;margin:2px;display:inline-block;">📎 ${escapeHtml(f.name)}</span>`).join(' ')
: '';
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong>${fileChips}<br>${(m.content || '').replace(/\n/g, '<br>')}</div>`;
});
html += '</body></html>';
downloadFile(`${session.title}.html`, html, 'text/html');
}
/** 导出为 TXT */
export function exportAsTxt(session) {
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`;
if (m.files && m.files.length > 0) {
txt += m.files.map(f => `📎 ${f.name}`).join(' · ') + '\n';
}
txt += `${m.content || ''}\n\n`;
});
downloadFile(`${session.title}.txt`, txt, 'text/plain');
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Header - 顶部导航组件
* 包含连接状态检测、新建会话按钮
*/
import { state, KEYS } from '../state.js';
import { formatSize } from '../utils.js';
import { OllamaAPI } from '../ollama-api.js';
let connStatusEl;
export function initHeader() {
connStatusEl = document.querySelector('#connStatus');
}
/**
* 检测连接状态并更新 UI
* @returns {Promise<{ok: boolean, version?: object, error?: Error}>}
*/
export async function checkConnection() {
const api = state.get(KEYS.API);
if (!api) return { ok: false, error: new Error('API 未初始化') };
connStatusEl.className = 'conn-status pending';
connStatusEl.querySelector('.status-label').textContent = '连接中...';
try {
const version = await api.getVersion();
connStatusEl.className = 'conn-status connected';
connStatusEl.title = `已连接 (v${version.version})`;
connStatusEl.querySelector('.status-label').textContent = '已连接';
return { ok: true, version };
} catch (err) {
connStatusEl.className = 'conn-status disconnected';
connStatusEl.title = '未连接';
connStatusEl.querySelector('.status-label').textContent = '未连接';
return { ok: false, error: err };
}
}
/**
* 更新设置面板中的连接信息
*/
export async function updateConnectionInfo() {
const api = state.get(KEYS.API);
if (!api) return;
const badge = document.querySelector('#connBadge');
const verEl = document.querySelector('#serverVersion');
const corsHint = document.querySelector('#corsHint');
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.message.includes('Failed to fetch') || err.message.includes('NetworkError') || err.message.includes('CORS')) {
corsHint.style.display = '';
} else {
corsHint.style.display = 'none';
}
}
}
/**
* 更新运行中的模型列表
*/
export async function updateRunningModels() {
const api = state.get(KEYS.API);
if (!api) return;
const container = document.querySelector('#runningModels');
try {
const data = await api.psModels();
if (!data.models || data.models.length === 0) {
container.innerHTML = '<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>';
}
}
-205
View File
@@ -1,205 +0,0 @@
/**
* HistoryModal - 历史记录面板组件
*/
import { state, KEYS } from '../state.js';
import { debounce, escapeHtml, formatTime } from '../utils.js';
import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js';
const HISTORY_PAGE_SIZE = 10;
let historyModalEl, historyListEl, historyPaginationEl;
let historyPage = 1;
let historySearchQuery = '';
export function initHistoryModal() {
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').click();
});
// 搜索
const historySearchInput = document.querySelector('#historySearchInput');
const historySearchClear = document.querySelector('#historySearchClear');
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.closest('[data-id]');
if (!target) return;
const sessionId = target.dataset.id;
const db = state.get(KEYS.DB);
if (target.classList.contains('btn-delete-session')) {
if (confirm('确定删除此会话?')) {
await db.deleteSession(sessionId);
const currentSession = state.get(KEYS.CURRENT_SESSION);
if (currentSession && currentSession.id === sessionId) {
document.querySelector('#btnNewChat').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.closest('.page-btn');
if (!btn || btn.classList.contains('disabled')) return;
const page = parseInt(btn.dataset.page);
if (page && page !== historyPage) {
historyPage = page;
loadHistory();
historyListEl.scrollTop = 0;
}
});
}
export async function loadHistory() {
const db = state.get(KEYS.DB);
if (!db) return;
let allSessions = await db.getAllSessions();
if (historySearchQuery) {
const q = historySearchQuery.toLowerCase();
allSessions = allSessions.filter(s => {
if (s.title?.toLowerCase().includes(q)) return true;
if (s.model?.toLowerCase().includes(q)) return true;
return s.messages.some(m => m.content?.toLowerCase().includes(q));
});
}
allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
if (allSessions.length === 0) {
historyListEl.innerHTML = `
<div class="empty-history">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="rgba(85,85,112,0.5)" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<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>`;
html += '<div class="page-buttons">';
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}"></button>`;
const range = 2;
let pages = [];
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>`;
html += '</div>';
historyPaginationEl.innerHTML = html;
}
async function loadHistorySession(sessionId) {
const db = state.get(KEYS.DB);
if (!db) return;
const session = await db.getSession(sessionId);
if (!session) return;
state.set(KEYS.CURRENT_SESSION, session);
state.set(KEYS.IS_HISTORY_VIEW, true);
document.querySelector('#historyBar').style.display = '';
document.querySelector('#inputArea').style.display = 'none';
clearMessages();
renderMessages();
closeHistoryModal();
}
export function openHistoryModal() {
historyPage = 1;
historySearchQuery = '';
document.querySelector('#historySearchInput').value = '';
document.querySelector('#historySearchClear').style.display = 'none';
loadHistory();
historyModalEl.style.display = '';
}
export function closeHistoryModal() {
historyModalEl.style.display = 'none';
}
-650
View File
@@ -1,650 +0,0 @@
/**
* InputArea - 输入区域组件
* 包含文本输入、图片上传、文件上传、发送逻辑
*/
import { state, KEYS } from '../state.js';
import { fileToBase64, fileToText, detectLanguage, debounce, truncate, escapeHtml, formatSize } from '../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 { checkConnection } from './header.js';
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
let chatInputEl, btnSendEl, fileInputEl, textFileInputEl, imagePreviewEl, filePreviewEl;
let pendingImages = [];
let pendingFiles = [];
/** 常见文本/代码文件扩展名集合(用于校验) */
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'
]);
/** 最大文件大小 500KB */
const MAX_FILE_SIZE = 500 * 1024;
/** 根据文件名返回图标 */
function getFileIcon(filename) {
const name = filename.toLowerCase();
if (name === 'dockerfile') return '🐳';
if (name === 'makefile' || name === 'gnumakefile') return '🔨';
const ext = filename.split('.').pop().toLowerCase();
const map = {
py: '🐍', pyw: '🐍', pyi: '🐍',
js: '💛', mjs: '💛', cjs: '💛',
ts: '🔷', tsx: '⚛️', jsx: '⚛️',
java: '☕', go: '🐹', rs: '🦀', rb: '💎',
php: '🐘', swift: '🍎', kt: '🤖', scala: '🔴',
lua: '🌙', pl: '🐪', r: '📐', m: '📐',
cs: '🟪', fs: '🔵', vb: '🔷',
ex: '💧', exs: '💧', erl: '📞', hs: '🟣',
clj: '🟢', lisp: '🟢',
cpp: '⚙️', cc: '⚙️', cxx: '⚙️', hpp: '⚙️',
c: '⚙️', h: '⚙️',
sh: '🐚', bash: '🐚', zsh: '🐚', fish: '🐚',
html: '🌐', htm: '🌐', css: '🎨', svg: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
ini: '📋', cfg: '📋', conf: '📋',
xml: '📰', sql: '🗃️',
md: '📝', markdown: '📝', rst: '📝', txt: '📄',
log: '📜', csv: '📊', tsv: '📊',
diff: '🔀', patch: '🔀',
};
return map[ext] || '📄';
}
/** 判断是否为文本/代码文件 */
function isTextFile(file) {
// 无扩展名的特殊文件名
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() {
chatInputEl = document.querySelector('#chatInput');
btnSendEl = document.querySelector('#btnSend');
fileInputEl = document.querySelector('#fileInput');
textFileInputEl = document.querySelector('#textFileInput');
imagePreviewEl = document.querySelector('#imagePreview');
filePreviewEl = document.querySelector('#filePreview');
// 发送 / 停止
btnSendEl.addEventListener('click', () => {
if (state.get(KEYS.IS_STREAMING)) {
stopGeneration();
} else {
sendMessage();
}
});
chatInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 输入框自动调整
chatInputEl.addEventListener('input', autoResizeTextarea);
// 图片上传
document.querySelector('#btnAttachImg').addEventListener('click', () => {
if (!isVisionAvailable()) {
showToast('当前模型不支持图片分析', 'warning');
return;
}
fileInputEl.click();
});
fileInputEl.addEventListener('change', (e) => {
handleImageSelect(e.target.files);
e.target.value = '';
});
// 文件上传(文本/代码)
document.querySelector('#btnAttachFile').addEventListener('click', () => textFileInputEl.click());
textFileInputEl.addEventListener('change', (e) => {
handleTextFileSelect(e.target.files);
e.target.value = '';
});
// 删除图片预览
imagePreviewEl.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-img')) {
const idx = parseInt(e.target.dataset.index);
pendingImages.splice(idx, 1);
renderImagePreviews();
}
});
// 删除文件预览
filePreviewEl.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-file')) {
const idx = parseInt(e.target.dataset.index);
pendingFiles.splice(idx, 1);
renderFilePreviews();
}
});
}
function autoResizeTextarea() {
chatInputEl.style.height = 'auto';
chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px';
}
// ── 图片处理 ──
async function handleImageSelect(files) {
const maxSize = 20 * 1024 * 1024;
for (const file of 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() {
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) {
for (const file of 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() {
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('');
}
// ── UI 状态 ──
function updateSendButton(streaming) {
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() {
const abortController = state.get(KEYS.ABORT_CONTROLLER);
if (abortController) {
abortController.abort();
}
}
/**
* 核心发送逻辑
*/
/** 构建发送给 Ollama API 的消息数组:将文件内容拼入 content */
function buildApiMessages(messages) {
return messages.map(m => {
let content = m.content || '';
// 如果有文件内容,拼接到 API 消息的 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() {
const text = chatInputEl.value.trim();
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
if (state.get(KEYS.IS_STREAMING)) return;
const model = getSelectedModel();
if (!model) {
appendSystemMessage('⚠️ 请先选择一个模型');
return;
}
// 有图片但模型不支持 vision → 阻止发送
if (pendingImages.length > 0 && !isVisionAvailable()) {
appendSystemMessage('⚠️ 当前模型不支持图片分析,请移除图片后重试');
return;
}
const currentSession = state.get(KEYS.CURRENT_SESSION);
if (!currentSession) return;
const now = Date.now();
const msgsToAdd = [];
// ── 构造用户消息:展示用(content=纯文本,files=元数据) ──
const userFiles = pendingFiles.map(f => ({
name: f.name,
language: f.language,
size: f.size
}));
// 场景1:有图片
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
});
}
// 场景2:有文本输入 + 文件,或者只有文件
if (text || pendingFiles.length > 0) {
const msg = {
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);
}
// 更新会话标题 + 追加消息(通过 update 确保引用变化,触发浅比较)
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, session => ({
...session,
...(isFirstMsg && {
title: truncate(
text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'),
30
),
model
}),
messages: [...session.messages, ...msgsToAdd],
updatedAt: Date.now()
}));
// update() 产生新引用,后续必须从 state 读取最新值
const freshSession = state.get(KEYS.CURRENT_SESSION);
enableAutoScroll();
renderMessages();
// 立即保存
await saveCurrentSession();
// 清空输入
chatInputEl.value = '';
pendingImages = [];
pendingFiles = [];
imagePreviewEl.style.display = 'none';
imagePreviewEl.innerHTML = '';
filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = '';
autoResizeTextarea();
// 助手占位
appendAssistantPlaceholder();
// 流式回复
state.set(KEYS.IS_STREAMING, true);
updateSendButton(true);
const api = state.get(KEYS.API);
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
const thinkMode = isThinkEnabled();
const systemPromptEnabled = state.get(KEYS.SYSTEM_PROMPT_ENABLED);
const systemPrompt = state.get(KEYS.SYSTEM_PROMPT);
const numCtx = state.get(KEYS.NUM_CTX, 24576);
let assistantContent = '';
let thinkContent = '';
let finalStats = null;
let modelName = '';
try {
const apiMessages = buildApiMessages(freshSession.messages);
const temperature = state.get('temperature', 0.7);
const chatParams = {
model,
messages: apiMessages,
think: thinkMode,
...(systemPromptEnabled && systemPrompt && { system: systemPrompt }),
...(numCtx && { options: { num_ctx: numCtx, temperature } })
};
// RAG 检索增强
let ragSources = null;
if (isRagEnabled()) {
appendSystemMessage('🧠 正在检索知识库...', 'rag-status');
try {
const ragResult = await performRagRetrieval(text);
if (ragResult && ragResult.results && ragResult.results.length > 0) {
// 计算 RAG 上下文可用预算
const ctxLimit = numCtx || 24576;
const outputReserve = 2048; // 预留给模型输出
// 估算已有 token 数(粗略:1 token ≈ 2 字符)
const msgChars = apiMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0);
const systemChars = (chatParams.system?.length || 0);
const usedTokens = Math.ceil((msgChars + systemChars) / 2);
const ragBudget = Math.max(0, ctxLimit - outputReserve - usedTokens);
const ragBudgetChars = ragBudget * 2; // 转换为字符数
// 构建 RAG prompt,按预算截取上下文
let ragContext = ragResult.results.map((r, i) =>
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
).join('\n\n---\n\n');
const ragTemplate = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
=== 检索到的相关内容 ===
=== 内容结束 ===
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
const templateOverhead = ragTemplate.length; // 模板本身占用的字符数
if (ragContext.length + templateOverhead > ragBudgetChars) {
ragContext = ragContext.slice(0, Math.max(0, ragBudgetChars - templateOverhead - 50)) + '\n\n[知识库内容过长,已截取最相关的部分]';
appendSystemMessage(`⚠️ 知识库内容已截取(预算 ${ragBudgetChars} 字符)`, 'rag-status');
}
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
=== 检索到的相关内容 ===
${ragContext}
=== 内容结束 ===
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
chatParams.system = chatParams.system
? chatParams.system + '\n\n' + ragPrompt
: ragPrompt;
ragSources = ragResult.results.map(r => ({
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.message}`, 'rag-status');
}
}
console.log('[RAG] numCtx:', numCtx, 'system长度:', chatParams.system?.length || 0);
console.log('[RAG] 开始调用 chatStream...');
let ragStatusCleared = false;
await api.chatStream(chatParams, (chunk) => {
// 首个有效内容到达时才清除 RAG 状态,避免检索完到开始回复之间的空白
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 || null);
if (chunk.done) {
finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration };
}
}, abortController);
// 如果流结束但没有任何内容
if (!assistantContent && !thinkContent) {
appendSystemMessage('⚠️ 模型未返回任何内容,可能是上下文过长或模型不支持当前请求');
}
const assistantMsg = {
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 => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
}));
if (finalStats) {
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null);
}
// 流结束后插入 RAG 来源到现有 DOMrenderMessages 不会重新渲染已有消息)
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);
// 清除 RAG 临时状态消息
document.querySelectorAll('.rag-status').forEach(el => el.remove());
if (err.name === 'AbortError') {
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading');
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 = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
...(modelName && { model: modelName }),
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, session => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
if (contentDiv) {
let html = '';
if (assistantContent) {
html = 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');
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) {
const partialMsg = {
role: 'assistant',
content: assistantContent + '\n\n`[已中断]`',
timestamp: Date.now(),
};
state.update(KEYS.CURRENT_SESSION, session => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
await saveCurrentSession();
} else {
if (placeholder) placeholder.remove();
}
let errMsg = `❌ 错误: ${err.message}`;
console.error('[InputArea] 完整错误:', err);
if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
} else if (err.message.includes('400')) {
errMsg = '❌ 请求参数错误,可能是知识库内容超出模型上下文限制';
} else if (err.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() {
const db = state.get(KEYS.DB);
const currentSession = state.get(KEYS.CURRENT_SESSION);
const isHistoryView = state.get(KEYS.IS_HISTORY_VIEW);
if (!currentSession || !db || isHistoryView) return;
currentSession.updatedAt = Date.now();
try {
await db.saveSession(currentSession);
} catch (err) {
console.error('[InputArea] 保存会话失败:', err);
}
}
-346
View File
@@ -1,346 +0,0 @@
/**
* KBModal - 知识库管理面板
*
* 支持:创建/删除集合、上传/删除文档、选择嵌入模型、启用 RAG 检索
*/
import { state, KEYS } from '../state.js';
import {
initVectorStore, getVectorStore, addDocumentToKB,
removeDocumentFromKB, getDocumentsInCollection, retrieveContext,
buildRagSystemPrompt
} from '../rag.js';
import { fileToText, formatSize, escapeHtml, truncate } from '../utils.js';
import { showToast } from './toast.js';
let kbModalEl;
let currentColId = null;
/** 当前是否启用 RAG */
let ragEnabled = false;
let ragCollectionId = null;
export function initKBModal() {
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').click();
});
document.querySelector('#kbFileInput').addEventListener('change', handleDocUpload);
// RAG 开关
document.querySelector('#toggleRAG').addEventListener('change', (e) => {
ragEnabled = e.target.checked;
if (ragEnabled && currentColId) {
ragCollectionId = currentColId;
showToast('RAG 知识检索已开启', 'success');
} else if (ragEnabled && !currentColId) {
e.target.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.value;
await vs.updateCollection(col);
}
});
}
// ── RAG 接口 ──
export function isRagEnabled() {
return ragEnabled && !!ragCollectionId;
}
export function getRagCollectionId() {
return ragCollectionId;
}
export async function performRagRetrieval(query) {
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;
}
}
// ── UI ──
function updateRagBadge() {
const badge = document.querySelector('#badgeRAG');
if (!badge) return;
badge.style.display = ragEnabled ? '' : 'none';
}
async function openKBModal() {
kbModalEl.style.display = '';
await refreshCollections();
await populateEmbedModels();
}
function closeKBModal() {
kbModalEl.style.display = 'none';
}
/**
* 刷新集合列表
*/
async function refreshCollections() {
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.closest('.kb-col-delete')) return;
currentColId = el.dataset.id;
await refreshCollections();
await refreshDocList();
// 设置嵌入模型下拉
const vs = getVectorStore();
const col = await vs.getCollection(currentColId);
if (col?.embeddingModel) {
document.querySelector('#selectEmbedModel').value = col.embeddingModel;
}
});
});
// 删除集合
listEl.querySelectorAll('.kb-col-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.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').checked = false;
updateRagBadge();
}
showToast('集合已删除', 'success');
await refreshCollections();
await refreshDocList();
});
});
}
/**
* 刷新文档列表
*/
async function refreshDocList() {
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.dataset.docid;
if (!confirm('确定删除此文档?')) return;
await removeDocumentFromKB(currentColId, docId);
showToast('文档已删除', 'success');
await refreshCollections();
await refreshDocList();
});
});
}
/**
* 创建新集合
*/
async function createCollection() {
const nameInput = document.querySelector('#inputKBName');
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) {
const fileArr = Array.from(e.target.files || []);
e.target.value = '';
if (fileArr.length === 0) return;
const embedModel = document.querySelector('#selectEmbedModel').value;
if (!embedModel) {
showToast('请先选择一个嵌入模型', 'warning');
return;
}
const progressEl = document.querySelector('#kbProgress');
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.message}`, 'error');
failCount++;
}
}
progressEl.style.display = 'none';
if (fileArr.length > 1) {
showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount}`, failCount > 0 ? 'warning' : 'success');
}
await refreshCollections();
await refreshDocList();
}
/**
* 填充嵌入模型下拉(通过 /api/show 检测 embedding 能力)
*/
async function populateEmbedModels() {
const select = document.querySelector('#selectEmbedModel');
const api = state.get(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;
}
// 并发检测每个模型的 capabilities
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.value);
embedModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
const size = m.size ? ` (${formatSize(m.size)})` : '';
opt.textContent = m.name + size;
select.appendChild(opt);
});
if (embedModels.length === 0) {
select.innerHTML = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
}
} catch (err) {
console.warn('[KB] 加载模型列表失败:', err);
}
}
-33
View File
@@ -1,33 +0,0 @@
/**
* Lightbox - 图片预览组件
*/
let lightboxEl, lightboxImg;
export function initLightbox() {
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) => {
if (e.target.dataset.lightbox === 'true') {
openLightbox(e.target.src);
}
});
}
export function openLightbox(src) {
lightboxImg.src = src;
lightboxEl.style.display = '';
}
export function closeLightbox() {
lightboxEl.style.display = 'none';
lightboxImg.src = '';
}
-230
View File
@@ -1,230 +0,0 @@
/**
* ModelBar - 模型选择栏组件
* 包含模型下拉选择、Think 能力检测、模型信息展示
*/
import { state, KEYS } from '../state.js';
import { formatSize } from '../utils.js';
let modelSelectEl, badgeThinkEl, badgeVisionEl, thinkCheckbox, thinkWrap, btnAttachImg;
/** 缓存模型能力 { modelName: { think: bool, vision: bool } } */
const modelCapabilityCache = new Map();
export function initModelBar() {
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;
if (db()) await db().saveSetting('selectedModel', model);
state.set('_defaultModel', model);
state.update(KEYS.CURRENT_SESSION, session => session ? ({ ...session, model }) : session);
if (model) {
await checkModelCapability(model);
} else {
setThinkAvailable(false);
setVisionAvailable(false);
badgeThinkEl.style.display = 'none';
badgeVisionEl.style.display = 'none';
}
});
}
function db() { return state.get(KEYS.DB); }
function api() { return state.get(KEYS.API); }
/**
* 加载模型列表
*/
export async function loadModels() {
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);
const label = diskSize ? `${m.name} · ${diskSize}` : m.name;
opt.textContent = label;
modelSelectEl.appendChild(opt);
});
// 恢复默认选中模型
const defaultModel = state.get('_defaultModel', '');
if (defaultModel) {
modelSelectEl.value = defaultModel;
}
// 检测当前选中模型的能力
if (modelSelectEl.value) {
await checkModelCapability(modelSelectEl.value);
}
// 后台过滤:移除不支持 completion 的嵌入模型
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);
}
}
/**
* 后台检测并过滤嵌入模型(不支持 completion 的模型)
*/
async function filterEmbedModels(models) {
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,
});
// 从下拉框移除不支持 completion 的模型
if (!isCompletion) {
const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
if (opt) opt.remove();
}
} catch { /* 忽略检测失败的模型 */ }
}
// 如果当前选中的模型被移除了,选中第一个可用模型
if (!modelSelectEl.value && modelSelectEl.options.length > 0) {
modelSelectEl.value = modelSelectEl.options[0].value;
state.set('_defaultModel', modelSelectEl.value);
if (db()) await db().saveSetting('selectedModel', modelSelectEl.value);
await checkModelCapability(modelSelectEl.value);
}
}
/**
* 调用 /api/show 检测模型能力
* @param {string} modelName
*/
async function checkModelCapability(modelName) {
// 命中缓存
if (modelCapabilityCache.has(modelName)) {
applyCapability(modelName, modelCapabilityCache.get(modelName));
return;
}
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);
// 降级:不支持 think
const fallback = { think: false, vision: false, completion: true };
modelCapabilityCache.set(modelName, fallback);
applyCapability(modelName, fallback);
}
}
/**
* 根据能力设置 UI 状态
*/
function applyCapability(modelName, caps) {
setThinkAvailable(caps.think);
setVisionAvailable(caps.vision);
// 更新能力标签
badgeThinkEl.style.display = caps.think ? '' : 'none';
badgeVisionEl.style.display = caps.vision ? '' : 'none';
}
/**
* 设置 Think 开关是否可用
*/
function setThinkAvailable(available) {
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) {
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() {
return btnAttachImg && !btnAttachImg.disabled;
}
/**
* 获取当前选择的模型
*/
export function getSelectedModel() {
return modelSelectEl.value;
}
/**
* 设置选中的模型
*/
export function setSelectedModel(modelName) {
modelSelectEl.value = modelName;
modelSelectEl.dispatchEvent(new Event('change'));
}
/**
* Think 模式是否开启
*/
export function isThinkEnabled() {
return thinkCheckbox.checked && !thinkCheckbox.disabled;
}
-212
View File
@@ -1,212 +0,0 @@
/**
* PresetBar - 预设栏组件
*
* 在模型栏下方显示预设胶囊按钮列表,点击即切换
* 右侧"+"按钮打开管理面板
*/
import { state, KEYS } from '../state.js';
import {
getPresets, getActivePresetId, activatePreset,
createPreset, updatePreset, deletePreset
} from '../preset-manager.js';
import { showToast } from './toast.js';
let presetBarEl;
let presetListEl;
let editingPresetId = null;
export function initPresetBar() {
// 创建预设栏 DOM
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);
// 监听预设变化,刷新 UI
state.on('activePresetId', renderPresetBar);
state.on('presets', renderPresetBar);
// 初始渲染
renderPresetBar();
}
/**
* 渲染预设胶囊列表
*/
function renderPresetBar() {
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) {
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) {
const modal = document.querySelector('#presetModal');
const titleEl = modal.querySelector('#presetModalTitle');
const nameInput = modal.querySelector('#inputPresetName');
const iconInput = modal.querySelector('#inputPresetIcon');
const spInput = modal.querySelector('#inputPresetSystemPrompt');
const tempInput = modal.querySelector('#inputPresetTemp');
const tempVal = modal.querySelector('#presetTempValue');
const ctxInput = modal.querySelector('#inputPresetNumCtx');
const thinkCheck = modal.querySelector('#togglePresetThink');
const btnSave = modal.querySelector('#btnSavePreset');
const btnDelete = modal.querySelector('#btnDeletePreset');
if (editPreset && editPreset.id) {
// 编辑模式
editingPresetId = editPreset.id;
titleEl.textContent = '✏️ 编辑预设';
nameInput.value = editPreset.name;
iconInput.value = editPreset.icon;
spInput.value = editPreset.systemPrompt;
tempInput.value = editPreset.temperature ?? 0.7;
tempVal.textContent = (editPreset.temperature ?? 0.7).toFixed(1);
ctxInput.value = 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() {
const modal = document.querySelector('#presetModal');
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').value.trim();
if (!name) {
showToast('请输入预设名称', 'warning');
return;
}
const data = {
name,
icon: modal.querySelector('#inputPresetIcon').value.trim() || '🤖',
systemPrompt: modal.querySelector('#inputPresetSystemPrompt').value.trim(),
temperature: parseFloat(modal.querySelector('#inputPresetTemp').value) || 0.7,
numCtx: parseInt(modal.querySelector('#inputPresetNumCtx').value) || 24576,
think: modal.querySelector('#togglePresetThink').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;
}
});
}
function escapeHtml(str) {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return String(str).replace(/[&<>"']/g, c => map[c]);
}
-189
View File
@@ -1,189 +0,0 @@
/**
* SettingsModal - 设置面板组件
*/
import { state, KEYS } from '../state.js';
import { debounce, formatSize } from '../utils.js';
import { encryptData, decryptData, isMetonaFile } from '../crypto.js';
import { updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels } from './model-bar.js';
import { showToast } from './toast.js';
import { OllamaAPI } from '../ollama-api.js';
let settingsModalEl;
export function initSettingsModal() {
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').value.trim();
if (!url) return;
const db = state.get(KEYS.DB);
const api = new OllamaAPI(url);
state.set(KEYS.API, api);
if (db) await db.saveSetting('serverUrl', url);
updateConnectionInfo();
loadModels();
}, 500);
document.querySelector('#inputServerUrl').addEventListener('input', saveServerUrl);
// 系统提示词开关
document.querySelector('#toggleSystemPrompt').addEventListener('change', async (e) => {
const db = state.get(KEYS.DB);
state.set(KEYS.SYSTEM_PROMPT_ENABLED, e.target.checked);
document.querySelector('#inputSystemPrompt').disabled = !e.target.checked;
if (db) await db.saveSetting('systemPromptEnabled', e.target.checked);
});
// 保存系统提示词
const saveSystemPrompt = debounce(async () => {
const db = state.get(KEYS.DB);
const val = document.querySelector('#inputSystemPrompt').value.trim();
state.set(KEYS.SYSTEM_PROMPT, val);
if (db) await db.saveSetting('systemPrompt', val);
}, 500);
document.querySelector('#inputSystemPrompt').addEventListener('input', saveSystemPrompt);
// 保存上下文长度
const saveNumCtx = debounce(async () => {
const db = state.get(KEYS.DB);
const val = document.querySelector('#inputNumCtx').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');
const tempDisplay = document.querySelector('#tempValue');
tempSlider.addEventListener('input', () => {
const val = parseFloat(tempSlider.value);
tempDisplay.textContent = val.toFixed(1);
});
const saveTemperature = debounce(async () => {
const db = state.get(KEYS.DB);
const val = parseFloat(tempSlider.value);
state.set('temperature', val);
if (db) await db.saveSetting('temperature', val);
}, 300);
tempSlider.addEventListener('change', saveTemperature);
// 释放显存
document.querySelector('#btnReleaseVRAM').addEventListener('click', async () => {
const api = state.get(KEYS.API);
const model = document.querySelector('#modelSelect').value;
try {
await api.chat({ model: model || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success');
updateRunningModels();
} catch (err) {
showToast(`释放失败: ${err.message}`, 'error');
}
});
// 清空所有历史
document.querySelector('#btnClearAllHistory').addEventListener('click', async () => {
const db = state.get(KEYS.DB);
if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
await db.clearAll();
// 触发新会话
document.querySelector('#btnNewChat').click();
showToast('已清空所有历史记录', 'success');
}
});
// 导出全部会话
document.querySelector('#btnExportAll').addEventListener('click', exportAllSessions);
// 导入会话
const importFileInput = document.querySelector('#importFileInput');
document.querySelector('#btnImportSessions').addEventListener('click', () => importFileInput.click());
importFileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
importSessions(e.target.files[0]);
e.target.value = '';
}
});
}
export function openSettingsModal() {
settingsModalEl.style.display = '';
document.querySelector('#inputServerUrl').value = state.get(KEYS.API)?.baseUrl || '';
updateConnectionInfo();
updateRunningModels();
}
export function closeSettingsModal() {
settingsModalEl.style.display = 'none';
}
async function exportAllSessions() {
const db = state.get(KEYS.DB);
if (!db) return;
const sessions = await db.getAllSessions();
if (sessions.length === 0) {
showToast('没有可导出的会话', 'warning');
return;
}
const backup = {
app: 'Metona Ollama Client',
version: 1,
exportedAt: new Date().toISOString(),
count: sessions.length,
sessions
};
try {
const blob = await encryptData(backup);
const ts = new Date().toISOString().slice(0, 10);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `metona-backup-${ts}.metona`;
a.click();
showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
} catch (err) {
console.error('[Settings] 导出失败:', err);
showToast(`导出失败: ${err.message}`, 'error');
}
}
async function importSessions(file) {
const db = state.get(KEYS.DB);
if (!db) return;
if (!isMetonaFile(file)) {
showToast('仅支持导入 .metona 格式文件', 'error');
return;
}
try {
const buffer = await file.arrayBuffer();
const data = await decryptData(buffer);
let sessions;
if (Array.isArray(data)) {
sessions = data;
} else if (data.sessions && Array.isArray(data.sessions)) {
sessions = data.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.message}`, 'error');
}
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Toast - 通知组件
*/
let toastContainer = null;
export function initToast() {
toastContainer = document.querySelector('#toastContainer');
}
/**
* 显示 Toast 通知
* @param {string} text - 通知文本
* @param {'info'|'success'|'warning'|'error'} type - 通知类型
* @param {number} duration - 显示时长 (ms)
*/
export function showToast(text, type = 'info', duration = 3000) {
if (!toastContainer) return;
const iconMap = { 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);
}
-205
View File
@@ -1,205 +0,0 @@
/**
* Crypto - Metona 会话加密模块
*
* 文件格式: [8B magic "METONA1\0"][16B salt][12B IV][AES-256-GCM ciphertext]
* 后缀: .metona
* 安全上下文(HTTPS)下使用 AES-256-GCM,否则降级为 XOR 混淆
*/
const MAGIC = new TextEncoder().encode('METONA1\0');
const PASSPHRASE = 'metona-ollama-v2';
const PBKDF2_ITERATIONS = 100000;
const SECURE = !!(globalThis.crypto && globalThis.crypto.subtle);
/**
* SHA-256 哈希 (降级模式用)
*/
async function sha256(data) {
if (SECURE) {
const buf = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(buf);
}
// 纯 JS SHA-256
return sha256js(data);
}
/**
* 纯 JS SHA-256 实现 (兼容非安全上下文)
*/
function sha256js(message) {
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;
// 预处理: padding
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;
}
/**
* 从 passhprase + salt 派生密钥 (PBKDF2 或降级多次哈希)
*/
async function deriveKeyBytes(salt) {
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;
}
/**
* XOR 加密/解密 (降级模式)
*/
function xorBytes(data, keyBytes) {
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
out[i] = data[i] ^ keyBytes[i % keyBytes.length];
}
return out;
}
/**
* 加密 JSON 数据 → .metona 二进制格式
*/
export async function encryptData(data) {
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;
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 {
// 降级: XOR 混淆,前 4 字节存原始长度
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);
}
// 标记加密模式: SECURE=0x01, FALLBACK=0x00
const flag = new Uint8Array([SECURE ? 1 : 0]);
return new Blob([MAGIC, salt, iv, flag, payload]);
}
/**
* 解密 .metona 文件 → JSON 对象
*/
export async function decryptData(buffer) {
const data = new Uint8Array(buffer);
// 校验 magic
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;
if (mode === 1) {
// AES-GCM
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 {
// XOR 降级
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));
}
/**
* 检查文件是否为 .metona 格式
*/
export function isMetonaFile(file) {
return file.name.endsWith('.metona');
}
-105
View File
@@ -1,105 +0,0 @@
/**
* DocumentProcessor - 文档分块处理
*/
/**
* 将文本切分为语义块
* @param {string} text - 原始文本
* @param {object} options
* @param {number} options.maxChunkSize - 每块最大字符数 (默认 1500)
* @param {number} options.overlap - 块间重叠字符数 (默认 200)
* @returns {string[]} 分块后的文本数组
*/
export function chunkText(text, { maxChunkSize = 1500, overlap = 200 } = {}) {
if (!text || text.trim().length === 0) return [];
// 先按段落分割
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
const chunks = [];
let current = '';
for (const para of paragraphs) {
const trimmed = para.trim();
if (!trimmed) continue;
// 单段落超过 maxChunkSize → 按句子分割
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());
// 保留末尾 overlap
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());
// 保留末尾 overlap
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) {
// 中文句号、问号、感叹号 + 英文句号等
const parts = text.split(/(?<=[。!?.!?])\s*/);
return parts.filter(p => p.trim()).map(p => p.trim() + ' ');
}
/**
* 从文件内容提取文档信息
* @param {string} content - 文件内容
* @param {string} filename - 文件名
* @returns {{ text: string, filename: string }}
*/
export function extractDocument(content, filename) {
return {
text: content,
filename
};
}
/**
* 生成文档块的元数据
*/
export function createChunkMetadata(chunk, index, docId, filename) {
return {
id: `${docId}_chunk_${index}`,
docId,
filename,
chunkIndex: index,
text: chunk,
charCount: chunk.length
};
}
-2580
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
/**
* Markdown 渲染器配置
* 封装 marked.js,配置自定义渲染器和安全策略
*/
import { marked } from './lib/marked.esm.js';
import { SAFE_URI_SCHEMES } from './sanitizer.js';
// 配置 marked
marked.setOptions({
breaks: true, // 换行转 <br>
gfm: true, // GitHub 风格 Markdown
});
// 自定义渲染器:链接新窗口打开 + 安全属性
const renderer = new marked.Renderer();
renderer.link = function ({ href, title, text }) {
const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol);
const t = title ? ` title="${title}"` : '';
if (!safe) {
return `<span class="blocked-link" title="已阻止不安全链接">${text}</span>`;
}
return `<a href="${href}"${t} target="_blank" rel="noopener noreferrer">${text}</a>`;
};
renderer.image = function ({ href, title, text }) {
const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol);
if (!safe && !/^data:image\//i.test(href)) {
return `<span class="blocked-link">[已阻止不安全图片: ${text}]</span>`;
}
const t = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${t} loading="lazy">`;
};
marked.use({ renderer });
export { marked };
-185
View File
@@ -1,185 +0,0 @@
/**
* Ollama API 客户端封装
*
* 基于 Fetch API 与 Ollama REST 接口交互
* 支持:流式聊天、模型管理、连接检测
*
* API 文档:https://docs.ollama.com/api
*/
export class OllamaAPI {
constructor(baseUrl = 'http://127.0.0.1:11434') {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
/** 通用请求方法 */
async _request(path, options = {}) {
const url = `${this.baseUrl}${path}`;
const config = {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers }
};
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;
}
/** JSON 请求快捷方法 */
async _json(path, body = null) {
const options = body ? { method: 'POST', body: JSON.stringify(body) } : {};
const response = await this._request(path, options);
return response.json();
}
// ── 模型管理 ──
/** 获取已安装模型列表 */
async listModels() { return this._json('/api/tags'); }
/** 获取运行中的模型列表 */
async psModels() { return this._json('/api/ps'); }
/** 获取 Ollama 版本 */
async getVersion() { return this._json('/api/version'); }
/** 查看模型详情 */
async showModel(model) { return this._json('/api/show', { model }); }
// ── 聊天 API ──
/** 非流式聊天 */
async chat(params) {
const body = {
model: params.model,
messages: params.messages,
stream: false,
...(params.think !== undefined && { think: params.think }),
...(params.system && { system: params.system }),
...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }),
...(params.options && { options: params.options })
};
return this._json('/api/chat', body);
}
/**
* 流式聊天
* 使用 ReadableStream + TextDecoder 解析 NDJSON 流
* Ollama 流式响应格式 (NDJSON):
* {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false}
*
* @param {AbortController} [abortController] - 可选,用于强制终止流
*/
async chatStream(params, onChunk, abortController) {
const body = {
model: params.model,
messages: params.messages,
stream: true,
...(params.think !== undefined && { think: params.think }),
...(params.system && { system: params.system }),
...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }),
...(params.options && { options: params.options })
};
const fetchOptions = {
method: 'POST',
body: JSON.stringify(body)
};
// 注入 abort 信号,同时中止 fetch 和 reader
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 = '';
// 如果外部触发 abort,立即取消 reader(双重保险:fetch abort + reader.cancel
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 = JSON.parse(line);
if (onChunk) onChunk(chunk);
if (chunk.done) {
reader.cancel();
return;
}
} catch (err) {
console.warn('[OllamaAPI] 流式数据解析警告:', err.message);
}
}
}
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer);
if (onChunk) onChunk(chunk);
} catch { /* 忽略 */ }
}
}
/** 流式生成(单轮) */
async generateStream(params, onChunk) {
const body = {
model: params.model,
prompt: params.prompt,
stream: true,
...(params.images && { images: params.images }),
...(params.options && { options: params.options })
};
const response = await this._request('/api/generate', {
method: 'POST',
body: JSON.stringify(body)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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 = JSON.parse(line);
if (onChunk) onChunk(chunk);
if (chunk.done) {
reader.cancel();
return;
}
} catch { /* 忽略不完整数据 */ }
}
}
}
/** 生成嵌入向量 */
async embed(model, input) {
return this._json('/api/embed', { model, input });
}
}
-319
View File
@@ -1,319 +0,0 @@
/**
* PresetManager - Agent 预设管理
*
* 预设数据结构:
* {
* id: string,
* name: string, // 预设名称,如 "翻译官"
* icon: string, // emoji 图标
* systemPrompt: string, // 系统提示词
* temperature: number, // 温度 (0~2)
* numCtx: number, // 上下文长度
* think: boolean, // Think 开关
* createdAt: number,
* updatedAt: number,
* }
*/
import { state, KEYS } from './state.js';
import { generateId } from './utils.js';
const PRESETS_KEY = 'agentPresets';
const ACTIVE_PRESET_KEY = 'activePresetId';
// ── 内置预设 ──
const BUILT_IN_PRESETS = [
{
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. **鼓励探索** — 提供延伸阅读方向和思考题\n\n当用户提问时:\n- 先评估用户当前理解水平\n- 用引导性问题帮助思考\n- 如果用户确实卡住,再给出答案并解释思路\n- 每个概念学完后给出一个小练习',
temperature: 0.6,
numCtx: 24576,
think: true,
builtIn: true,
createdAt: 0,
updatedAt: 0
}
];
let presets = [];
let activePresetId = null;
/**
* 初始化预设管理器
*/
export async function initPresetManager() {
const db = state.get(KEYS.DB);
// 从 DB 加载用户自定义预设
let userPresets = [];
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
state.set('presets', presets);
state.set('activePresetId', activePresetId);
// 应用当前预设(恢复设置)
const activePreset = presets.find(p => p.id === activePresetId);
if (activePreset && activePreset.id !== '_default') {
applyPresetToState(activePreset, false); // 不再 DB,已在 init 阶段加载
}
}
/**
* 获取所有预设
*/
export function getPresets() {
return presets;
}
/**
* 获取当前激活预设
*/
export function getActivePreset() {
return presets.find(p => p.id === activePresetId) || presets[0];
}
/**
* 获取当前激活预设 ID
*/
export function getActivePresetId() {
return activePresetId;
}
/**
* 激活预设
*/
export async function activatePreset(presetId) {
const preset = presets.find(p => p.id === presetId);
if (!preset) return;
activePresetId = presetId;
state.set('activePresetId', presetId);
applyPresetToState(preset, true);
const db = state.get(KEYS.DB);
if (db) {
await db.saveSetting(ACTIVE_PRESET_KEY, presetId);
}
}
/**
* 将预设配置应用到全局 state
*/
function applyPresetToState(preset, saveToDb) {
const db = state.get(KEYS.DB);
// 系统提示词
if (preset.systemPrompt) {
state.set(KEYS.SYSTEM_PROMPT_ENABLED, true);
state.set(KEYS.SYSTEM_PROMPT, preset.systemPrompt);
} else {
state.set(KEYS.SYSTEM_PROMPT_ENABLED, false);
state.set(KEYS.SYSTEM_PROMPT, '');
}
// 上下文长度
if (preset.numCtx) {
state.set(KEYS.NUM_CTX, preset.numCtx);
}
// 温度(存储到 state,聊天时使用)
state.set('temperature', preset.temperature ?? 0.7);
// Think 开关
state.set('thinkEnabled', preset.think ?? false);
// 同步到 UI
syncPresetToUI(preset);
// 持久化
if (saveToDb && db) {
db.saveSetting('systemPromptEnabled', preset.systemPrompt ? true : false);
db.saveSetting('systemPrompt', preset.systemPrompt || '');
db.saveSetting('numCtx', preset.numCtx || 24576);
db.saveSetting('temperature', preset.temperature ?? 0.7);
}
}
/**
* 将预设配置同步到设置面板 UI
*/
function syncPresetToUI(preset) {
// 系统提示词
const toggleSP = document.querySelector('#toggleSystemPrompt');
const inputSP = document.querySelector('#inputSystemPrompt');
if (toggleSP) toggleSP.checked = !!preset.systemPrompt;
if (inputSP) {
inputSP.disabled = !preset.systemPrompt;
inputSP.value = preset.systemPrompt || '';
}
// numCtx
const inputCtx = document.querySelector('#inputNumCtx');
if (inputCtx) inputCtx.value = preset.numCtx || 24576;
// Temperature
const tempSlider = document.querySelector('#inputTemperature');
const tempDisplay = document.querySelector('#tempValue');
if (tempSlider) tempSlider.value = preset.temperature ?? 0.7;
if (tempDisplay) tempDisplay.textContent = (preset.temperature ?? 0.7).toFixed(1);
// Think toggle — 仅在模型支持时才设为 checked,否则保持 unchecked
const thinkCheckbox = document.querySelector('#toggleThink');
if (thinkCheckbox) {
const modelSupportsThink = !thinkCheckbox.disabled;
thinkCheckbox.checked = modelSupportsThink && (preset.think ?? false);
}
}
/**
* 创建预设
*/
export async function createPreset({ name, icon, systemPrompt, temperature, numCtx, think }) {
const preset = {
id: `preset_${generateId()}`,
name: name || '新预设',
icon: icon || '🤖',
systemPrompt: systemPrompt || '',
temperature: temperature ?? 0.7,
numCtx: numCtx ?? 24576,
think: think ?? false,
createdAt: Date.now(),
updatedAt: Date.now()
};
presets.push(preset);
state.set('presets', [...presets]);
await saveUserPresets();
return preset;
}
/**
* 更新预设
*/
export async function updatePreset(presetId, updates) {
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) {
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();
}
/**
* 保存用户自定义预设到 IndexedDB
*/
async function saveUserPresets() {
const db = state.get(KEYS.DB);
if (!db) return;
const userPresets = presets
.filter(p => !p.builtIn)
.map(({ builtIn, ...rest }) => rest); // 去掉 builtIn 标记
await db.saveSetting(PRESETS_KEY, userPresets);
}
-202
View File
@@ -1,202 +0,0 @@
/**
* RAG - 检索增强生成管线
*
* 查询 → 嵌入 → 向量检索 → 构造上下文 → 注入 prompt
*/
import { state, KEYS } from './state.js';
import { VectorStore } from './vector-store.js';
import { chunkText, createChunkMetadata } from './document-processor.js';
import { showToast } from './components/toast.js';
let vectorStore = null;
/**
* 初始化向量存储
*/
export async function initVectorStore() {
if (vectorStore) return vectorStore;
vectorStore = new VectorStore();
await vectorStore.init();
return vectorStore;
}
/**
* 获取向量存储实例
*/
export function getVectorStore() {
return vectorStore;
}
/**
* 使用 Ollama 生成文本嵌入向量
* @param {string} text
* @param {string} model - 嵌入模型名称
* @returns {number[]}
*/
export async function embedText(text, model) {
const api = state.get(KEYS.API);
if (!api) throw new Error('Ollama API 未连接');
const result = await api.embed(model, text);
// /api/embed 返回 { embedding: [...] } 或 { embeddings: [[...]] }
if (result.embedding) return result.embedding;
if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
throw new Error('嵌入向量返回格式异常');
}
/**
* 批量嵌入(分批处理,避免超时)
* @param {string[]} texts
* @param {string} model
* @param {function} onProgress - (done, total) => void
* @returns {number[][]}
*/
export async function embedBatch(texts, model, onProgress) {
const results = [];
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;
}
/**
* 向知识库集合添加文档
* @param {string} colId - 集合 ID
* @param {string} filename - 文件名
* @param {string} content - 文件内容
* @param {string} embedModel - 嵌入模型
* @param {function} onProgress
*/
export async function addDocumentToKB(colId, filename, content, embedModel, onProgress) {
const vs = await initVectorStore();
// 1. 分块
const textChunks = chunkText(content);
if (textChunks.length === 0) throw new Error('文档内容为空,无法处理');
// 2. 生成文档 ID
const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
// 3. 生成嵌入向量
if (onProgress) onProgress(0, textChunks.length, '正在生成向量...');
const embeddings = await embedBatch(textChunks, embedModel, (done, total) => {
if (onProgress) onProgress(done, total, '正在生成向量...');
});
// 4. 构造向量数据
const vectors = textChunks.map((chunk, i) => {
const meta = createChunkMetadata(chunk, i, docId, filename);
return {
...meta,
collectionId: colId,
embedding: embeddings[i]
};
});
// 5. 存储
if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...');
await vs.addVectors(vectors);
// 6. 更新集合统计
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, docId) {
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) {
const vs = await initVectorStore();
const vectors = await vs.getVectorsByCollection(colId);
const docMap = new Map();
for (const v of vectors) {
if (!docMap.has(v.docId)) {
docMap.set(v.docId, {
docId: v.docId,
filename: v.filename,
chunkCount: 0
});
}
docMap.get(v.docId).chunkCount++;
}
return Array.from(docMap.values());
}
/**
* RAG 检索:查询 → 嵌入 → 相似度搜索 → 返回上下文文本
* @param {string} query - 用户查询
* @param {string} colId - 集合 ID
* @param {number} topK - 返回最相关的 K 条
* @returns {{ context: string, results: Array }}
*/
export async function retrieveContext(query, colId, topK = 5) {
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 };
}
/**
* 构造 RAG 增强的系统提示词
*/
export function buildRagSystemPrompt(context, originalSystemPrompt = '') {
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
=== 检索到的相关内容 ===
${context}
=== 内容结束 ===
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
if (originalSystemPrompt) {
return originalSystemPrompt + '\n\n' + ragPrompt;
}
return ragPrompt;
}
-88
View File
@@ -1,88 +0,0 @@
/**
* Sanitizer - HTML 净化器
* 基于白名单策略,防御 XSS 攻击
*/
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:'];
/**
* 净化 HTML 字符串,移除危险标签和属性
* @param {string} html - 原始 HTML
* @returns {string} 净化后的 HTML
*/
export function sanitize(html) {
try {
const template = document.createElement('template');
template.innerHTML = html;
const root = template.content;
// 删除注释节点
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
const comments = [];
while (walker.nextNode()) comments.push(walker.currentNode);
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;
}
// URI 协议检查
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;
}
}
-205
View File
@@ -1,205 +0,0 @@
/**
* State - 轻量级响应式状态管理
*
* 优化点:
* 1. 结构化浅比较:对象按 key 数量 + 逐 key 严格比较,数组按长度 + 逐元素
* 2. update():函数式更新,自动比较
* 3. setIn():路径式嵌套更新,沿途浅拷贝确保引用变化
* 4. batch():批量更新,合并通知
*/
// ── 结构化浅比较 ──
function shallowEqual(a, b) {
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;
// 对象:key 数量 + 逐 key
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false;
}
return true;
}
class AppState {
constructor() {
this._state = {};
this._listeners = new Map();
this._batching = false;
this._batchQueue = null;
}
/**
* 设置状态值,使用结构化浅比较判断变更
*/
set(key, value) {
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);
}
}
}
/**
* 函数式更新:基于当前值计算新值,自动比较
*
* state.update('currentSession', session => ({
* ...session,
* messages: [...session.messages, newMsg],
* updatedAt: Date.now()
* }));
*/
update(key, updater) {
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);
}
}
}
/**
* 路径式嵌套更新:按 path 更新嵌套属性,沿途浅拷贝确保引用变化
*
* state.setIn('currentSession', ['messages', 0, 'content'], '新内容');
*/
setIn(key, path, value) {
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] : { ...old };
let current = newRoot;
for (let i = 0; i < path.length - 1; i++) {
const segment = path[i];
const next = current[segment];
if (next == null || typeof next !== 'object') {
const isIndex = typeof path[i + 1] === 'number';
current[segment] = isIndex ? [] : {};
} else {
current[segment] = Array.isArray(next) ? [...next] : { ...next };
}
current = current[segment];
}
current[path[path.length - 1]] = value;
this._state[key] = newRoot;
if (!shallowEqual(old, newRoot)) {
if (this._batching) {
this._batchQueue.add(key);
} else {
this._notify(key, newRoot, old);
}
}
}
/**
* 批量更新:合并多次 set 为一次通知
*
* state.batch(() => {
* state.set('isStreaming', false);
* state.update('currentSession', s => ({ ...s, updatedAt: Date.now() }));
* });
*/
batch(fn) {
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);
}
}
}
_notify(key, value, old) {
if (!this._listeners.has(key)) return;
for (const cb of this._listeners.get(key)) {
try { cb(value, old); } catch (e) {
console.error(`[State] 监听器错误 (${key}):`, e);
}
}
}
/**
* 获取状态值
*/
get(key, defaultValue = undefined) {
return key in this._state ? this._state[key] : defaultValue;
}
/**
* 订阅状态变更
* @returns {function} 取消订阅函数
*/
on(key, callback) {
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) {
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',
};
-114
View File
@@ -1,114 +0,0 @@
/**
* Utils - 通用工具函数
*/
/** 生成唯一 ID */
export function generateId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/** 格式化时间戳 */
export function formatTime(ts) {
const d = new Date(ts);
const pad = n => 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, len = 50) {
if (!str) return '';
return str.length > len ? str.substring(0, len) + '...' : str;
}
/** 防抖 */
export function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
/** 格式化字节大小 */
export function formatSize(bytes) {
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) {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return String(str).replace(/[&<>"']/g, (c) => map[c]);
}
/** 下载文件 */
export function downloadFile(filename, content, type) {
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) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/** File → 文本内容 */
export function fileToText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
export function detectLanguage(filename) {
const ext = filename.split('.').pop().toLowerCase();
const map = {
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;
}
-449
View File
@@ -1,449 +0,0 @@
/**
* VectorStore - 向量存储与相似度检索
*
* 优化:IVF 倒排索引
* - 小数据集 (N < MIN_IVF_SIZE):自动降级为暴力搜索
* - 大数据集:K-Means 聚类 + nprobe 近邻搜索
* - 索引在 addVectors() 后自动构建,持久化到 IndexedDB
*/
const MIN_IVF_SIZE = 200; // 低于此数量不建索引,直接暴力搜
const DEFAULT_K = 20; // 聚类中心数上限
const DEFAULT_NPROBE = 5; // 搜索时探测的聚类数(覆盖 ~25% 数据)
const KMEANS_ITERS = 10; // K-Means 迭代次数
export class VectorStore {
constructor(dbName = 'metona-ollama-vectors') {
this.dbName = dbName;
this.db = null;
// 内存索引缓存 { colId → { centroids, invertedLists, version } }
this._indexCache = new Map();
// 向量内存缓存 { colId → Map<vecId, vectorData> }
this._vectorCache = new Map();
}
async init() {
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.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' });
}
};
});
}
_tx(store, mode = 'readonly') {
return this.db.transaction(store, mode).objectStore(store);
}
// ═══════════════════════════════════════════
// 集合管理
// ═══════════════════════════════════════════
async createCollection(name, embeddingModel = '') {
const col = {
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() {
return new Promise((resolve, reject) => {
const req = this._tx('collections').getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async getCollection(id) {
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) {
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) {
const vectors = await this.getVectorsByCollection(colId);
await new Promise((resolve, reject) => {
const tx = this.db.transaction(['vectors', 'collections', 'indexes'], 'readwrite');
const vStore = tx.objectStore('vectors');
for (const v of vectors) vStore.delete(v.id);
tx.objectStore('collections').delete(colId);
tx.objectStore('indexes').delete(colId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
this._indexCache.delete(colId);
this._vectorCache.delete(colId);
}
// ═══════════════════════════════════════════
// 向量操作
// ═══════════════════════════════════════════
async addVectors(items) {
if (items.length === 0) return;
await new Promise((resolve, reject) => {
const tx = this.db.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const item of items) store.put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
// 更新内存缓存
const colId = items[0].collectionId;
if (!this._vectorCache.has(colId)) {
this._vectorCache.set(colId, new Map());
}
const cache = this._vectorCache.get(colId);
for (const item of items) cache.set(item.id, item);
// 标记索引需重建
this._indexCache.delete(colId);
}
async getVectorsByCollection(colId) {
if (this._vectorCache.has(colId)) {
return Array.from(this._vectorCache.get(colId).values());
}
const vectors = await new Promise((resolve, reject) => {
const idx = this._tx('vectors').index('collectionId');
const req = idx.getAll(colId);
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const cache = new Map();
for (const v of vectors) cache.set(v.id, v);
this._vectorCache.set(colId, cache);
return vectors;
}
async deleteVectorsByCollection(colId) {
const vectors = await this.getVectorsByCollection(colId);
await new Promise((resolve, reject) => {
const tx = this.db.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const v of vectors) store.delete(v.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
this._indexCache.delete(colId);
this._vectorCache.delete(colId);
}
async deleteVectorsByDocument(colId, docId) {
const vectors = await this.getVectorsByCollection(colId);
const docVectors = vectors.filter(v => v.docId === docId);
await new Promise((resolve, reject) => {
const tx = this.db.transaction('vectors', 'readwrite');
const store = tx.objectStore('vectors');
for (const v of docVectors) store.delete(v.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
if (this._vectorCache.has(colId)) {
const cache = this._vectorCache.get(colId);
for (const v of docVectors) cache.delete(v.id);
}
this._indexCache.delete(colId);
}
// ═══════════════════════════════════════════
// 搜索:自动选择暴力 / IVF
// ═══════════════════════════════════════════
async search(colId, queryEmbedding, topK = 5) {
const vectors = await this.getVectorsByCollection(colId);
if (vectors.length === 0) return [];
// 小数据集 → 暴力搜索
if (vectors.length < MIN_IVF_SIZE) {
return this._bruteForceSearch(vectors, queryEmbedding, topK);
}
// 大数据集 → IVF 搜索
const index = await this._getIndex(colId, vectors);
if (!index) {
return this._bruteForceSearch(vectors, queryEmbedding, topK);
}
return this._ivfSearch(vectors, index, queryEmbedding, topK);
}
_bruteForceSearch(vectors, queryEmbedding, topK) {
return vectors
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
_ivfSearch(vectors, index, queryEmbedding, topK) {
const { centroids, invertedLists } = index;
// 1. 找最近的 nprobe 个聚类中心
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);
// 2. 收集候选向量 ID
const candidateIds = new Set();
for (const { id: clusterId } of targetClusters) {
const list = invertedLists.get(clusterId);
if (list) for (const id of list) candidateIds.add(id);
}
// 3. 候选向量构建快速查找(避免逐个 find)
const vectorMap = new Map();
for (const v of vectors) vectorMap.set(v.id, v);
const candidates = [];
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);
}
// ═══════════════════════════════════════════
// IVF 索引
// ═══════════════════════════════════════════
async _getIndex(colId, vectors) {
// 内存缓存
if (this._indexCache.has(colId)) {
const cached = this._indexCache.get(colId);
if (cached.version === this._indexVersion(vectors)) {
return cached;
}
}
// IndexedDB 缓存
const saved = await new Promise((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)) {
// 反序列化 invertedListsIndexedDB 不存 Map,存的是对象)
if (!(saved.invertedLists instanceof Map)) {
saved.invertedLists = new Map(Object.entries(saved.invertedLists || {}));
}
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);
// 持久化(Map → 普通对象)
const toSave = {
collectionId: index.collectionId,
version: index.version,
centroids: index.centroids,
invertedLists: Object.fromEntries(index.invertedLists)
};
await new Promise((resolve, reject) => {
const req = this._tx('indexes', 'readwrite').put(toSave);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
this._indexCache.set(colId, index);
return index;
}
_indexVersion(vectors) {
if (vectors.length === 0) return '0';
return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`;
}
/**
* K-Means 聚类构建 IVF 索引
*/
_buildIVFIndex(vectors) {
const N = vectors.length;
const dim = vectors[0].embedding.length;
const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10)));
// 1. K-Means++ 初始化
const centroids = this._kmeansPPInit(vectors, K, dim);
// 2. K-Means 迭代
const assignments = new Array(N);
for (let iter = 0; iter < KMEANS_ITERS; iter++) {
// E-step
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;
}
// M-step
const newCentroids = Array.from({ length: K }, () => new Array(dim).fill(0));
const counts = new Array(K).fill(0);
for (let i = 0; i < N; i++) {
const k = assignments[i];
counts[k]++;
const emb = vectors[i].embedding;
for (let d = 0; d < dim; d++) {
newCentroids[k][d] += emb[d];
}
}
for (let k = 0; k < K; k++) {
if (counts[k] > 0) {
for (let d = 0; d < dim; d++) {
newCentroids[k][d] /= counts[k];
}
this._normalize(newCentroids[k]);
} else {
const randIdx = Math.floor(Math.random() * N);
newCentroids[k] = [...vectors[randIdx].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;
}
// 3. 构建倒排链表
const invertedLists = new Map();
for (let i = 0; i < N; i++) {
const k = assignments[i];
if (!invertedLists.has(k)) invertedLists.set(k, []);
invertedLists.get(k).push(vectors[i].id);
}
return { centroids, invertedLists };
}
/**
* K-Means++ 初始化
*/
_kmeansPPInit(vectors, K, dim) {
const centroids = [];
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;
}
/** 归一化向量(in-place */
_normalize(vec) {
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, b) {
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;
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "Metona Ollama Client",
"short_name": "Ollama",
"description": "基于原生 JavaScript 的 Ollama AI 客户端 - 流式聊天、多模态、历史管理",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#0a0a1a",
"theme_color": "#0a0a1a",
"orientation": "any",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦙</text></svg>",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"categories": ["productivity", "utilities"]
}
+1860 -57
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -6,13 +6,16 @@
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite && electron .", "dev:renderer": "vite build --watch",
"build": "tsc && vite build", "dev:main": "tsc -p tsconfig.main.json --watch",
"start": "electron . --no-sandbox", "build:renderer": "vite build",
"pack": "electron-builder --dir", "build:main": "tsc -p tsconfig.main.json",
"dist": "electron-builder --win", "build": "npm run build:renderer && npm run build:main",
"dist:nsis": "electron-builder --win nsis", "start": "npm run build && electron . --no-sandbox",
"dist:portable": "electron-builder --win portable" "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder --win",
"dist:nsis": "npm run build && electron-builder --win nsis",
"dist:portable": "npm run build && electron-builder --win portable"
}, },
"build": { "build": {
"appId": "com.metona.ollama-desktop", "appId": "com.metona.ollama-desktop",
+11 -2
View File
@@ -18,6 +18,10 @@ const IS_DEV = !app.isPackaged;
export let mainWindow: BrowserWindow | null = null; export let mainWindow: BrowserWindow | null = null;
export let isQuitting = false; export let isQuitting = false;
export function setQuitting(): void {
isQuitting = true;
}
export function getIconPath(): string { export function getIconPath(): string {
return process.platform === 'win32' ? ICO_PATH : ICON_PATH; return process.platform === 'win32' ? ICO_PATH : ICON_PATH;
} }
@@ -45,7 +49,6 @@ function createMainWindow(): BrowserWindow {
backgroundColor: '#202020', backgroundColor: '#202020',
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
menuBarVisible: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
@@ -56,7 +59,13 @@ function createMainWindow(): BrowserWindow {
} }
}); });
mainWindow.loadFile(path.join(__dirname, '..', '..', 'index.html')); // 开发模式:从 src/renderer/ 加载;生产模式:从 dist/renderer/ 加载
const isDev = !app.isPackaged;
if (isDev) {
mainWindow.loadFile(path.join(__dirname, '..', '..', 'src', 'renderer', 'index.html'));
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
}
mainWindow.setMenuBarVisibility(false); mainWindow.setMenuBarVisibility(false);
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
+4 -6
View File
@@ -2,8 +2,8 @@
* Metona Ollama Desktop - 原生菜单 * Metona Ollama Desktop - 原生菜单
*/ */
import { Menu, dialog, shell } from 'electron'; import { Menu, dialog, shell, app } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js'; import { mainWindow, setQuitting, getIconPath } from './main.js';
export function createMenu(): void { export function createMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [ const template: Electron.MenuItemConstructorOptions[] = [
@@ -30,8 +30,7 @@ export function createMenu(): void {
label: '退出', label: '退出',
accelerator: 'Ctrl+Q', accelerator: 'Ctrl+Q',
click: () => { click: () => {
const { app } = require('electron'); setQuitting();
(require('./main.js') as Record<string, unknown>).isQuitting = true;
app.quit(); app.quit();
} }
} }
@@ -112,8 +111,7 @@ export function createMenu(): void {
} }
]; ];
const IS_DEV = !require('electron').app.isPackaged; if (!app.isPackaged) {
if (IS_DEV) {
template.push({ template.push({
label: 'Dev', label: 'Dev',
submenu: [ submenu: [
+4 -5
View File
@@ -2,8 +2,8 @@
* Metona Ollama Desktop - 系统托盘 * Metona Ollama Desktop - 系统托盘
*/ */
import { Tray, Menu, nativeImage } from 'electron'; import { Tray, Menu, nativeImage, app } from 'electron';
import { mainWindow, isQuitting, getIconPath } from './main.js'; import { mainWindow, setQuitting, getIconPath } from './main.js';
let tray: Tray | null = null; let tray: Tray | null = null;
@@ -32,9 +32,8 @@ export function createTray(): void {
{ {
label: '退出', label: '退出',
click: () => { click: () => {
const main = require('./main.js'); setQuitting();
main.isQuitting = true; app.quit();
require('electron').app.quit();
} }
} }
]); ]);
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <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="theme-color" content="#202020">
<meta name="description" content="Metona Ollama Desktop - TypeScript + Electron AI 聊天客户端"> <meta name="description" content="Metona Ollama Desktop - TypeScript + Electron AI 聊天客户端">
<link rel="icon" href="../assets/icons/llama.ico" type="image/x-icon"> <link rel="icon" href="/assets/icons/llama.ico" type="image/x-icon">
<title>Metona Ollama</title> <title>Metona Ollama</title>
</head> </head>
<body> <body>
+1 -2
View File
@@ -3,8 +3,7 @@
* 负责初始化所有组件、协调事件流、管理生命周期 * 负责初始化所有组件、协调事件流、管理生命周期
*/ */
import '../styles/style.css'; import './styles/style.css';
import './types.js';
import { ChatDB } from './db/chat-db.js'; import { ChatDB } from './db/chat-db.js';
import { OllamaAPI } from './api/ollama.js'; import { OllamaAPI } from './api/ollama.js';
-63
View File
@@ -1,63 +0,0 @@
/**
* Service Worker - PWA 离线支持
* 缓存应用资源以便离线使用
*/
const CACHE_NAME = 'metona-ollama-v3';
const ASSETS = [
'./',
'./index.html',
'./css/style.css',
'./js/chat-db.js',
'./js/lib/marked.esm.js',
'./js/ollama-api.js',
'./manifest.json'
];
// 安装阶段:预缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
.then(() => self.skipWaiting())
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// 拦截请求:缓存优先,网络回退
self.addEventListener('fetch', (event) => {
// 不拦截非 GET 请求
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request)
.then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
// 仅缓存成功的响应
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => {
// 离线且无缓存时返回简单提示
if (event.request.destination === 'document') {
return new Response('离线模式:请检查网络连接', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
});
})
);
});
+3 -10
View File
@@ -13,18 +13,11 @@
"noUnusedParameters": false, "noUnusedParameters": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true, "sourceMap": true,
"outDir": "dist", "outDir": "dist/renderer",
"rootDir": "src", "rootDir": "src/renderer",
"baseUrl": ".",
"paths": {
"@main/*": ["src/main/*"],
"@renderer/*": ["src/renderer/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"] "lib": ["ES2022", "DOM", "DOM.Iterable"]
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts"], "include": ["src/renderer/**/*.ts", "src/renderer/**/*.d.ts"],
"exclude": ["node_modules", "dist", "release"] "exclude": ["node_modules", "dist", "release"]
} }
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "dist/main",
"rootDir": "src/main",
"sourceMap": true,
"declaration": false
},
"include": ["src/main/**/*.ts"],
"exclude": ["node_modules", "dist", "release"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html'),
},
},
css: {
preprocessorOptions: {},
},
server: {
port: 5173,
},
});