/** * KBModal - 知识库管理面板 */ import { state, KEYS } from '../state/state.js'; import { initVectorStore, getVectorStore, addDocumentToKB, removeDocumentFromKB, getDocumentsInCollection, retrieveContext, buildRagSystemPrompt } from '../services/rag.js'; import { formatSize, escapeHtml, truncate } from '../utils/utils.js'; import { showToast } from './toast.js'; import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; import { logRAG, logError, logWarn } from '../services/log-service.js'; import type { VectorCollection, SearchResult } from '../types.js'; let kbModalEl: HTMLElement; let currentColId: string | null = null; let ragEnabled = false; let ragCollectionId: string | null = null; export function initKBModal(): void { kbModalEl = document.querySelector('#kbModal')!; document.querySelector('#btnKB')!.addEventListener('click', openKBModal); document.querySelector('#btnCloseKB')!.addEventListener('click', closeKBModal); kbModalEl.addEventListener('click', (e) => { if (e.target === kbModalEl) closeKBModal(); }); document.querySelector('#btnNewCollection')!.addEventListener('click', createCollection); document.querySelector('#btnUploadDoc')!.addEventListener('click', async () => { if (!currentColId) { showToast('请先选择一个知识库集合', 'warning'); return; } const bridge = (window as any).metonaDesktop; if (!bridge) return; const paths = await bridge.dialog.openFile({ filters: [{ name: '文档', extensions: ['txt', 'md', 'py', 'js', 'ts', 'java', 'go', 'rs', 'cpp', 'c', 'h', 'hpp', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'csv'] }] }); if (!paths || paths.length === 0) return; await handleDocUpload(paths); }); document.querySelector('#toggleRAG')!.addEventListener('change', (e) => { ragEnabled = (e.target as HTMLInputElement).checked; if (ragEnabled && currentColId) { ragCollectionId = currentColId; showToast('RAG 知识检索已开启', 'success'); } else if (ragEnabled && !currentColId) { (e.target as HTMLInputElement).checked = false; ragEnabled = false; showToast('请先选择一个知识库集合', 'warning'); return; } else { ragCollectionId = null; showToast('RAG 知识检索已关闭', 'info'); } updateRagBadge(); }); document.querySelector('#selectEmbedModel')!.addEventListener('change', async (e) => { if (!currentColId) return; const vs = getVectorStore(); const col = await vs!.getCollection(currentColId); if (col) { col.embeddingModel = (e.target as HTMLSelectElement).value; await vs!.updateCollection(col); } }); } export function isRagEnabled(): boolean { return ragEnabled && !!ragCollectionId; } export function getRagCollectionId(): string | null { return ragCollectionId; } export async function performRagRetrieval(query: string): Promise<{ context: string; results: SearchResult[]; ragPrompt: string } | null> { if (!isRagEnabled()) return null; try { const { context, results } = await retrieveContext(query, ragCollectionId!, 5); if (!context) return null; return { context, results, ragPrompt: buildRagSystemPrompt(context) }; } catch (err) { logWarn('RAG 检索失败', (err as Error).message); return null; } } function updateRagBadge(): void { const badge = document.querySelector('#badgeRAG') as HTMLElement; if (!badge) return; badge.style.display = ragEnabled ? '' : 'none'; } async function openKBModal(): Promise { kbModalEl.style.display = ''; logRAG('打开知识库管理'); await refreshCollections(); await populateEmbedModels(); } function closeKBModal(): void { kbModalEl.style.display = 'none'; } async function refreshCollections(): Promise { const vs = await initVectorStore(); const collections = await vs.getCollections(); const listEl = document.querySelector('#kbCollectionList')!; if (collections.length === 0) { listEl.innerHTML = '

暂无知识库,点击上方「新建集合」创建

'; currentColId = null; refreshDocList(); return; } listEl.innerHTML = collections.map(c => `
${escapeHtml(c.name)} ${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块
`).join(''); listEl.querySelectorAll('.kb-collection-item').forEach(el => { el.addEventListener('click', async (e) => { if ((e.target as HTMLElement).closest('.kb-col-delete')) return; currentColId = (el as HTMLElement).dataset.id!; await refreshCollections(); await refreshDocList(); const vs = getVectorStore(); const col = await vs!.getCollection(currentColId); if (col?.embeddingModel) { (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value = col.embeddingModel; } }); }); listEl.querySelectorAll('.kb-col-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = (btn as HTMLElement).dataset.id!; if (!await showConfirm('确定删除此知识库集合?所有文档将被清除。', '删除知识库')) return; const vs = getVectorStore(); await vs!.deleteCollection(id); if (currentColId === id) currentColId = null; if (ragCollectionId === id) { ragEnabled = false; ragCollectionId = null; (document.querySelector('#toggleRAG') as HTMLInputElement).checked = false; updateRagBadge(); } showToast('集合已删除', 'success'); await refreshCollections(); await refreshDocList(); }); }); } async function refreshDocList(): Promise { const docListEl = document.querySelector('#kbDocList')!; if (!currentColId) { docListEl.innerHTML = '

← 选择一个知识库集合

'; return; } const docs = await getDocumentsInCollection(currentColId); if (docs.length === 0) { docListEl.innerHTML = '

此集合暂无文档,上传文件开始构建知识库

'; return; } docListEl.innerHTML = docs.map(d => `
📄
${escapeHtml(d.filename)} ${d.chunkCount} 个分块
`).join(''); docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => { btn.addEventListener('click', async () => { const docId = (btn as HTMLElement).dataset.docid!; if (!await showConfirm('确定删除此文档?', '删除文档')) return; await removeDocumentFromKB(currentColId!, docId); showToast('文档已删除', 'success'); await refreshCollections(); await refreshDocList(); }); }); } async function createCollection(): Promise { const nameInput = document.querySelector('#inputKBName') as HTMLInputElement; const name = nameInput.value.trim(); if (!name) { showToast('请输入知识库名称', 'warning'); return; } const vs = await initVectorStore(); const col = await vs.createCollection(name); currentColId = col.id; nameInput.value = ''; showToast(`知识库「${name}」已创建`, 'success'); logRAG(`创建集合: ${name}`); await refreshCollections(); await refreshDocList(); } async function handleDocUpload(paths: string[]): Promise { if (paths.length === 0) return; const embedModel = (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value; if (!embedModel) { showToast('请先选择一个嵌入模型', 'warning'); return; } const bridge = (window as any).metonaDesktop; const progressEl = document.querySelector('#kbProgress') as HTMLElement; const progressTextEl = document.querySelector('#kbProgressText')!; let successCount = 0; let failCount = 0; for (const filePath of paths) { const name = filePath.split(/[/\\]/).pop() || filePath; try { progressEl.style.display = ''; progressTextEl.textContent = `正在处理 ${name}...`; const result = await bridge.fs.readFile(filePath); if (!result.success) { showToast(`${name} 读取失败: ${result.error}`, 'error'); failCount++; continue; } const content = result.content as string; const size = new TextEncoder().encode(content).length; if (size > 5 * 1024 * 1024) { showToast(`${name} 超过 5MB 限制`, 'warning'); failCount++; continue; } const kbResult = await addDocumentToKB( currentColId!, name, content, embedModel, (done, total, msg) => { progressTextEl.textContent = `${name}: ${msg} (${done}/${total})`; } ); showToast(`${name} 已添加到知识库(${kbResult.chunkCount} 个分块)`, 'success'); logRAG(`文档上传: ${name}`, `${kbResult.chunkCount} 个分块`); successCount++; } catch (err) { logError(`RAG 文档处理失败: ${name}`, (err as Error).message); showToast(`${name} 处理失败: ${(err as Error).message}`, 'error'); failCount++; } } progressEl.style.display = 'none'; if (fileArr.length > 1) { showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount} 个`, failCount > 0 ? 'warning' : 'success'); logRAG(`批量上传完成`, `成功 ${successCount}, 失败 ${failCount}`); } await refreshCollections(); await refreshDocList(); } async function populateEmbedModels(): Promise { const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement; const api = state.get(KEYS.API); if (!api) return; try { const data = await api.listModels(); select.innerHTML = ''; if (!data.models || data.models.length === 0) { select.innerHTML = ''; return; } const checks = await Promise.allSettled( data.models.map(async (m) => { const info = await api.showModel(m.name); const caps = info.capabilities || []; return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') }; }) ); const embedModels = checks .filter(r => r.status === 'fulfilled' && r.value.isEmbed) .map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value); embedModels.forEach(m => { const opt = document.createElement('option'); opt.value = m.name; opt.textContent = m.name + (m.size ? ` (${formatSize(m.size)})` : ''); select.appendChild(opt); }); if (embedModels.length === 0) { select.innerHTML = ''; } } catch (err) { logWarn('加载嵌入模型列表失败', (err as Error).message); } }