diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 40f2bc0..d5e449b 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -166,17 +166,6 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { contentHtml += `
${stats.map((s, i) => `${s}`).join(' · ')}
`; } - 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 `
- 📄 来源 ${i + 1}: ${escapeHtml(s.filename)} - ${scorePercent ? `${scorePercent}` : ''} -
`; - }).join(''); - contentHtml += `
🧠 基于知识库回答
${sourcesHtml}
`; - } - if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) { contentHtml += '
'; for (const tc of msg.toolCalls) { diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 1fcd712..3467ce7 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -10,14 +10,13 @@ import { updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens } from './chat-area.js'; import { showToast } from './toast.js'; -import { isRagEnabled, performRagRetrieval } from './kb-modal.js'; import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; import { runAgentLoop } from '../services/agent-engine.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js'; import { showToolConfirm } from './tool-confirm-modal.js'; import { logInfo, logStream, logError, logSuccess, logWarn } from '../services/log-service.js'; -import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile, ToolCallRecord } from '../types.js'; +import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord } from '../types.js'; let chatInputEl: HTMLTextAreaElement; let btnSendEl: HTMLButtonElement; @@ -396,61 +395,7 @@ export async function sendMessage(): Promise { } } - let ragSources: RagSource[] | null = null; - - if (isRagEnabled()) { - appendSystemMessage('🧠 正在检索知识库...', 'rag-status'); - try { - const textForRag = text; - const ragResult = await performRagRetrieval(textForRag); - if (ragResult && ragResult.results && ragResult.results.length > 0) { - const ctxLimit = numCtx || 24576; - const outputReserve = 2048; - const msgChars = apiMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0); - const systemChars = ((chatParams.system as string)?.length || 0); - const usedTokens = Math.ceil((msgChars + systemChars) / 2); - const ragBudget = Math.max(0, ctxLimit - outputReserve - usedTokens); - const ragBudgetChars = ragBudget * 2; - - let ragContext = ragResult.results.map((r: any, i: number) => - `[来源 ${i + 1}: ${r.filename}]\n${r.text}` - ).join('\n\n---\n\n'); - - const ragTemplate = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===`; - if (ragContext.length + ragTemplate.length > ragBudgetChars) { - ragContext = ragContext.slice(0, Math.max(0, ragBudgetChars - ragTemplate.length - 50)) + '\n\n[知识库内容过长,已截取最相关的部分]'; - appendSystemMessage(`⚠️ 知识库内容已截取(预算 ${ragBudgetChars} 字符)`, 'rag-status'); - } - - const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===\n\n请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`; - - chatParams.system = chatParams.system - ? (chatParams.system as string) + '\n\n' + ragPrompt - : ragPrompt; - - ragSources = ragResult.results.map((r: any) => ({ - 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) { - logError('RAG 检索失败', (ragErr as Error).message); - appendSystemMessage(`🧠 知识库检索失败: ${(ragErr as Error).message}`, 'rag-status'); - } - } - - let ragStatusCleared = false; - await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => { - 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; @@ -487,8 +432,7 @@ export async function sendMessage(): Promise { timestamp: Date.now(), ...(modelName && { model: modelName }), ...(thinkContent && { think: thinkContent }), - ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }), - ...(ragSources && { ragSources }) + ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }) }; state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, @@ -500,20 +444,6 @@ export async function sendMessage(): Promise { updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || undefined); } - 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 `
📄 来源 ${i + 1}: ${escapeHtml(s.filename)}${scorePercent ? `${scorePercent}` : ''}
`; - }).join(''); - const ragDiv = document.createElement('div'); - ragDiv.className = 'rag-sources'; - ragDiv.innerHTML = `
🧠 基于知识库回答
${sourcesHtml}`; - lastAssistant.querySelector('.msg-body')!.appendChild(ragDiv); - } - } - await saveCurrentSession(); updateTotalTokens(); @@ -532,8 +462,6 @@ export async function sendMessage(): Promise { } } } catch (err) { - document.querySelectorAll('.rag-status').forEach(el => el.remove()); - if ((err as Error).name === 'AbortError') { const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement; const loadingDots = placeholder?.querySelector('.loading-dots'); @@ -596,7 +524,7 @@ export async function sendMessage(): Promise { errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"'; logError('连接失败', 'Ollama 服务不可达'); } else if ((err as Error).message.includes('400')) { - errMsg = '❌ 请求参数错误,可能是知识库内容超出模型上下文限制'; + errMsg = '❌ 请求参数错误,可能是上下文过长'; logError('请求参数错误 (400)', (err as Error).message); } else if ((err as Error).message.includes('500')) { errMsg = '❌ Ollama 服务器错误,请检查 Ollama 日志'; diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts deleted file mode 100644 index 511de1c..0000000 --- a/src/renderer/components/kb-modal.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 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); - } -} diff --git a/src/renderer/components/memory-modal.ts b/src/renderer/components/memory-modal.ts new file mode 100644 index 0000000..fee5a7d --- /dev/null +++ b/src/renderer/components/memory-modal.ts @@ -0,0 +1,343 @@ +/** + * MemoryModal - Agent 记忆管理大模态框 + * 左右分栏布局:左侧分类筛选+统计,右侧搜索+列表+操作 + */ + +import { + getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory, + clearAllMemories, isMemoryEnabled, setMemoryEnabled, + getTypeIcon, getTypeName, isVectorMemoryEnabled, getEmbeddingModel +} from '../services/memory-manager.js'; +import { showToast } from './toast.js'; +import { showPrompt, showConfirm } from './prompt-modal.js'; +import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; +import type { MemoryEntry, MemoryType } from '../types.js'; + +let modalEl: HTMLElement; + +// ── 初始化 ── + +export function initMemoryModal(): void { + // 创建模态框 DOM + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.id = 'memoryModal'; + overlay.style.display = 'none'; + overlay.innerHTML = ` + + `; + + document.querySelector('#app')!.appendChild(overlay); + modalEl = overlay; + + // 绑定事件 + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeMemoryModal(); + }); + overlay.querySelector('#btnCloseMemoryModal')!.addEventListener('click', closeMemoryModal); + + // 自动记忆开关 + const toggle = overlay.querySelector('#memoryModalToggle') as HTMLInputElement; + toggle.checked = isMemoryEnabled(); + toggle.addEventListener('change', () => { + setMemoryEnabled(toggle.checked); + showToast(toggle.checked ? '自动记忆已开启' : '自动记忆已关闭', 'info'); + }); + + // 搜索 + const searchInput = overlay.querySelector('#memorySearchLg') as HTMLInputElement; + searchInput.addEventListener('input', debounce(() => renderList(), 300)); + + // 分类筛选 + overlay.querySelectorAll('.memory-category-item').forEach(el => { + el.addEventListener('click', () => { + overlay.querySelectorAll('.memory-category-item').forEach(c => c.classList.remove('active')); + el.classList.add('active'); + renderList(); + }); + }); + + // 添加记忆 + overlay.querySelector('#btnAddMemoryLg')!.addEventListener('click', openAddDialog); + + // 清空 + overlay.querySelector('#btnClearMemoryLg')!.addEventListener('click', async () => { + if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) { + await clearAllMemories(); + renderList(); + updateStats(); + showToast('已清空所有记忆', 'success'); + } + }); + + // header 按钮 + document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal); + + // 全局状态监听 + if (typeof (window as any).__memoryStateListener === 'undefined') { + (window as any).__memoryStateListener = true; + // 简单轮询:在模态框打开时监听变化 + } +} + +// ── 打开/关闭 ── + +export function openMemoryModal(): void { + modalEl.style.display = ''; + (modalEl.querySelector('#memoryModalToggle') as HTMLInputElement).checked = isMemoryEnabled(); + updateVectorBadge(); + updateStats(); + renderList(); + (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).focus(); +} + +export function closeMemoryModal(): void { + modalEl.style.display = 'none'; +} + +// ── 向量记忆状态 ── + +function updateVectorBadge(): void { + const badge = modalEl.querySelector('#memoryVectorBadge')!; + if (isVectorMemoryEnabled()) { + badge.textContent = `✅ 向量搜索 (${getEmbeddingModel()})`; + badge.className = 'memory-vector-badge enabled'; + } else { + badge.textContent = '关键词模式'; + badge.className = 'memory-vector-badge disabled'; + } +} + +// ── 统计 ── + +function updateStats(): void { + const entries = getMemoryCache(); + const counts = { total: entries.length, fact: 0, preference: 0, rule: 0 }; + for (const e of entries) { + if (e.type in counts) (counts as any)[e.type]++; + } + const el = (id: string) => modalEl.querySelector(`#${id}`)!; + el('memStatTotal').textContent = String(counts.total); + el('memStatFact').textContent = String(counts.fact); + el('memStatPref').textContent = String(counts.preference); + el('memStatRule').textContent = String(counts.rule); + el('catCountAll').textContent = String(counts.total); + el('catCountFact').textContent = String(counts.fact); + el('catCountPref').textContent = String(counts.preference); + el('catCountRule').textContent = String(counts.rule); +} + +// ── 列表渲染 ── + +function getActiveType(): string { + const active = modalEl.querySelector('.memory-category-item.active') as HTMLElement; + return active?.dataset.type || ''; +} + +function renderList(): void { + const listEl = modalEl.querySelector('#memoryListLg')!; + const query = (modalEl.querySelector('#memorySearchLg') as HTMLInputElement).value.trim(); + const typeFilter = getActiveType(); + + let entries: MemoryEntry[]; + if (query) { + entries = searchMemories(query, 50); + } else { + entries = getMemoryCache(); + } + + if (typeFilter) { + entries = entries.filter(e => e.type === typeFilter); + } + + entries.sort((a, b) => { + if (a.importance !== b.importance) return b.importance - a.importance; + return b.updatedAt - a.updatedAt; + }); + + if (entries.length === 0) { + listEl.innerHTML = `
${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}
`; + return; + } + + listEl.innerHTML = entries.map(entry => { + const icon = getTypeIcon(entry.type); + const typeName = getTypeName(entry.type); + const importanceStars = '★'.repeat(Math.min(entry.importance, 10)) + '☆'.repeat(Math.max(0, 10 - entry.importance)); + return ` +
+
+ ${icon} + ${typeName} + ${importanceStars} + +
+
${escapeHtml(entry.content)}
+
+ ${entry.tags.length > 0 ? `${entry.tags.map(t => `${escapeHtml(t)}`).join('')}` : ''} + ${formatTime(entry.updatedAt)} +
+
+ `; + }).join(''); + + // 删除按钮 + listEl.querySelectorAll('.memory-item-lg-delete').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = (btn as HTMLElement).dataset.id!; + await deleteMemory(id); + renderList(); + updateStats(); + showToast('记忆已删除', 'info', 1500); + }); + }); + + // 双击编辑 + listEl.querySelectorAll('.memory-item-lg-content').forEach(el => { + el.addEventListener('dblclick', () => { + const item = (el as HTMLElement).closest('.memory-item-lg')!; + const id = item.getAttribute('data-id')!; + const entry = getMemoryCache().find(e => e.id === id); + if (entry) openEditDialog(entry); + }); + }); +} + +// ── 添加对话框 ── + +async function openAddDialog(): Promise { + const type = await showPrompt({ + title: '添加记忆', + message: '选择记忆类型:', + type: 'select', + defaultValue: 'fact', + options: [ + { value: 'fact', label: '📌 事实 — 项目信息、身份背景' }, + { value: 'preference', label: '⚙️ 偏好 — 语言风格、技术栈偏好' }, + { value: 'rule', label: '📏 规则 — 编码规范、输出要求' } + ] + }); + if (type === null) return; + const memoryType = (type as MemoryType) || 'fact'; + + const content = await showPrompt({ + title: '添加记忆', + message: '请输入记忆内容:', + type: 'textarea', + placeholder: '例如:用户正在开发一个 Electron 桌面应用' + }); + if (!content?.trim()) return; + + const importanceStr = await showPrompt({ + title: '添加记忆', + message: '重要性(1-10,默认 5):', + defaultValue: '5', + placeholder: '5' + }); + const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5)); + + await addMemory({ type: memoryType, content: content.trim(), importance }); + renderList(); + updateStats(); + showToast('记忆已添加', 'success', 1500); +} + +// ── 编辑对话框 ── + +async function openEditDialog(entry: MemoryEntry): Promise { + const newContent = await showPrompt({ + title: '编辑记忆', + message: '修改记忆内容:', + type: 'textarea', + defaultValue: entry.content + }); + if (newContent === null || newContent.trim() === entry.content) return; + + const importanceStr = await showPrompt({ + title: '编辑记忆', + message: '重要性(1-10):', + defaultValue: String(entry.importance), + placeholder: String(entry.importance) + }); + const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance; + + await updateMemory(entry.id, { content: newContent.trim(), importance }); + renderList(); + updateStats(); + showToast('记忆已更新', 'success', 1500); +} diff --git a/src/renderer/components/memory-panel.ts b/src/renderer/components/memory-panel.ts deleted file mode 100644 index 9197f76..0000000 --- a/src/renderer/components/memory-panel.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * MemoryPanel - Agent 记忆管理面板 - * 提供记忆的查看、搜索、编辑、删除功能 - */ - -import { state, KEYS } from '../state/state.js'; -import { - getMemoryCache, searchMemories, addMemory, updateMemory, deleteMemory, - clearAllMemories, isMemoryEnabled, setMemoryEnabled, - getTypeIcon, getTypeName -} from '../services/memory-manager.js'; -import { showToast } from './toast.js'; -import { showPrompt, showConfirm } from './prompt-modal.js'; -import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; -import type { MemoryEntry, MemoryType } from '../types.js'; - -let panelEl: HTMLElement; -let isOpen = false; - -export function initMemoryPanel(): void { - // 绑定 header 中的记忆按钮 - const btnMemory = document.querySelector('#btnMemory'); - if (!btnMemory) return; - - // 创建记忆面板 - const panel = document.createElement('div'); - panel.className = 'memory-panel'; - panel.id = 'memoryPanel'; - panel.style.display = 'none'; - panel.innerHTML = ` -
-

🧠 Agent 记忆

-
- - -
-
- -
- - -
-
- `; - - document.querySelector('#app')!.appendChild(panel); - panelEl = panel; - - // 绑定事件 - btnMemory.addEventListener('click', togglePanel); - panel.querySelector('#btnCloseMemoryPanel')!.addEventListener('click', () => setPanelOpen(false)); - - const toggleMemory = panel.querySelector('#toggleMemoryPanelEnabled') as HTMLInputElement; - toggleMemory.checked = isMemoryEnabled(); - toggleMemory.addEventListener('change', () => { - setMemoryEnabled(toggleMemory.checked); - showToast(toggleMemory.checked ? '自动记忆已开启' : '自动记忆已关闭', 'info'); - }); - - const searchInput = panel.querySelector('#memorySearchInput') as HTMLInputElement; - const typeFilter = panel.querySelector('#memoryTypeFilter') as HTMLSelectElement; - searchInput.addEventListener('input', debounce(() => renderMemoryList(), 200)); - typeFilter.addEventListener('change', () => renderMemoryList()); - - panel.querySelector('#btnAddMemory')!.addEventListener('click', () => openAddMemoryDialog()); - panel.querySelector('#btnClearMemories')!.addEventListener('click', async () => { - if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) { - await clearAllMemories(); - renderMemoryList(); - updateMemoryCount(); - showToast('已清空所有记忆', 'success'); - } - }); - - state.on('memoryEntries', () => { - updateMemoryCount(); - if (isOpen) renderMemoryList(); - }); - - updateMemoryCount(); -} - -function togglePanel(): void { - setPanelOpen(!isOpen); -} - -function setPanelOpen(open: boolean): void { - isOpen = open; - panelEl.style.display = open ? '' : 'none'; - if (open) { - renderMemoryList(); - (panelEl.querySelector('#memorySearchInput') as HTMLInputElement)?.focus(); - } -} - -function updateMemoryCount(): void { - const count = getMemoryCache().length; - const btn = document.querySelector('#btnMemory'); - if (btn) btn.setAttribute('title', count > 0 ? `Agent 记忆 (${count})` : 'Agent 记忆'); -} - -function renderMemoryList(): void { - const listEl = panelEl.querySelector('#memoryList')!; - const searchInput = panelEl.querySelector('#memorySearchInput') as HTMLInputElement; - const typeFilter = panelEl.querySelector('#memoryTypeFilter') as HTMLSelectElement; - - const query = searchInput.value.trim(); - const type = typeFilter.value; - - let entries: MemoryEntry[]; - - if (query) { - entries = searchMemories(query, 20).map(r => ({ - ...r, - score: undefined - })); - } else { - entries = getMemoryCache(); - } - - if (type) { - entries = entries.filter(e => e.type === type); - } - - entries.sort((a, b) => { - if (a.importance !== b.importance) return b.importance - a.importance; - return b.updatedAt - a.updatedAt; - }); - - if (entries.length === 0) { - listEl.innerHTML = `
${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}
`; - return; - } - - listEl.innerHTML = entries.map(entry => { - const icon = getTypeIcon(entry.type); - const typeName = getTypeName(entry.type); - const importanceDots = '●'.repeat(Math.min(entry.importance, 10)) + '○'.repeat(Math.max(0, 10 - entry.importance)); - return ` -
-
- ${icon} - ${typeName} - ${importanceDots} - -
-
${escapeHtml(entry.content)}
-
- ${entry.tags.length > 0 ? `${entry.tags.map(t => `${escapeHtml(t)}`).join('')}` : ''} - ${formatTime(entry.updatedAt)} -
-
- `; - }).join(''); - - // 绑定删除按钮 - listEl.querySelectorAll('.memory-item-delete').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const id = (btn as HTMLElement).dataset.id!; - await deleteMemory(id); - renderMemoryList(); - updateMemoryCount(); - showToast('记忆已删除', 'info', 1500); - }); - }); - - // 点击编辑 - listEl.querySelectorAll('.memory-item-content').forEach(el => { - el.addEventListener('dblclick', () => { - const item = (el as HTMLElement).closest('.memory-item')!; - const id = item.getAttribute('data-id')!; - const entry = getMemoryCache().find(e => e.id === id); - if (entry) openEditMemoryDialog(entry); - }); - }); -} - -async function openAddMemoryDialog(): Promise { - const type = await showPrompt({ - title: '添加记忆', - message: '选择记忆类型:', - type: 'select', - defaultValue: 'fact', - options: [ - { value: 'fact', label: '📌 事实 — 项目信息、身份背景' }, - { value: 'preference', label: '⚙️ 偏好 — 语言风格、技术栈偏好' }, - { value: 'rule', label: '📏 规则 — 编码规范、输出要求' }, - { value: 'episode', label: '📝 事件 — 完成的任务、结论' } - ] - }); - if (type === null) return; - const memoryType = (type as MemoryType) || 'fact'; - - const content = await showPrompt({ - title: '添加记忆', - message: '请输入记忆内容:', - type: 'textarea', - placeholder: '例如:用户正在开发一个 Electron 桌面应用' - }); - if (!content?.trim()) return; - - const importanceStr = await showPrompt({ - title: '添加记忆', - message: '重要性(1-10,默认 5):', - defaultValue: '5', - placeholder: '5' - }); - const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5)); - - await addMemory({ type: memoryType, content: content.trim(), importance }); - renderMemoryList(); - updateMemoryCount(); - showToast('记忆已添加', 'success', 1500); -} - -async function openEditMemoryDialog(entry: MemoryEntry): Promise { - const newContent = await showPrompt({ - title: '编辑记忆', - message: '修改记忆内容:', - type: 'textarea', - defaultValue: entry.content - }); - if (newContent === null || newContent.trim() === entry.content) return; - - const importanceStr = await showPrompt({ - title: '编辑记忆', - message: '重要性(1-10):', - defaultValue: String(entry.importance), - placeholder: String(entry.importance) - }); - const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance; - - await updateMemory(entry.id, { content: newContent.trim(), importance }); - renderMemoryList(); - showToast('记忆已更新', 'success', 1500); -} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index b47e1b3..a7b2642 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -5,7 +5,7 @@ import { state, KEYS } from '../state/state.js'; import { debounce } from '../utils/utils.js'; import { encryptData, decryptData } from '../services/crypto.js'; -import { setMemoryEnabled } from '../services/memory-manager.js'; +import { setMemoryEnabled, setEmbeddingModel, getEmbeddingModel, isVectorMemoryEnabled, getMemoryCache } from '../services/memory-manager.js'; import { setToolEnabled } from '../services/tool-registry.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js'; @@ -153,6 +153,23 @@ export function initSettingsModal(): void { }); } + // ── 向量记忆引擎设置 ── + const selectEmbedModel = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement; + if (selectEmbedModel) { + selectEmbedModel.addEventListener('change', async () => { + const model = selectEmbedModel.value; + await setEmbeddingModel(model); + if (model) { + showToast(`向量记忆已启用(${model})`, 'success'); + logSetting('向量记忆引擎', `启用: ${model}`); + } else { + showToast('向量记忆已关闭,仅使用关键词匹配', 'info'); + logSetting('向量记忆引擎', '关闭'); + } + updateMemoryVectorStatus(); + }); + } + // ── 工作空间目录设置 ── const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement; const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement; @@ -206,6 +223,8 @@ export function openSettingsModal(): void { (document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get(KEYS.API)?.baseUrl || ''; updateConnectionInfo(); updateRunningModels(); + populateMemoryEmbedModels(); + updateMemoryVectorStatus(); } export function closeSettingsModal(): void { @@ -269,6 +288,64 @@ async function exportAllSessions(): Promise { } } +async function populateMemoryEmbedModels(): Promise { + const select = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement; + if (!select) return; + const api = state.get(KEYS.API); + if (!api) return; + + try { + const data = await api.listModels(); + select.innerHTML = ''; + if (!data.models || data.models.length === 0) 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; + select.appendChild(opt); + }); + + if (embedModels.length === 0) { + select.innerHTML = ''; + return; + } + + // 恢复已保存的选择 + const saved = getEmbeddingModel(); + if (saved) select.value = saved; + } catch (err) { + logWarn('加载嵌入模型列表失败', (err as Error).message); + } +} + +function updateMemoryVectorStatus(): void { + const statusEl = document.querySelector('#memoryVectorStatus'); + if (!statusEl) return; + const model = getEmbeddingModel(); + if (model) { + const memCount = getMemoryCache().length; + statusEl.textContent = `✅ 向量搜索已启用(${model})· ${memCount} 条记忆`; + (statusEl as HTMLElement).style.color = 'var(--success)'; + } else { + statusEl.textContent = 'ℹ️ 未选择嵌入模型,仅使用关键词匹配'; + (statusEl as HTMLElement).style.color = ''; + } +} + async function importSessions(filePath: string): Promise { const db = state.get(KEYS.DB); if (!db) return; diff --git a/src/renderer/index.html b/src/renderer/index.html index 558aea9..40f0305 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -46,14 +46,6 @@ -
@@ -319,9 +310,16 @@
📌 事实项目信息、身份背景
⚙️ 偏好语言风格、技术栈偏好
📏 规则编码规范、输出格式要求
-
📝 事件完成的任务、结论
+
+ +

选择嵌入模型以启用向量语义记忆。未选择时仅使用关键词匹配。

+ +
+
@@ -373,69 +371,17 @@
- - - - -