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

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