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:
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 2:npm install 超时
|
|
||||||
|
|
||||||
**现象**:使用默认 npm registry 下载依赖超时。
|
|
||||||
|
|
||||||
**解决**:切换到 npmmirror 国内镜像:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm config set registry https://registry.npmmirror.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 3:Electron 下载超时(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 之前直接超时。
|
|
||||||
|
|
||||||
### 问题 4:winCodeSign 下载慢
|
|
||||||
|
|
||||||
**现象**: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 | 小文件可等待 / 手动缓存 | 通常可完成 |
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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] 桌面集成完成 ✓');
|
|
||||||
})();
|
|
||||||
@@ -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) => {
|
|
||||||
// 保留自定义菜单
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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 来源到现有 DOM(renderMessages 不会重新渲染已有消息)
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = '';
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
||||||
return String(str).replace(/[&<>"']/g, c => map[c]);
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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)) {
|
|
||||||
// 反序列化 invertedLists(IndexedDB 不存 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
Generated
+1860
-57
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -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
@@ -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
@@ -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
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user