feat: 将记忆系统和知识库合并改造为长效向量记忆系统
- 移除知识库(KB/RAG)功能,删除 kb-modal.ts、rag.ts、memory-panel.ts - 新增 vector-memory.ts:记忆向量存储与检索引擎 - 新增 memory-modal.ts:大模态框布局的记忆管理面板 - 简化记忆分类为3类:fact(事实)、preference(偏好)、rule(规则) - 嵌入模型配置移至设置面板 - 无嵌入模型时自动降级为关键词搜索模式 - 向量搜索支持语义相似度检索 - 嵌入模型变化时自动重新索引所有记忆
This commit is contained in:
@@ -166,17 +166,6 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
||||
contentHtml += `<div class="msg-stats">${stats.map((s, i) => `<span${statClasses[i] ? ` class="${statClasses[i]}"` : ''}>${s}</span>`).join(' · ')}</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' && msg.ragSources && msg.ragSources.length > 0) {
|
||||
const sourcesHtml = msg.ragSources.map((s, i) => {
|
||||
const scorePercent = s.score ? `${(s.score * 100).toFixed(0)}%` : '';
|
||||
return `<div class="rag-source-item">
|
||||
<span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>
|
||||
${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
contentHtml += `<div class="rag-sources"><div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
contentHtml += '<div class="tool-calls-container">';
|
||||
for (const tc of msg.toolCalls) {
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 `<div class="rag-source-item"><span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}</div>`;
|
||||
}).join('');
|
||||
const ragDiv = document.createElement('div');
|
||||
ragDiv.className = 'rag-sources';
|
||||
ragDiv.innerHTML = `<div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}`;
|
||||
lastAssistant.querySelector('.msg-body')!.appendChild(ragDiv);
|
||||
}
|
||||
}
|
||||
|
||||
await saveCurrentSession();
|
||||
updateTotalTokens();
|
||||
|
||||
@@ -532,8 +462,6 @@ export async function sendMessage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} 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<void> {
|
||||
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 日志';
|
||||
|
||||
@@ -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<void> {
|
||||
kbModalEl.style.display = '';
|
||||
logRAG('打开知识库管理');
|
||||
await refreshCollections();
|
||||
await populateEmbedModels();
|
||||
}
|
||||
|
||||
function closeKBModal(): void {
|
||||
kbModalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
async function refreshCollections(): Promise<void> {
|
||||
const vs = await initVectorStore();
|
||||
const collections = await vs.getCollections();
|
||||
const listEl = document.querySelector('#kbCollectionList')!;
|
||||
|
||||
if (collections.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库,点击上方「新建集合」创建</p>';
|
||||
currentColId = null;
|
||||
refreshDocList();
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = collections.map(c => `
|
||||
<div class="kb-collection-item ${c.id === currentColId ? 'active' : ''}" data-id="${c.id}">
|
||||
<div class="kb-col-info">
|
||||
<span class="kb-col-name">${escapeHtml(c.name)}</span>
|
||||
<span class="kb-col-stats">${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块</span>
|
||||
</div>
|
||||
<button class="kb-col-delete icon-btn" data-id="${c.id}" title="删除集合">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.kb-collection-item').forEach(el => {
|
||||
el.addEventListener('click', async (e) => {
|
||||
if ((e.target 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<void> {
|
||||
const docListEl = document.querySelector('#kbDocList')!;
|
||||
if (!currentColId) {
|
||||
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const docs = await getDocumentsInCollection(currentColId);
|
||||
if (docs.length === 0) {
|
||||
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">此集合暂无文档,上传文件开始构建知识库</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
docListEl.innerHTML = docs.map(d => `
|
||||
<div class="kb-doc-item">
|
||||
<span class="kb-doc-icon">📄</span>
|
||||
<div class="kb-doc-info">
|
||||
<span class="kb-doc-name">${escapeHtml(d.filename)}</span>
|
||||
<span class="kb-doc-meta">${d.chunkCount} 个分块</span>
|
||||
</div>
|
||||
<button class="kb-doc-delete icon-btn" data-docid="${d.docId}" title="删除文档">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const docId = (btn as HTMLElement).dataset.docid!;
|
||||
if (!await showConfirm('确定删除此文档?', '删除文档')) return;
|
||||
await removeDocumentFromKB(currentColId!, docId);
|
||||
showToast('文档已删除', 'success');
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createCollection(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement;
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const data = await api.listModels();
|
||||
select.innerHTML = '<option value="">选择嵌入模型...</option>';
|
||||
if (!data.models || data.models.length === 0) {
|
||||
select.innerHTML = '<option value="">未安装任何模型</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
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 = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
|
||||
}
|
||||
} catch (err) {
|
||||
logWarn('加载嵌入模型列表失败', (err as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>🧠 Agent 记忆</h3>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<label class="memory-toggle" title="自动记忆">
|
||||
<input type="checkbox" id="memoryModalToggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<button class="icon-btn" id="btnCloseMemoryModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="memory-layout">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="memory-sidebar">
|
||||
<div class="memory-stats" id="memoryStats">
|
||||
<div class="memory-stats-item">
|
||||
<span>总计</span>
|
||||
<span class="memory-stats-count" id="memStatTotal">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>📌 事实</span>
|
||||
<span class="memory-stats-count" id="memStatFact">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>⚙️ 偏好</span>
|
||||
<span class="memory-stats-count" id="memStatPref">0</span>
|
||||
</div>
|
||||
<div class="memory-stats-item">
|
||||
<span>📏 规则</span>
|
||||
<span class="memory-stats-count" id="memStatRule">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-categories" id="memoryCategories">
|
||||
<div class="memory-category-item active" data-type="">
|
||||
<span class="memory-category-icon">📋</span>
|
||||
<span>全部</span>
|
||||
<span class="memory-category-count" id="catCountAll">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="fact">
|
||||
<span class="memory-category-icon">📌</span>
|
||||
<span>事实</span>
|
||||
<span class="memory-category-count" id="catCountFact">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="preference">
|
||||
<span class="memory-category-icon">⚙️</span>
|
||||
<span>偏好</span>
|
||||
<span class="memory-category-count" id="catCountPref">0</span>
|
||||
</div>
|
||||
<div class="memory-category-item" data-type="rule">
|
||||
<span class="memory-category-icon">📏</span>
|
||||
<span>规则</span>
|
||||
<span class="memory-category-count" id="catCountRule">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧主区域 -->
|
||||
<div class="memory-main">
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-search-wrap">
|
||||
<input type="text" id="memorySearchLg" class="memory-search-input-lg" placeholder="搜索记忆(支持关键词和语义搜索)...">
|
||||
</div>
|
||||
<span id="memoryVectorBadge" class="memory-vector-badge disabled">关键词模式</span>
|
||||
<button class="btn btn-sm btn-outline" id="btnAddMemoryLg">+ 添加</button>
|
||||
<button class="btn btn-sm btn-danger-outline" id="btnClearMemoryLg">清空</button>
|
||||
</div>
|
||||
<div class="memory-list-lg" id="memoryListLg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<div class="memory-empty-lg">${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}</div>`;
|
||||
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 `
|
||||
<div class="memory-item-lg" data-id="${entry.id}">
|
||||
<div class="memory-item-lg-header">
|
||||
<span>${icon}</span>
|
||||
<span class="memory-item-lg-type">${typeName}</span>
|
||||
<span class="memory-item-lg-importance">${importanceStars}</span>
|
||||
<button class="memory-item-lg-delete" data-id="${entry.id}" title="删除">✕</button>
|
||||
</div>
|
||||
<div class="memory-item-lg-content">${escapeHtml(entry.content)}</div>
|
||||
<div class="memory-item-lg-meta">
|
||||
${entry.tags.length > 0 ? `<span class="memory-item-lg-tags">${entry.tags.map(t => `<span class="memory-tag-lg">${escapeHtml(t)}</span>`).join('')}</span>` : ''}
|
||||
<span class="memory-item-lg-time">${formatTime(entry.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="memory-panel-header">
|
||||
<h3>🧠 Agent 记忆</h3>
|
||||
<div class="memory-panel-actions">
|
||||
<label class="memory-toggle" title="自动记忆">
|
||||
<input type="checkbox" id="toggleMemoryPanelEnabled" checked>
|
||||
<span class="toggle-slider-sm"></span>
|
||||
</label>
|
||||
<button class="icon-btn sm" id="btnCloseMemoryPanel" title="关闭">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-panel-search">
|
||||
<input type="text" id="memorySearchInput" placeholder="搜索记忆..." class="memory-search-input">
|
||||
<select id="memoryTypeFilter" class="memory-type-filter">
|
||||
<option value="">全部类型</option>
|
||||
<option value="fact">📌 事实</option>
|
||||
<option value="preference">⚙️ 偏好</option>
|
||||
<option value="rule">📏 规则</option>
|
||||
<option value="episode">📝 事件</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="memory-panel-actions-row">
|
||||
<button class="btn btn-sm btn-outline" id="btnAddMemory">+ 添加记忆</button>
|
||||
<button class="btn btn-sm btn-danger-outline" id="btnClearMemories">清空</button>
|
||||
</div>
|
||||
<div class="memory-list" id="memoryList"></div>
|
||||
`;
|
||||
|
||||
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 = `<div class="memory-empty">${query ? '未找到匹配的记忆' : '暂无记忆,AI 会自动在对话中学习'}</div>`;
|
||||
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 `
|
||||
<div class="memory-item" data-id="${entry.id}">
|
||||
<div class="memory-item-header">
|
||||
<span class="memory-item-icon">${icon}</span>
|
||||
<span class="memory-item-type">${typeName}</span>
|
||||
<span class="memory-item-importance" title="重要性 ${entry.importance}/10">${importanceDots}</span>
|
||||
<button class="memory-item-delete" data-id="${entry.id}" title="删除">✕</button>
|
||||
</div>
|
||||
<div class="memory-item-content">${escapeHtml(entry.content)}</div>
|
||||
<div class="memory-item-meta">
|
||||
${entry.tags.length > 0 ? `<span class="memory-item-tags">${entry.tags.map(t => `<span class="memory-tag">${escapeHtml(t)}</span>`).join('')}</span>` : ''}
|
||||
<span class="memory-item-time">${formatTime(entry.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||||
updateConnectionInfo();
|
||||
updateRunningModels();
|
||||
populateMemoryEmbedModels();
|
||||
updateMemoryVectorStatus();
|
||||
}
|
||||
|
||||
export function closeSettingsModal(): void {
|
||||
@@ -269,6 +288,64 @@ async function exportAllSessions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function populateMemoryEmbedModels(): Promise<void> {
|
||||
const select = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const data = await api.listModels();
|
||||
select.innerHTML = '<option value="">未选择(仅关键词模式)</option>';
|
||||
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 = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
|
||||
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<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
Reference in New Issue
Block a user