feat: TypeScript + Electron v2 重构 - 纯桌面版
- 全面迁移到 TypeScript,严格类型定义 - 放弃 Web 版,专注 Electron 桌面应用 - 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts - 渲染进程完整迁移所有功能组件 - 删除 PWA 相关文件 (sw.js, manifest.json) - 删除 Web 版降级逻辑 - 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理 - 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Crypto - Metona 会话加密模块
|
||||
*/
|
||||
|
||||
const MAGIC = new TextEncoder().encode('METONA1\0');
|
||||
const PASSPHRASE = 'metona-ollama-v2';
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
const SECURE = !!(globalThis.crypto && globalThis.crypto.subtle);
|
||||
|
||||
async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
if (SECURE) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
return sha256js(data);
|
||||
}
|
||||
|
||||
function sha256js(message: Uint8Array): Uint8Array {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
]);
|
||||
|
||||
const bytes = message instanceof Uint8Array ? message : new Uint8Array(message);
|
||||
const bitLen = bytes.length * 8;
|
||||
const paddedLen = Math.ceil((bytes.length + 9) / 64) * 64;
|
||||
const padded = new Uint8Array(paddedLen);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(paddedLen - 4, bitLen, false);
|
||||
|
||||
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
|
||||
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
|
||||
|
||||
const w = new Uint32Array(64);
|
||||
|
||||
for (let offset = 0; offset < paddedLen; offset += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = (w[i - 15] >>> 7 | w[i - 15] << 25) ^ (w[i - 15] >>> 18 | w[i - 15] << 14) ^ (w[i - 15] >>> 3);
|
||||
const s1 = (w[i - 2] >>> 17 | w[i - 2] << 15) ^ (w[i - 2] >>> 19 | w[i - 2] << 13) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0;
|
||||
const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (S0 + maj) >>> 0;
|
||||
h = g; g = f; f = e;
|
||||
e = (d + temp1) >>> 0;
|
||||
d = c; c = b; b = a;
|
||||
a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
|
||||
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0;
|
||||
}
|
||||
|
||||
const result = new Uint8Array(32);
|
||||
const rv = new DataView(result.buffer);
|
||||
rv.setUint32(0, h0, false); rv.setUint32(4, h1, false);
|
||||
rv.setUint32(8, h2, false); rv.setUint32(12, h3, false);
|
||||
rv.setUint32(16, h4, false); rv.setUint32(20, h5, false);
|
||||
rv.setUint32(24, h6, false); rv.setUint32(28, h7, false);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function deriveKeyBytes(salt: Uint8Array): Promise<Uint8Array> {
|
||||
const passBytes = new TextEncoder().encode(PASSPHRASE);
|
||||
const input = new Uint8Array(passBytes.length + salt.length);
|
||||
input.set(passBytes);
|
||||
input.set(salt, passBytes.length);
|
||||
|
||||
if (SECURE) {
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
return new Uint8Array(raw);
|
||||
}
|
||||
|
||||
let key = input;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
key = await sha256(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function xorBytes(data: Uint8Array, keyBytes: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
out[i] = data[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function encryptData(data: unknown): Promise<Blob> {
|
||||
const json = new TextEncoder().encode(JSON.stringify(data));
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const keyBytes = await deriveKeyBytes(salt);
|
||||
|
||||
let payload: Uint8Array;
|
||||
if (SECURE) {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json);
|
||||
payload = new Uint8Array(ciphertext);
|
||||
} else {
|
||||
const lenBytes = new Uint32Array([json.length]);
|
||||
const withLen = new Uint8Array(4 + json.length);
|
||||
withLen.set(new Uint8Array(lenBytes.buffer));
|
||||
withLen.set(json, 4);
|
||||
payload = xorBytes(withLen, keyBytes);
|
||||
}
|
||||
|
||||
const flag = new Uint8Array([SECURE ? 1 : 0]);
|
||||
return new Blob([MAGIC, salt, iv, flag, payload]);
|
||||
}
|
||||
|
||||
export async function decryptData(buffer: ArrayBuffer): Promise<unknown> {
|
||||
const data = new Uint8Array(buffer);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (data[i] !== MAGIC[i]) throw new Error('不是有效的 .metona 文件');
|
||||
}
|
||||
|
||||
const salt = data.slice(8, 24);
|
||||
const iv = data.slice(24, 36);
|
||||
const mode = data[36];
|
||||
const payload = data.slice(37);
|
||||
const keyBytes = await deriveKeyBytes(salt);
|
||||
|
||||
let jsonBytes: Uint8Array;
|
||||
if (mode === 1) {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload);
|
||||
jsonBytes = new Uint8Array(decrypted);
|
||||
} else {
|
||||
const withLen = xorBytes(payload, keyBytes);
|
||||
const len = new DataView(withLen.buffer).getUint32(0, true);
|
||||
jsonBytes = withLen.slice(4, 4 + len);
|
||||
}
|
||||
|
||||
return JSON.parse(new TextDecoder().decode(jsonBytes));
|
||||
}
|
||||
|
||||
export function isMetonaFile(file: File): boolean {
|
||||
return file.name.endsWith('.metona');
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* DocumentProcessor - 文档分块处理
|
||||
*/
|
||||
|
||||
export function chunkText(text: string, { maxChunkSize = 1500, overlap = 200 } = {}): string[] {
|
||||
if (!text || text.trim().length === 0) return [];
|
||||
|
||||
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
|
||||
const chunks: string[] = [];
|
||||
let current = '';
|
||||
|
||||
for (const para of paragraphs) {
|
||||
const trimmed = para.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.length > maxChunkSize) {
|
||||
if (current) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
const sentences = splitSentences(trimmed);
|
||||
let sentenceBuf = '';
|
||||
for (const sent of sentences) {
|
||||
if ((sentenceBuf + sent).length > maxChunkSize && sentenceBuf) {
|
||||
chunks.push(sentenceBuf.trim());
|
||||
sentenceBuf = overlap > 0 ? sentenceBuf.slice(-overlap) + sent : sent;
|
||||
} else {
|
||||
sentenceBuf += sent;
|
||||
}
|
||||
}
|
||||
if (sentenceBuf.trim()) {
|
||||
current = sentenceBuf;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((current + '\n\n' + trimmed).length > maxChunkSize && current) {
|
||||
chunks.push(current.trim());
|
||||
if (overlap > 0 && current.length > overlap) {
|
||||
current = current.slice(-overlap) + '\n\n' + trimmed;
|
||||
} else {
|
||||
current = trimmed;
|
||||
}
|
||||
} else {
|
||||
current = current ? current + '\n\n' + trimmed : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
chunks.push(current.trim());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
const parts = text.split(/(?<=[。!?.!?])\s*/);
|
||||
return parts.filter(p => p.trim()).map(p => p.trim() + ' ');
|
||||
}
|
||||
|
||||
export function extractDocument(content: string, filename: string): { text: string; filename: string } {
|
||||
return { text: content, filename };
|
||||
}
|
||||
|
||||
export function createChunkMetadata(chunk: string, index: number, docId: string, filename: string) {
|
||||
return {
|
||||
id: `${docId}_chunk_${index}`,
|
||||
docId,
|
||||
filename,
|
||||
chunkIndex: index,
|
||||
text: chunk,
|
||||
charCount: chunk.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* PresetManager - Agent 预设管理
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { generateId } from '../utils/utils.js';
|
||||
import type { Preset, ChatDB } from '../types.js';
|
||||
|
||||
const PRESETS_KEY = 'agentPresets';
|
||||
const ACTIVE_PRESET_KEY = 'activePresetId';
|
||||
|
||||
const BUILT_IN_PRESETS: Preset[] = [
|
||||
{ id: '_default', name: '默认', icon: '💬', systemPrompt: '', temperature: 0.7, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
|
||||
{ id: '_translator', name: '翻译官', icon: '🌐', systemPrompt: '你是一位专业翻译官。你的任务是将用户输入的内容准确翻译为目标语言。\n\n规则:\n1. 自动识别源语言,翻译为用户指定的目标语言(如未指定则翻译为中文)\n2. 保持原文的格式、语气和风格\n3. 专有名词保留原文并给出通用译名\n4. 对于有多种含义的词语,结合上下文选择最合适的翻译\n5. 直接输出翻译结果,不需要额外解释(除非用户要求)', temperature: 0.3, numCtx: 16384, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
|
||||
{ id: '_code_review', name: '代码审查', icon: '🔍', systemPrompt: '你是一位资深代码审查专家。对用户提交的代码进行全面审查。\n\n审查维度:\n1. **Bug 与逻辑错误** — 潜在的运行时错误、边界条件、空指针等\n2. **安全性** — SQL注入、XSS、敏感信息泄露等安全隐患\n3. **性能** — 时间/空间复杂度、不必要的计算、内存泄漏\n4. **可读性** — 命名规范、代码结构、注释质量\n5. **最佳实践** — 设计模式、语言惯用法、框架约定\n\n输出格式:\n- 按严重程度排列问题(严重 → 轻微)\n- 每个问题给出:位置、问题描述、修复建议、改进后的代码\n- 最后给出整体评价和改进建议', temperature: 0.2, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 },
|
||||
{ id: '_writer', name: '写作助手', icon: '✍️', systemPrompt: '你是一位才华横溢的写作助手。擅长多种文体和写作风格。\n\n能力范围:\n1. 文章撰写 — 议论文、说明文、散文、故事\n2. 文案创作 — 广告文案、产品描述、社交媒体文案\n3. 内容润色 — 修改语法、优化措辞、提升表达力\n4. 风格调整 — 正式/口语/文艺/简洁等多种风格\n5. 创意写作 — 小说、诗歌、剧本、对话\n\n工作方式:\n- 仔细理解用户的写作需求(读者、用途、风格偏好)\n- 如需信息不全,主动询问确认\n- 提供多种版本供选择\n- 解释重要的写作决策', temperature: 0.9, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 },
|
||||
{ id: '_analyst', name: '数据分析师', icon: '📊', systemPrompt: '你是一位专业的数据分析师。擅长从数据中提取洞察并给出可执行的建议。\n\n分析方法:\n1. 数据概览 — 数据量、字段类型、缺失值、异常值\n2. 统计分析 — 描述性统计、相关性分析、趋势分析\n3. 可视化建议 — 推荐合适的图表类型和展示方式\n4. 洞察提取 — 关键发现、模式识别、因果推断\n5. 行动建议 — 基于数据的具体可执行建议\n\n输出格式:\n- 先给出核心结论(Executive Summary)\n- 再展开详细分析过程\n- 使用表格和列表组织数据\n- 给出代码(Python/SQL)时附带注释', temperature: 0.4, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 },
|
||||
{ id: '_tutor', name: '学习导师', icon: '🎓', systemPrompt: '你是一位耐心且善于启发的学习导师。用苏格拉底式教学法帮助用户深入理解知识。\n\n教学原则:\n1. **先问后答** — 通过引导性问题帮助用户自己找到答案\n2. **由浅入深** — 从基础概念开始,逐步构建知识体系\n3. **类比教学** — 用生活中的类比解释抽象概念\n4. **实践导向** — 每个知识点配一个动手练习\n5. **鼓励探索** — 提供延伸阅读方向和思考题', temperature: 0.6, numCtx: 24576, think: true, builtIn: true, createdAt: 0, updatedAt: 0 }
|
||||
];
|
||||
|
||||
let presets: Preset[] = [];
|
||||
let activePresetId: string | null = null;
|
||||
|
||||
export async function initPresetManager(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
|
||||
let userPresets: Preset[] = [];
|
||||
if (db) {
|
||||
userPresets = await db.getSetting(PRESETS_KEY, []);
|
||||
}
|
||||
|
||||
presets = [...BUILT_IN_PRESETS, ...userPresets];
|
||||
|
||||
if (db) {
|
||||
activePresetId = await db.getSetting(ACTIVE_PRESET_KEY, '_default');
|
||||
} else {
|
||||
activePresetId = '_default';
|
||||
}
|
||||
|
||||
state.set('presets', presets);
|
||||
state.set('activePresetId', activePresetId);
|
||||
|
||||
const activePreset = presets.find(p => p.id === activePresetId);
|
||||
if (activePreset && activePreset.id !== '_default') {
|
||||
applyPresetToState(activePreset, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPresets(): Preset[] {
|
||||
return presets;
|
||||
}
|
||||
|
||||
export function getActivePreset(): Preset {
|
||||
return presets.find(p => p.id === activePresetId) || presets[0];
|
||||
}
|
||||
|
||||
export function getActivePresetId(): string | null {
|
||||
return activePresetId;
|
||||
}
|
||||
|
||||
export async function activatePreset(presetId: string): Promise<void> {
|
||||
const preset = presets.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
activePresetId = presetId;
|
||||
state.set('activePresetId', presetId);
|
||||
applyPresetToState(preset, true);
|
||||
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (db) {
|
||||
await db.saveSetting(ACTIVE_PRESET_KEY, presetId);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPresetToState(preset: Preset, saveToDb: boolean): void {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
|
||||
if (preset.systemPrompt) {
|
||||
state.set(KEYS.SYSTEM_PROMPT_ENABLED, true);
|
||||
state.set(KEYS.SYSTEM_PROMPT, preset.systemPrompt);
|
||||
} else {
|
||||
state.set(KEYS.SYSTEM_PROMPT_ENABLED, false);
|
||||
state.set(KEYS.SYSTEM_PROMPT, '');
|
||||
}
|
||||
|
||||
if (preset.numCtx) state.set(KEYS.NUM_CTX, preset.numCtx);
|
||||
state.set('temperature', preset.temperature ?? 0.7);
|
||||
state.set('thinkEnabled', preset.think ?? false);
|
||||
|
||||
syncPresetToUI(preset);
|
||||
|
||||
if (saveToDb && db) {
|
||||
db.saveSetting('systemPromptEnabled', !!preset.systemPrompt);
|
||||
db.saveSetting('systemPrompt', preset.systemPrompt || '');
|
||||
db.saveSetting('numCtx', preset.numCtx || 24576);
|
||||
db.saveSetting('temperature', preset.temperature ?? 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPresetToUI(preset: Preset): void {
|
||||
const toggleSP = document.querySelector<HTMLInputElement>('#toggleSystemPrompt');
|
||||
const inputSP = document.querySelector<HTMLTextAreaElement>('#inputSystemPrompt');
|
||||
if (toggleSP) toggleSP.checked = !!preset.systemPrompt;
|
||||
if (inputSP) { inputSP.disabled = !preset.systemPrompt; inputSP.value = preset.systemPrompt || ''; }
|
||||
|
||||
const inputCtx = document.querySelector<HTMLInputElement>('#inputNumCtx');
|
||||
if (inputCtx) inputCtx.value = String(preset.numCtx || 24576);
|
||||
|
||||
const tempSlider = document.querySelector<HTMLInputElement>('#inputTemperature');
|
||||
const tempDisplay = document.querySelector('#tempValue');
|
||||
if (tempSlider) tempSlider.value = String(preset.temperature ?? 0.7);
|
||||
if (tempDisplay) tempDisplay.textContent = (preset.temperature ?? 0.7).toFixed(1);
|
||||
|
||||
const thinkCheckbox = document.querySelector<HTMLInputElement>('#toggleThink');
|
||||
if (thinkCheckbox) {
|
||||
const modelSupportsThink = !thinkCheckbox.disabled;
|
||||
thinkCheckbox.checked = modelSupportsThink && (preset.think ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPreset(data: { name: string; icon: string; systemPrompt: string; temperature: number; numCtx: number; think: boolean }): Promise<Preset> {
|
||||
const preset: Preset = {
|
||||
id: `preset_${generateId()}`,
|
||||
name: data.name || '新预设',
|
||||
icon: data.icon || '🤖',
|
||||
systemPrompt: data.systemPrompt || '',
|
||||
temperature: data.temperature ?? 0.7,
|
||||
numCtx: data.numCtx ?? 24576,
|
||||
think: data.think ?? false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
presets.push(preset);
|
||||
state.set('presets', [...presets]);
|
||||
await saveUserPresets();
|
||||
return preset;
|
||||
}
|
||||
|
||||
export async function updatePreset(presetId: string, updates: Partial<Preset>): Promise<void> {
|
||||
const idx = presets.findIndex(p => p.id === presetId);
|
||||
if (idx === -1) return;
|
||||
if (presets[idx].builtIn) return;
|
||||
|
||||
presets[idx] = { ...presets[idx], ...updates, updatedAt: Date.now() };
|
||||
state.set('presets', [...presets]);
|
||||
|
||||
if (activePresetId === presetId) {
|
||||
applyPresetToState(presets[idx], true);
|
||||
}
|
||||
|
||||
await saveUserPresets();
|
||||
}
|
||||
|
||||
export async function deletePreset(presetId: string): Promise<void> {
|
||||
const idx = presets.findIndex(p => p.id === presetId);
|
||||
if (idx === -1) return;
|
||||
if (presets[idx].builtIn) return;
|
||||
|
||||
presets.splice(idx, 1);
|
||||
state.set('presets', [...presets]);
|
||||
|
||||
if (activePresetId === presetId) {
|
||||
await activatePreset('_default');
|
||||
}
|
||||
|
||||
await saveUserPresets();
|
||||
}
|
||||
|
||||
async function saveUserPresets(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
const userPresets = presets
|
||||
.filter(p => !p.builtIn)
|
||||
.map(({ builtIn, ...rest }) => rest);
|
||||
|
||||
await db.saveSetting(PRESETS_KEY, userPresets);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* RAG - 检索增强生成管线
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { VectorStore } from './vector-store.js';
|
||||
import { chunkText, createChunkMetadata } from './document-processor.js';
|
||||
import type { OllamaAPI, SearchResult, VectorItem } from '../types.js';
|
||||
|
||||
let vectorStore: VectorStore | null = null;
|
||||
|
||||
export async function initVectorStore(): Promise<VectorStore> {
|
||||
if (vectorStore) return vectorStore;
|
||||
vectorStore = new VectorStore();
|
||||
await vectorStore.init();
|
||||
return vectorStore;
|
||||
}
|
||||
|
||||
export function getVectorStore(): VectorStore | null {
|
||||
return vectorStore;
|
||||
}
|
||||
|
||||
export async function embedText(text: string, model: string): Promise<number[]> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) throw new Error('Ollama API 未连接');
|
||||
|
||||
const result = await api.embed(model, text);
|
||||
if (result.embedding) return result.embedding;
|
||||
if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
|
||||
throw new Error('嵌入向量返回格式异常');
|
||||
}
|
||||
|
||||
export async function embedBatch(texts: string[], model: string, onProgress?: (done: number, total: number) => void): Promise<number[][]> {
|
||||
const results: number[][] = [];
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < texts.length; i += batchSize) {
|
||||
const batch = texts.slice(i, i + batchSize);
|
||||
const embeddings = await Promise.all(batch.map(t => embedText(t, model)));
|
||||
results.push(...embeddings);
|
||||
if (onProgress) onProgress(Math.min(i + batchSize, texts.length), texts.length);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function addDocumentToKB(
|
||||
colId: string, filename: string, content: string, embedModel: string,
|
||||
onProgress?: (done: number, total: number, msg: string) => void
|
||||
): Promise<{ docId: string; chunkCount: number }> {
|
||||
const vs = await initVectorStore();
|
||||
|
||||
const textChunks = chunkText(content);
|
||||
if (textChunks.length === 0) throw new Error('文档内容为空,无法处理');
|
||||
|
||||
const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
if (onProgress) onProgress(0, textChunks.length, '正在生成向量...');
|
||||
const embeddings = await embedBatch(textChunks, embedModel, (done, total) => {
|
||||
if (onProgress) onProgress(done, total, '正在生成向量...');
|
||||
});
|
||||
|
||||
const vectors: VectorItem[] = textChunks.map((chunk, i) => {
|
||||
const meta = createChunkMetadata(chunk, i, docId, filename);
|
||||
return {
|
||||
...meta,
|
||||
collectionId: colId,
|
||||
embedding: embeddings[i]
|
||||
};
|
||||
});
|
||||
|
||||
if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...');
|
||||
await vs.addVectors(vectors);
|
||||
|
||||
const col = await vs.getCollection(colId);
|
||||
if (col) {
|
||||
col.docCount = (col.docCount || 0) + 1;
|
||||
col.chunkCount = (col.chunkCount || 0) + textChunks.length;
|
||||
col.embeddingModel = embedModel;
|
||||
await vs.updateCollection(col);
|
||||
}
|
||||
|
||||
return { docId, chunkCount: textChunks.length };
|
||||
}
|
||||
|
||||
export async function removeDocumentFromKB(colId: string, docId: string): Promise<void> {
|
||||
const vs = await initVectorStore();
|
||||
const allVectors = await vs.getVectorsByCollection(colId);
|
||||
const docVectors = allVectors.filter(v => v.docId === docId);
|
||||
|
||||
await vs.deleteVectorsByDocument(colId, docId);
|
||||
|
||||
const col = await vs.getCollection(colId);
|
||||
if (col) {
|
||||
col.chunkCount = Math.max(0, (col.chunkCount || 0) - docVectors.length);
|
||||
col.docCount = Math.max(0, (col.docCount || 0) - 1);
|
||||
await vs.updateCollection(col);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDocumentsInCollection(colId: string): Promise<Array<{ docId: string; filename: string; chunkCount: number }>> {
|
||||
const vs = await initVectorStore();
|
||||
const vectors = await vs.getVectorsByCollection(colId);
|
||||
const docMap = new Map<string, { docId: string; filename: string; chunkCount: number }>();
|
||||
for (const v of vectors) {
|
||||
if (!docMap.has(v.docId)) {
|
||||
docMap.set(v.docId, { docId: v.docId, filename: v.filename, chunkCount: 0 });
|
||||
}
|
||||
docMap.get(v.docId)!.chunkCount++;
|
||||
}
|
||||
return Array.from(docMap.values());
|
||||
}
|
||||
|
||||
export async function retrieveContext(query: string, colId: string, topK = 5): Promise<{ context: string; results: SearchResult[] }> {
|
||||
const vs = await initVectorStore();
|
||||
const col = await vs.getCollection(colId);
|
||||
if (!col) throw new Error('知识库集合不存在');
|
||||
|
||||
const embedModel = col.embeddingModel;
|
||||
if (!embedModel) throw new Error('未配置嵌入模型');
|
||||
|
||||
const queryEmbedding = await embedText(query, embedModel);
|
||||
const results = await vs.search(colId, queryEmbedding, topK);
|
||||
if (results.length === 0) return { context: '', results: [] };
|
||||
|
||||
const contextParts = results.map((r, i) =>
|
||||
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
|
||||
);
|
||||
const context = contextParts.join('\n\n---\n\n');
|
||||
|
||||
return { context, results };
|
||||
}
|
||||
|
||||
export function buildRagSystemPrompt(context: string, originalSystemPrompt = ''): string {
|
||||
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
|
||||
|
||||
=== 检索到的相关内容 ===
|
||||
${context}
|
||||
=== 内容结束 ===
|
||||
|
||||
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
|
||||
|
||||
if (originalSystemPrompt) {
|
||||
return originalSystemPrompt + '\n\n' + ragPrompt;
|
||||
}
|
||||
return ragPrompt;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* VectorStore - 向量存储与相似度检索
|
||||
*/
|
||||
|
||||
import type { VectorCollection, VectorItem, SearchResult } from '../types.js';
|
||||
|
||||
const MIN_IVF_SIZE = 200;
|
||||
const DEFAULT_K = 20;
|
||||
const DEFAULT_NPROBE = 5;
|
||||
const KMEANS_ITERS = 10;
|
||||
|
||||
interface IVFIndex {
|
||||
centroids: number[][];
|
||||
invertedLists: Map<number, string[]>;
|
||||
collectionId?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export class VectorStore {
|
||||
private dbName: string;
|
||||
private db: IDBDatabase | null = null;
|
||||
private _indexCache = new Map<string, IVFIndex>();
|
||||
private _vectorCache = new Map<string, Map<string, VectorItem>>();
|
||||
|
||||
constructor(dbName = 'metona-ollama-vectors') {
|
||||
this.dbName = dbName;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(this.dbName, 2);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => { this.db = req.result; resolve(); };
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains('vectors')) {
|
||||
const store = db.createObjectStore('vectors', { keyPath: 'id' });
|
||||
store.createIndex('collectionId', 'collectionId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('collections')) {
|
||||
db.createObjectStore('collections', { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('indexes')) {
|
||||
db.createObjectStore('indexes', { keyPath: 'collectionId' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _tx(store: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||
return this.db!.transaction(store, mode).objectStore(store);
|
||||
}
|
||||
|
||||
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
|
||||
const col: VectorCollection = {
|
||||
id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
name, embeddingModel,
|
||||
docCount: 0, chunkCount: 0,
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = this._tx('collections', 'readwrite').put(col);
|
||||
req.onsuccess = () => resolve(col);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getCollections(): Promise<VectorCollection[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = this._tx('collections').getAll();
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getCollection(id: string): Promise<VectorCollection | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = this._tx('collections').get(id);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async updateCollection(col: VectorCollection): Promise<void> {
|
||||
col.updatedAt = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = this._tx('collections', 'readwrite').put(col);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCollection(colId: string): Promise<void> {
|
||||
const vectors = await this.getVectorsByCollection(colId);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction(['vectors', 'collections', 'indexes'], 'readwrite');
|
||||
const vStore = tx.objectStore('vectors');
|
||||
for (const v of vectors) vStore.delete(v.id);
|
||||
tx.objectStore('collections').delete(colId);
|
||||
tx.objectStore('indexes').delete(colId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
this._indexCache.delete(colId);
|
||||
this._vectorCache.delete(colId);
|
||||
}
|
||||
|
||||
async addVectors(items: VectorItem[]): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction('vectors', 'readwrite');
|
||||
const store = tx.objectStore('vectors');
|
||||
for (const item of items) store.put(item);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
const colId = items[0].collectionId;
|
||||
if (!this._vectorCache.has(colId)) {
|
||||
this._vectorCache.set(colId, new Map());
|
||||
}
|
||||
const cache = this._vectorCache.get(colId)!;
|
||||
for (const item of items) cache.set(item.id, item);
|
||||
this._indexCache.delete(colId);
|
||||
}
|
||||
|
||||
async getVectorsByCollection(colId: string): Promise<VectorItem[]> {
|
||||
if (this._vectorCache.has(colId)) {
|
||||
return Array.from(this._vectorCache.get(colId)!.values());
|
||||
}
|
||||
const vectors = await new Promise<VectorItem[]>((resolve, reject) => {
|
||||
const idx = this._tx('vectors').index('collectionId');
|
||||
const req = idx.getAll(colId);
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const cache = new Map<string, VectorItem>();
|
||||
for (const v of vectors) cache.set(v.id, v);
|
||||
this._vectorCache.set(colId, cache);
|
||||
return vectors;
|
||||
}
|
||||
|
||||
async deleteVectorsByDocument(colId: string, docId: string): Promise<void> {
|
||||
const vectors = await this.getVectorsByCollection(colId);
|
||||
const docVectors = vectors.filter(v => v.docId === docId);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction('vectors', 'readwrite');
|
||||
const store = tx.objectStore('vectors');
|
||||
for (const v of docVectors) store.delete(v.id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
if (this._vectorCache.has(colId)) {
|
||||
const cache = this._vectorCache.get(colId)!;
|
||||
for (const v of docVectors) cache.delete(v.id);
|
||||
}
|
||||
this._indexCache.delete(colId);
|
||||
}
|
||||
|
||||
async search(colId: string, queryEmbedding: number[], topK = 5): Promise<SearchResult[]> {
|
||||
const vectors = await this.getVectorsByCollection(colId);
|
||||
if (vectors.length === 0) return [];
|
||||
|
||||
if (vectors.length < MIN_IVF_SIZE) {
|
||||
return this._bruteForceSearch(vectors, queryEmbedding, topK);
|
||||
}
|
||||
|
||||
const index = await this._getIndex(colId, vectors);
|
||||
if (!index) {
|
||||
return this._bruteForceSearch(vectors, queryEmbedding, topK);
|
||||
}
|
||||
return this._ivfSearch(vectors, index, queryEmbedding, topK);
|
||||
}
|
||||
|
||||
private _bruteForceSearch(vectors: VectorItem[], queryEmbedding: number[], topK: number): SearchResult[] {
|
||||
return vectors
|
||||
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
||||
private _ivfSearch(vectors: VectorItem[], index: IVFIndex, queryEmbedding: number[], topK: number): SearchResult[] {
|
||||
const { centroids, invertedLists } = index;
|
||||
const clusterScores = centroids.map((c, i) => ({
|
||||
id: i,
|
||||
score: VectorStore.cosineSimilarity(queryEmbedding, c)
|
||||
}));
|
||||
clusterScores.sort((a, b) => b.score - a.score);
|
||||
|
||||
const nprobe = Math.min(DEFAULT_NPROBE, centroids.length);
|
||||
const targetClusters = clusterScores.slice(0, nprobe);
|
||||
|
||||
const candidateIds = new Set<string>();
|
||||
for (const { id: clusterId } of targetClusters) {
|
||||
const list = invertedLists.get(clusterId);
|
||||
if (list) for (const id of list) candidateIds.add(id);
|
||||
}
|
||||
|
||||
const vectorMap = new Map<string, VectorItem>();
|
||||
for (const v of vectors) vectorMap.set(v.id, v);
|
||||
|
||||
const candidates: VectorItem[] = [];
|
||||
for (const id of candidateIds) {
|
||||
const v = vectorMap.get(id);
|
||||
if (v) candidates.push(v);
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
||||
private async _getIndex(colId: string, vectors: VectorItem[]): Promise<IVFIndex | null> {
|
||||
if (this._indexCache.has(colId)) {
|
||||
const cached = this._indexCache.get(colId)!;
|
||||
if (cached.version === this._indexVersion(vectors)) return cached;
|
||||
}
|
||||
|
||||
const saved = await new Promise<IVFIndex & { invertedListsObj?: Record<string, string[]> } | null>((resolve, reject) => {
|
||||
const req = this._tx('indexes').get(colId);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
if (saved && saved.version === this._indexVersion(vectors)) {
|
||||
if (!(saved.invertedLists instanceof Map)) {
|
||||
saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}));
|
||||
}
|
||||
this._indexCache.set(colId, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
console.log(`[VectorStore] 构建 IVF 索引: ${colId} (${vectors.length} 向量)`);
|
||||
const index = this._buildIVFIndex(vectors);
|
||||
index.collectionId = colId;
|
||||
index.version = this._indexVersion(vectors);
|
||||
|
||||
const toSave = {
|
||||
collectionId: index.collectionId,
|
||||
version: index.version,
|
||||
centroids: index.centroids,
|
||||
invertedListsObj: Object.fromEntries(index.invertedLists)
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = this._tx('indexes', 'readwrite').put(toSave);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
this._indexCache.set(colId, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
private _indexVersion(vectors: VectorItem[]): string {
|
||||
if (vectors.length === 0) return '0';
|
||||
return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`;
|
||||
}
|
||||
|
||||
private _buildIVFIndex(vectors: VectorItem[]): IVFIndex {
|
||||
const N = vectors.length;
|
||||
const dim = vectors[0].embedding.length;
|
||||
const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10)));
|
||||
|
||||
const centroids = this._kmeansPPInit(vectors, K, dim);
|
||||
const assignments = new Array<number>(N);
|
||||
|
||||
for (let iter = 0; iter < KMEANS_ITERS; iter++) {
|
||||
for (let i = 0; i < N; i++) {
|
||||
let bestCluster = 0;
|
||||
let bestScore = -Infinity;
|
||||
for (let k = 0; k < K; k++) {
|
||||
const score = VectorStore.cosineSimilarity(vectors[i].embedding, centroids[k]);
|
||||
if (score > bestScore) { bestScore = score; bestCluster = k; }
|
||||
}
|
||||
assignments[i] = bestCluster;
|
||||
}
|
||||
|
||||
const newCentroids = Array.from({ length: K }, () => new Array<number>(dim).fill(0));
|
||||
const counts = new Array<number>(K).fill(0);
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const k = assignments[i];
|
||||
counts[k]++;
|
||||
const emb = vectors[i].embedding;
|
||||
for (let d = 0; d < dim; d++) newCentroids[k][d] += emb[d];
|
||||
}
|
||||
|
||||
for (let k = 0; k < K; k++) {
|
||||
if (counts[k] > 0) {
|
||||
for (let d = 0; d < dim; d++) newCentroids[k][d] /= counts[k];
|
||||
this._normalize(newCentroids[k]);
|
||||
} else {
|
||||
newCentroids[k] = [...vectors[Math.floor(Math.random() * N)].embedding];
|
||||
}
|
||||
}
|
||||
|
||||
let converged = true;
|
||||
for (let k = 0; k < K; k++) {
|
||||
if (VectorStore.cosineSimilarity(centroids[k], newCentroids[k]) < 0.999) {
|
||||
converged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let k = 0; k < K; k++) centroids[k] = newCentroids[k];
|
||||
if (converged) break;
|
||||
}
|
||||
|
||||
const invertedLists = new Map<number, string[]>();
|
||||
for (let i = 0; i < N; i++) {
|
||||
const k = assignments[i];
|
||||
if (!invertedLists.has(k)) invertedLists.set(k, []);
|
||||
invertedLists.get(k)!.push(vectors[i].id);
|
||||
}
|
||||
|
||||
return { centroids, invertedLists };
|
||||
}
|
||||
|
||||
private _kmeansPPInit(vectors: VectorItem[], K: number, dim: number): number[][] {
|
||||
const centroids: number[][] = [];
|
||||
const first = Math.floor(Math.random() * vectors.length);
|
||||
centroids.push([...vectors[first].embedding]);
|
||||
|
||||
for (let k = 1; k < K; k++) {
|
||||
const dists = vectors.map(v => {
|
||||
let minDist = Infinity;
|
||||
for (const c of centroids) {
|
||||
const sim = VectorStore.cosineSimilarity(v.embedding, c);
|
||||
const dist = 1 - sim;
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
return minDist;
|
||||
});
|
||||
|
||||
const total = dists.reduce((s, d) => s + d, 0);
|
||||
if (total === 0) {
|
||||
centroids.push([...vectors[Math.floor(Math.random() * vectors.length)].embedding]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
r -= dists[i];
|
||||
if (r <= 0) { centroids.push([...vectors[i].embedding]); break; }
|
||||
}
|
||||
}
|
||||
return centroids;
|
||||
}
|
||||
|
||||
private _normalize(vec: number[]): void {
|
||||
let norm = 0;
|
||||
for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
if (norm > 0) for (let i = 0; i < vec.length; i++) vec[i] /= norm;
|
||||
}
|
||||
|
||||
static cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (!a || !b || a.length !== b.length) return 0;
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user