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>`;
|
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) {
|
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||||
contentHtml += '<div class="tool-calls-container">';
|
contentHtml += '<div class="tool-calls-container">';
|
||||||
for (const tc of msg.toolCalls) {
|
for (const tc of msg.toolCalls) {
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens
|
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens
|
||||||
} from './chat-area.js';
|
} from './chat-area.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
|
|
||||||
import { ChatDB } from '../db/chat-db.js';
|
import { ChatDB } from '../db/chat-db.js';
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
import { runAgentLoop } from '../services/agent-engine.js';
|
import { runAgentLoop } from '../services/agent-engine.js';
|
||||||
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
|
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
|
||||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||||
import { logInfo, logStream, logError, logSuccess, logWarn } from '../services/log-service.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 chatInputEl: HTMLTextAreaElement;
|
||||||
let btnSendEl: HTMLButtonElement;
|
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) => {
|
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) {
|
||||||
if (chunk.message.content) assistantContent += chunk.message.content;
|
if (chunk.message.content) assistantContent += chunk.message.content;
|
||||||
const think = chunk.message.thinking || chunk.message.reasoning_content;
|
const think = chunk.message.thinking || chunk.message.reasoning_content;
|
||||||
@@ -487,8 +432,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(modelName && { model: modelName }),
|
...(modelName && { model: modelName }),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
|
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration })
|
||||||
...(ragSources && { ragSources })
|
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
@@ -500,20 +444,6 @@ export async function sendMessage(): Promise<void> {
|
|||||||
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || undefined);
|
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();
|
await saveCurrentSession();
|
||||||
updateTotalTokens();
|
updateTotalTokens();
|
||||||
|
|
||||||
@@ -532,8 +462,6 @@ export async function sendMessage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.querySelectorAll('.rag-status').forEach(el => el.remove());
|
|
||||||
|
|
||||||
if ((err as Error).name === 'AbortError') {
|
if ((err as Error).name === 'AbortError') {
|
||||||
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
||||||
const loadingDots = placeholder?.querySelector('.loading-dots');
|
const loadingDots = placeholder?.querySelector('.loading-dots');
|
||||||
@@ -596,7 +524,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
|
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
|
||||||
logError('连接失败', 'Ollama 服务不可达');
|
logError('连接失败', 'Ollama 服务不可达');
|
||||||
} else if ((err as Error).message.includes('400')) {
|
} else if ((err as Error).message.includes('400')) {
|
||||||
errMsg = '❌ 请求参数错误,可能是知识库内容超出模型上下文限制';
|
errMsg = '❌ 请求参数错误,可能是上下文过长';
|
||||||
logError('请求参数错误 (400)', (err as Error).message);
|
logError('请求参数错误 (400)', (err as Error).message);
|
||||||
} else if ((err as Error).message.includes('500')) {
|
} else if ((err as Error).message.includes('500')) {
|
||||||
errMsg = '❌ Ollama 服务器错误,请检查 Ollama 日志';
|
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 { state, KEYS } from '../state/state.js';
|
||||||
import { debounce } from '../utils/utils.js';
|
import { debounce } from '../utils/utils.js';
|
||||||
import { encryptData, decryptData } from '../services/crypto.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 { setToolEnabled } from '../services/tool-registry.js';
|
||||||
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
|
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
|
||||||
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.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 inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
|
||||||
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
|
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 || '';
|
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||||||
updateConnectionInfo();
|
updateConnectionInfo();
|
||||||
updateRunningModels();
|
updateRunningModels();
|
||||||
|
populateMemoryEmbedModels();
|
||||||
|
updateMemoryVectorStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSettingsModal(): void {
|
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> {
|
async function importSessions(filePath: string): Promise<void> {
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|||||||
+11
-65
@@ -46,14 +46,6 @@
|
|||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" id="btnKB" title="知识库">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
|
||||||
<line x1="8" y1="7" x2="16" y2="7"/>
|
|
||||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" id="btnMemory" title="Agent 记忆">
|
<button class="icon-btn" id="btnMemory" title="Agent 记忆">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
|
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
|
||||||
@@ -93,7 +85,6 @@
|
|||||||
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
|
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
|
||||||
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
|
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
|
||||||
<span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span>
|
<span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span>
|
||||||
<span class="model-badge rag-badge" id="badgeRAG" style="display:none;">📚 RAG</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -319,9 +310,16 @@
|
|||||||
<div class="tool-item"><span>📌 事实</span><span class="text-muted">项目信息、身份背景</span></div>
|
<div class="tool-item"><span>📌 事实</span><span class="text-muted">项目信息、身份背景</span></div>
|
||||||
<div class="tool-item"><span>⚙️ 偏好</span><span class="text-muted">语言风格、技术栈偏好</span></div>
|
<div class="tool-item"><span>⚙️ 偏好</span><span class="text-muted">语言风格、技术栈偏好</span></div>
|
||||||
<div class="tool-item"><span>📏 规则</span><span class="text-muted">编码规范、输出格式要求</span></div>
|
<div class="tool-item"><span>📏 规则</span><span class="text-muted">编码规范、输出格式要求</span></div>
|
||||||
<div class="tool-item"><span>📝 事件</span><span class="text-muted">完成的任务、结论</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">🧠 向量记忆引擎</label>
|
||||||
|
<p class="text-muted" style="font-size:11px;margin-bottom:8px;">选择嵌入模型以启用向量语义记忆。未选择时仅使用关键词匹配。</p>
|
||||||
|
<select class="setting-input" id="selectMemoryEmbedModel">
|
||||||
|
<option value="">未选择(仅关键词模式)</option>
|
||||||
|
</select>
|
||||||
|
<div id="memoryVectorStatus" class="text-muted" style="font-size:11px;margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">显存管理</label>
|
<label class="setting-label">显存管理</label>
|
||||||
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
|
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
|
||||||
@@ -373,69 +371,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools / 📚 RAG)</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
|
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
|
||||||
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 设置中开启,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,内容以代码块发送给模型</li><li><strong>上下文长度</strong> — 设置中调整 <code>num_ctx</code>,越大记忆越长,消耗显存越多</li></ul></div>
|
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 设置中开启,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,内容以代码块发送给模型</li><li><strong>上下文长度</strong> — 设置中调整 <code>num_ctx</code>,越大记忆越长,消耗显存越多</li></ul></div>
|
||||||
<div class="help-section"><h4>🔧 Tool Calling(AI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>7 个工具</strong>:<code>read_file</code> / <code>write_file</code> / <code>list_directory</code> / <code>search_files</code> / <code>create_directory</code> / <code>delete_file</code> / <code>run_command</code></li><li>写入/删除/命令执行等高风险操作会弹出<strong>确认对话框</strong>,你可以批准或拒绝</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error)</li><li>最多自动循环 <strong>10 轮</strong>工具调用,也可随时中断</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral)</li></ul></div>
|
<div class="help-section"><h4>🔧 Tool Calling(AI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>7 个工具</strong>:<code>read_file</code> / <code>write_file</code> / <code>list_directory</code> / <code>search_files</code> / <code>create_directory</code> / <code>delete_file</code> / <code>run_command</code></li><li>写入/删除/命令执行等高风险操作会弹出<strong>确认对话框</strong>,你可以批准或拒绝</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error)</li><li>最多自动循环 <strong>10 轮</strong>工具调用,也可随时中断</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral)</li></ul></div>
|
||||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则/事件),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li>记忆存储在本地 IndexedDB,不会上传到任何服务器</li></ul></div>
|
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>记忆存储在本地 IndexedDB,不会上传到任何服务器</li></ul></div>
|
||||||
<div class="help-section"><h4>📚 知识库 (RAG)</h4><ul><li>点击顶部 📚 按钮打开知识库管理</li><li>创建集合 → 选择嵌入模型(推荐 <code>nomic-embed-text</code>)</li><li>上传文档,自动分块并生成向量索引</li><li>开启 RAG 检索后,聊天时自动检索相关文档增强回答</li></ul></div>
|
|
||||||
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),多终端 Tab</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li></ul></li><li>工作空间面板宽度可通过拖拽左边缘调整(300–700px)</li><li>工作空间目录可在设置中修改</li></ul></div>
|
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),多终端 Tab</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li></ul></li><li>工作空间面板宽度可通过拖拽左边缘调整(300–700px)</li><li>工作空间目录可在设置中修改</li></ul></div>
|
||||||
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 IndexedDB</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 Markdown / HTML / TXT / <code>.metona</code> 加密备份</li></ul></div>
|
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 IndexedDB</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 Markdown / HTML / TXT / <code>.metona</code> 加密备份</li></ul></div>
|
||||||
<div class="help-section"><h4>⚡ 快捷键</h4><table class="help-shortcuts"><tr><td><kbd>Enter</kbd></td><td>发送消息</td></tr><tr><td><kbd>Shift + Enter</kbd></td><td>换行</td></tr><tr><td><kbd>Ctrl + N</kbd></td><td>新建会话</td></tr><tr><td><kbd>Ctrl + K</kbd></td><td>知识库管理</td></tr><tr><td><kbd>Ctrl + M</kbd></td><td>记忆面板</td></tr><tr><td><kbd>Esc</kbd></td><td>关闭弹窗</td></tr></table></div>
|
<div class="help-section"><h4>⚡ 快捷键</h4><table class="help-shortcuts"><tr><td><kbd>Enter</kbd></td><td>发送消息</td></tr><tr><td><kbd>Shift + Enter</kbd></td><td>换行</td></tr><tr><td><kbd>Ctrl + N</kbd></td><td>新建会话</td></tr><tr><td><kbd>Ctrl + M</kbd></td><td>记忆管理</td></tr><tr><td><kbd>Esc</kbd></td><td>关闭弹窗</td></tr></table></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════ 知识库管理模态框 ═══════════════ -->
|
|
||||||
<div class="modal-overlay" id="kbModal" style="display:none;">
|
|
||||||
<div class="modal modal-lg">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>📚 知识库 (RAG)</h3>
|
|
||||||
<button class="icon-btn" id="btnCloseKB">
|
|
||||||
<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 class="modal-body">
|
|
||||||
<div class="kb-layout">
|
|
||||||
<div class="kb-sidebar">
|
|
||||||
<div class="kb-new-collection">
|
|
||||||
<input class="setting-input" id="inputKBName" type="text" placeholder="新建知识库名称...">
|
|
||||||
<button class="btn btn-sm btn-primary" id="btnNewCollection">创建</button>
|
|
||||||
</div>
|
|
||||||
<div class="kb-collection-list" id="kbCollectionList">
|
|
||||||
<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kb-main">
|
|
||||||
<div class="kb-toolbar">
|
|
||||||
<button class="btn btn-sm btn-outline" id="btnUploadDoc">📄 上传文档</button>
|
|
||||||
<span id="kbProgress" style="display:none;" class="kb-progress">
|
|
||||||
<span class="spinner"></span>
|
|
||||||
<span id="kbProgressText">处理中...</span>
|
|
||||||
</span>
|
|
||||||
<select class="kb-embed-select" id="selectEmbedModel">
|
|
||||||
<option value="">嵌入模型...</option>
|
|
||||||
</select>
|
|
||||||
<div class="kb-rag-toggle">
|
|
||||||
<label class="setting-label" style="margin:0;font-size:12px;white-space:nowrap;">RAG</label>
|
|
||||||
<label class="toggle-label" style="margin:0;">
|
|
||||||
<input type="checkbox" id="toggleRAG">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kb-doc-list" id="kbDocList">
|
|
||||||
<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
|
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
|
||||||
<div class="lightbox-overlay" id="lightbox" style="display:none;">
|
<div class="lightbox-overlay" id="lightbox" style="display:none;">
|
||||||
<button class="lightbox-close" id="lightboxClose">✕</button>
|
<button class="lightbox-close" id="lightboxClose">✕</button>
|
||||||
|
|||||||
+4
-14
@@ -19,12 +19,10 @@ import { initChatArea, renderMessages, clearMessages, enableAutoScroll, updateTo
|
|||||||
import { initInputArea } from './components/input-area.js';
|
import { initInputArea } from './components/input-area.js';
|
||||||
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
||||||
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
||||||
import { initKBModal } from './components/kb-modal.js';
|
import { initMemoryModal } from './components/memory-modal.js';
|
||||||
import { initVectorStore } from './services/rag.js';
|
|
||||||
import { initMemoryManager } from './services/memory-manager.js';
|
import { initMemoryManager } from './services/memory-manager.js';
|
||||||
import { setToolEnabled } from './services/tool-registry.js';
|
import { setToolEnabled } from './services/tool-registry.js';
|
||||||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||||
import { initMemoryPanel } from './components/memory-panel.js';
|
|
||||||
import { initWorkspacePanel } from './components/workspace-panel.js';
|
import { initWorkspacePanel } from './components/workspace-panel.js';
|
||||||
import { initLogPanel, addLog } from './services/log-service.js';
|
import { initLogPanel, addLog } from './services/log-service.js';
|
||||||
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
||||||
@@ -204,10 +202,9 @@ async function init(): Promise<void> {
|
|||||||
initInputArea();
|
initInputArea();
|
||||||
initSettingsModal();
|
initSettingsModal();
|
||||||
initHistoryModal();
|
initHistoryModal();
|
||||||
initKBModal();
|
|
||||||
initHelpModal();
|
initHelpModal();
|
||||||
initToolConfirmModal();
|
initToolConfirmModal();
|
||||||
initMemoryPanel();
|
initMemoryModal();
|
||||||
initWorkspacePanel();
|
initWorkspacePanel();
|
||||||
setupDesktopIntegration();
|
setupDesktopIntegration();
|
||||||
|
|
||||||
@@ -215,7 +212,6 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
await checkConnection();
|
await checkConnection();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
await initVectorStore();
|
|
||||||
await initMemoryManager();
|
await initMemoryManager();
|
||||||
logInit('所有组件已就绪');
|
logInit('所有组件已就绪');
|
||||||
|
|
||||||
@@ -291,16 +287,10 @@ function bindGlobalEvents(): void {
|
|||||||
closeHistoryModal();
|
closeHistoryModal();
|
||||||
closeLightbox();
|
closeLightbox();
|
||||||
(document.querySelector('#helpModal') as HTMLElement).style.display = 'none';
|
(document.querySelector('#helpModal') as HTMLElement).style.display = 'none';
|
||||||
(document.querySelector('#kbModal') as HTMLElement).style.display = 'none';
|
(document.querySelector('#memoryModal') as HTMLElement).style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+K — 知识库管理
|
// Ctrl+M — 记忆管理
|
||||||
if (e.ctrlKey && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
document.querySelector('#btnKB')?.dispatchEvent(new Event('click'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+M — 记忆面板
|
|
||||||
if (e.ctrlKey && e.key === 'm') {
|
if (e.ctrlKey && e.key === 'm') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.querySelector('#btnMemory')?.dispatchEvent(new Event('click'));
|
document.querySelector('#btnMemory')?.dispatchEvent(new Event('click'));
|
||||||
|
|||||||
@@ -1,35 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* MemoryManager - Agent 记忆系统
|
* MemoryManager - Agent 记忆系统(长效向量记忆)
|
||||||
* 自动提取 → 存储 → 检索 → 注入上下文
|
* 自动提取 → 存储 → 检索 → 注入上下文
|
||||||
*
|
*
|
||||||
* 记忆类型:
|
* 记忆类型:
|
||||||
* - fact: 用户告诉我的事实(项目信息、个人背景等)
|
* - fact: 用户告诉我的事实(项目信息、个人背景等)
|
||||||
* - preference: 用户偏好(语言、风格、格式习惯)
|
* - preference: 用户偏好(语言、风格、格式习惯)
|
||||||
* - rule: 应遵守的规则(命名规范、输出格式要求)
|
* - rule: 应遵守的规则(命名规范、输出格式要求)
|
||||||
* - episode: 重要事件(完成了什么、得到了什么结论)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { generateId } from '../utils/utils.js';
|
import { generateId } from '../utils/utils.js';
|
||||||
|
import {
|
||||||
|
initMemoryVectorStore, getOrCreateMemoryCollection,
|
||||||
|
embedMemoryEntry, addMemoryVector, updateMemoryVector,
|
||||||
|
deleteMemoryVector, searchMemoriesByVector, reindexAllMemories,
|
||||||
|
getMemoryCollectionId
|
||||||
|
} from './vector-memory.js';
|
||||||
import { logMemory, logDebug, logWarn } from './log-service.js';
|
import { logMemory, logDebug, logWarn } from './log-service.js';
|
||||||
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js';
|
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js';
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
fact: '📌',
|
fact: '📌',
|
||||||
preference: '⚙️',
|
preference: '⚙️',
|
||||||
rule: '📏',
|
rule: '📏'
|
||||||
episode: '📝'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_NAMES: Record<string, string> = {
|
const TYPE_NAMES: Record<string, string> = {
|
||||||
fact: '事实',
|
fact: '事实',
|
||||||
preference: '偏好',
|
preference: '偏好',
|
||||||
rule: '规则',
|
rule: '规则'
|
||||||
episode: '事件'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let memoryCache: MemoryEntry[] = [];
|
let memoryCache: MemoryEntry[] = [];
|
||||||
let memoryEnabled = true;
|
let memoryEnabled = true;
|
||||||
|
let embeddingModel = '';
|
||||||
|
|
||||||
|
// ── 初始化 ──
|
||||||
|
|
||||||
export async function initMemoryManager(): Promise<void> {
|
export async function initMemoryManager(): Promise<void> {
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
@@ -38,12 +44,70 @@ export async function initMemoryManager(): Promise<void> {
|
|||||||
memoryEnabled = await db.getSetting('memoryEnabled', true);
|
memoryEnabled = await db.getSetting('memoryEnabled', true);
|
||||||
state.set('memoryEnabled', memoryEnabled);
|
state.set('memoryEnabled', memoryEnabled);
|
||||||
|
|
||||||
|
// 加载嵌入模型设置
|
||||||
|
embeddingModel = await db.getSetting('embeddingModel', '');
|
||||||
|
state.set('embeddingModel', embeddingModel);
|
||||||
|
|
||||||
memoryCache = await db.getAllMemories();
|
memoryCache = await db.getAllMemories();
|
||||||
state.set('memoryEntries', memoryCache);
|
state.set('memoryEntries', memoryCache);
|
||||||
|
|
||||||
logMemory(`初始化完成, 加载 ${memoryCache.length} 条`);
|
// 如果有嵌入模型,初始化向量存储
|
||||||
|
if (embeddingModel) {
|
||||||
|
try {
|
||||||
|
await initMemoryVectorStore();
|
||||||
|
logMemory('向量存储已初始化');
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量存储初始化失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logMemory(`初始化完成, 加载 ${memoryCache.length} 条${embeddingModel ? ', 向量记忆已启用' : ', 仅关键词模式'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 嵌入模型管理 ──
|
||||||
|
|
||||||
|
export function getEmbeddingModel(): string {
|
||||||
|
return embeddingModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setEmbeddingModel(model: string): Promise<void> {
|
||||||
|
const oldModel = embeddingModel;
|
||||||
|
embeddingModel = model;
|
||||||
|
state.set('embeddingModel', model);
|
||||||
|
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (db) await db.saveSetting('embeddingModel', model);
|
||||||
|
|
||||||
|
if (model && model !== oldModel) {
|
||||||
|
// 嵌入模型变化,重新索引所有记忆
|
||||||
|
logMemory(`嵌入模型变更: ${oldModel || '(无)'} → ${model}`);
|
||||||
|
await reindexMemories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVectorMemoryEnabled(): boolean {
|
||||||
|
return !!embeddingModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 重新索引所有记忆 ──
|
||||||
|
|
||||||
|
export async function reindexMemories(): Promise<void> {
|
||||||
|
if (!embeddingModel || memoryCache.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initMemoryVectorStore();
|
||||||
|
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
|
||||||
|
await reindexAllMemories(memoryCache, embeddingModel, colId, (done, total) => {
|
||||||
|
logMemory(`重新索引进度: ${done}/${total}`);
|
||||||
|
});
|
||||||
|
logMemory('向量索引重建完成');
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量索引重建失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础管理 ──
|
||||||
|
|
||||||
export function isMemoryEnabled(): boolean {
|
export function isMemoryEnabled(): boolean {
|
||||||
return memoryEnabled;
|
return memoryEnabled;
|
||||||
}
|
}
|
||||||
@@ -67,11 +131,52 @@ export function getTypeName(type: string): string {
|
|||||||
return TYPE_NAMES[type] || type;
|
return TYPE_NAMES[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 记忆检索(关键词 + 标签 + 重要性加权)──
|
// ── 记忆检索(向量优先,关键词补充)──
|
||||||
|
|
||||||
export function searchMemories(query: string, limit = 8): MemorySearchResult[] {
|
export function searchMemories(query: string, limit = 8): MemorySearchResult[] {
|
||||||
if (!memoryEnabled || memoryCache.length === 0) return [];
|
if (!memoryEnabled || memoryCache.length === 0) return [];
|
||||||
|
|
||||||
|
// 始终执行关键词搜索(即时结果)
|
||||||
|
const keywordResults = searchMemoriesByKeyword(query, limit);
|
||||||
|
|
||||||
|
// 如果启用了向量记忆,异步触发向量搜索(结果在下次调用时更新)
|
||||||
|
if (embeddingModel) {
|
||||||
|
triggerVectorSearch(query, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywordResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向量搜索异步缓存
|
||||||
|
const vectorSearchCache = new Map<string, MemorySearchResult[]>();
|
||||||
|
let vectorSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function triggerVectorSearch(query: string, limit: number): void {
|
||||||
|
if (vectorSearchTimer) clearTimeout(vectorSearchTimer);
|
||||||
|
vectorSearchTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const colId = getMemoryCollectionId();
|
||||||
|
if (!colId) return;
|
||||||
|
const results = await searchMemoriesByVector(query, colId, limit);
|
||||||
|
const memoryResults: MemorySearchResult[] = results.map(r => {
|
||||||
|
const entry = memoryCache.find(e => e.id === r.docId);
|
||||||
|
if (!entry) return null;
|
||||||
|
return { ...entry, score: r.score };
|
||||||
|
}).filter(Boolean) as MemorySearchResult[];
|
||||||
|
vectorSearchCache.set(query, memoryResults);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量搜索失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出:获取向量搜索结果(供 UI 使用)
|
||||||
|
export function getVectorSearchResults(query: string): MemorySearchResult[] {
|
||||||
|
return vectorSearchCache.get(query) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
function searchMemoriesByKeyword(query: string, limit: number): MemorySearchResult[] {
|
||||||
const queryLower = query.toLowerCase();
|
const queryLower = query.toLowerCase();
|
||||||
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
|
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
|
||||||
|
|
||||||
@@ -155,6 +260,20 @@ export async function addMemory(data: {
|
|||||||
if (db) await db.saveMemory(entry);
|
if (db) await db.saveMemory(entry);
|
||||||
logMemory(`新增: ${data.type}`, entry.content.slice(0, 60));
|
logMemory(`新增: ${data.type}`, entry.content.slice(0, 60));
|
||||||
|
|
||||||
|
// 向量存储
|
||||||
|
if (embeddingModel) {
|
||||||
|
try {
|
||||||
|
await initMemoryVectorStore();
|
||||||
|
const colId = getMemoryCollectionId() || (await getOrCreateMemoryCollection(embeddingModel)).id;
|
||||||
|
const embedding = await embedMemoryEntry(entry, embeddingModel);
|
||||||
|
entry.embedding = embedding;
|
||||||
|
await addMemoryVector(entry, embedding, colId);
|
||||||
|
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量存储失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +288,21 @@ export async function updateMemory(id: string, updates: Partial<MemoryEntry>): P
|
|||||||
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) await db.saveMemory(memoryCache[idx]);
|
if (db) await db.saveMemory(memoryCache[idx]);
|
||||||
|
|
||||||
|
// 更新向量
|
||||||
|
if (embeddingModel && (updates.content || updates.type || updates.tags)) {
|
||||||
|
try {
|
||||||
|
const colId = getMemoryCollectionId();
|
||||||
|
if (colId) {
|
||||||
|
const embedding = await embedMemoryEntry(memoryCache[idx], embeddingModel);
|
||||||
|
memoryCache[idx].embedding = embedding;
|
||||||
|
await updateMemoryVector(memoryCache[idx], embedding, colId);
|
||||||
|
if (db) await db.saveMemory(memoryCache[idx]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量更新失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 记忆删除 ──
|
// ── 记忆删除 ──
|
||||||
@@ -179,6 +313,17 @@ export async function deleteMemory(id: string): Promise<void> {
|
|||||||
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) await db.deleteMemory(id);
|
if (db) await db.deleteMemory(id);
|
||||||
|
|
||||||
|
// 删除向量
|
||||||
|
if (embeddingModel) {
|
||||||
|
try {
|
||||||
|
const colId = getMemoryCollectionId();
|
||||||
|
if (colId) await deleteMemoryVector(id, colId);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量删除失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logMemory(`删除`, id);
|
logMemory(`删除`, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +335,25 @@ export async function clearAllMemories(): Promise<void> {
|
|||||||
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) await db.clearAllMemories();
|
if (db) await db.clearAllMemories();
|
||||||
|
|
||||||
|
// 清空向量集合
|
||||||
|
if (embeddingModel) {
|
||||||
|
try {
|
||||||
|
await initMemoryVectorStore();
|
||||||
|
const { id: colId } = await getOrCreateMemoryCollection(embeddingModel);
|
||||||
|
const { getMemoryVectorStore } = await import('./vector-memory.js');
|
||||||
|
const vs = getMemoryVectorStore();
|
||||||
|
if (vs) {
|
||||||
|
await vs.deleteCollection(colId);
|
||||||
|
// 重新创建空集合
|
||||||
|
const { setMemoryCollectionId } = await import('./vector-memory.js');
|
||||||
|
setMemoryCollectionId(null);
|
||||||
|
await getOrCreateMemoryCollection(embeddingModel);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('向量集合清空失败', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 标记记忆被使用 ──
|
// ── 标记记忆被使用 ──
|
||||||
@@ -240,13 +404,12 @@ export async function extractMemoriesFromConversation(
|
|||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!memoryEnabled) return 0;
|
if (!memoryEnabled) return 0;
|
||||||
if (messages.length < 3) return 0; // 至少一轮完整对话
|
if (messages.length < 3) return 0;
|
||||||
|
|
||||||
const api = state.get<OllamaAPI>(KEYS.API);
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
const model = state.get<string>('_defaultModel', '');
|
const model = state.get<string>('_defaultModel', '');
|
||||||
if (!api || !model) return 0;
|
if (!api || !model) return 0;
|
||||||
|
|
||||||
// 取最近 20 条消息作为提取素材
|
|
||||||
const recentMessages = messages.slice(-20);
|
const recentMessages = messages.slice(-20);
|
||||||
const conversationText = recentMessages
|
const conversationText = recentMessages
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
@@ -277,7 +440,6 @@ ${conversationText.slice(0, 4000)}
|
|||||||
- fact: 关于用户的事实(项目、身份、背景、习惯)
|
- fact: 关于用户的事实(项目、身份、背景、习惯)
|
||||||
- preference: 用户偏好(语言风格、输出格式、技术栈偏好)
|
- preference: 用户偏好(语言风格、输出格式、技术栈偏好)
|
||||||
- rule: 用户要求遵守的规则(编码规范、输出要求)
|
- rule: 用户要求遵守的规则(编码规范、输出要求)
|
||||||
- episode: 重要事件(完成的任务、达成的结论)
|
|
||||||
|
|
||||||
提取规则:
|
提取规则:
|
||||||
1. 只提取真正有价值、值得跨会话记住的信息
|
1. 只提取真正有价值、值得跨会话记住的信息
|
||||||
@@ -308,8 +470,9 @@ ${conversationText.slice(0, 4000)}
|
|||||||
const currentSession = state.get(KEYS.CURRENT_SESSION);
|
const currentSession = state.get(KEYS.CURRENT_SESSION);
|
||||||
for (const entry of parsed.entries) {
|
for (const entry of parsed.entries) {
|
||||||
if (!entry.content || entry.content.length < 5) continue;
|
if (!entry.content || entry.content.length < 5) continue;
|
||||||
|
const validType = (['fact', 'preference', 'rule'] as const).includes(entry.type as any) ? entry.type : 'fact';
|
||||||
await addMemory({
|
await addMemory({
|
||||||
type: entry.type || 'fact',
|
type: validType as any,
|
||||||
content: entry.content,
|
content: entry.content,
|
||||||
importance: Math.min(10, Math.max(1, entry.importance || 5)),
|
importance: Math.min(10, Math.max(1, entry.importance || 5)),
|
||||||
tags: entry.tags || [],
|
tags: entry.tags || [],
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* RAG - 检索增强生成管线
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
|
||||||
import { VectorStore } from './vector-store.js';
|
|
||||||
import { chunkText, createChunkMetadata } from './document-processor.js';
|
|
||||||
import { logRAG, logDebug, logError } from './log-service.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();
|
|
||||||
logRAG('向量存储已初始化');
|
|
||||||
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;
|
|
||||||
logDebug(`嵌入批次`, `${texts.length} 个文本, 模型: ${model}`);
|
|
||||||
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('文档内容为空,无法处理');
|
|
||||||
logRAG(`分块完成: ${filename}`, `${textChunks.length} 个分块`);
|
|
||||||
|
|
||||||
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);
|
|
||||||
logRAG(`删除文档`, `${docId} (${docVectors.length} 个分块)`);
|
|
||||||
|
|
||||||
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('未配置嵌入模型');
|
|
||||||
|
|
||||||
logRAG('开始检索', col.name);
|
|
||||||
const queryEmbedding = await embedText(query, embedModel);
|
|
||||||
const results = await vs.search(colId, queryEmbedding, topK);
|
|
||||||
if (results.length === 0) return { context: '', results: [] };
|
|
||||||
logRAG(`检索完成`, `${results.length} 个相关片段`);
|
|
||||||
|
|
||||||
const contextParts = results.map((r, i) =>
|
|
||||||
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
|
|
||||||
);
|
|
||||||
const context = contextParts.join('\n\n---\n\n');
|
|
||||||
|
|
||||||
return { context, results };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRagSystemPrompt(context: string, originalSystemPrompt = ''): string {
|
|
||||||
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
|
|
||||||
|
|
||||||
=== 检索到的相关内容 ===
|
|
||||||
${context}
|
|
||||||
=== 内容结束 ===
|
|
||||||
|
|
||||||
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
|
|
||||||
|
|
||||||
if (originalSystemPrompt) {
|
|
||||||
return originalSystemPrompt + '\n\n' + ragPrompt;
|
|
||||||
}
|
|
||||||
return ragPrompt;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* VectorMemory - 记忆向量存储与检索
|
||||||
|
* 替代原 rag.ts,专为记忆系统提供向量能力
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state, KEYS } from '../state/state.js';
|
||||||
|
import { VectorStore } from './vector-store.js';
|
||||||
|
import { logMemory, logDebug, logError } from './log-service.js';
|
||||||
|
import type { MemoryEntry, OllamaAPI, VectorItem, SearchResult } from '../types.js';
|
||||||
|
|
||||||
|
const MEMORY_COLLECTION_NAME = '记忆向量索引';
|
||||||
|
|
||||||
|
let vectorStore: VectorStore | null = null;
|
||||||
|
let memoryCollectionId: string | null = null;
|
||||||
|
|
||||||
|
// ── 初始化 ──
|
||||||
|
|
||||||
|
export async function initMemoryVectorStore(): Promise<VectorStore> {
|
||||||
|
if (vectorStore) return vectorStore;
|
||||||
|
vectorStore = new VectorStore();
|
||||||
|
await vectorStore.init();
|
||||||
|
logDebug('记忆向量存储已初始化');
|
||||||
|
return vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemoryVectorStore(): VectorStore | null {
|
||||||
|
return vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 集合管理 ──
|
||||||
|
|
||||||
|
export async function getOrCreateMemoryCollection(embedModel: string): Promise<{ id: string; isNew: boolean }> {
|
||||||
|
const vs = await initMemoryVectorStore();
|
||||||
|
|
||||||
|
// 如果已有缓存的集合ID,验证它是否存在
|
||||||
|
if (memoryCollectionId) {
|
||||||
|
const existing = await vs.getCollection(memoryCollectionId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.embeddingModel !== embedModel) {
|
||||||
|
existing.embeddingModel = embedModel;
|
||||||
|
await vs.updateCollection(existing);
|
||||||
|
}
|
||||||
|
return { id: memoryCollectionId, isNew: false };
|
||||||
|
}
|
||||||
|
memoryCollectionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索现有集合
|
||||||
|
const collections = await vs.getCollections();
|
||||||
|
const found = collections.find(c => c.name === MEMORY_COLLECTION_NAME);
|
||||||
|
if (found) {
|
||||||
|
memoryCollectionId = found.id;
|
||||||
|
if (found.embeddingModel !== embedModel) {
|
||||||
|
found.embeddingModel = embedModel;
|
||||||
|
await vs.updateCollection(found);
|
||||||
|
}
|
||||||
|
return { id: found.id, isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新集合
|
||||||
|
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
|
||||||
|
memoryCollectionId = col.id;
|
||||||
|
logMemory('创建记忆向量集合', col.id);
|
||||||
|
return { id: col.id, isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemoryCollectionId(): string | null {
|
||||||
|
return memoryCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMemoryCollectionId(id: string | null): void {
|
||||||
|
memoryCollectionId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 文本嵌入 ──
|
||||||
|
|
||||||
|
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 embedMemoryEntry(entry: MemoryEntry, model: string): Promise<number[]> {
|
||||||
|
const text = `[${entry.type}] ${entry.content} ${entry.tags.join(' ')}`;
|
||||||
|
return embedText(text, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 添加记忆向量 ──
|
||||||
|
|
||||||
|
export async function addMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
|
||||||
|
const vs = await initMemoryVectorStore();
|
||||||
|
const item: VectorItem = {
|
||||||
|
id: `vec_${entry.id}`,
|
||||||
|
collectionId: colId,
|
||||||
|
docId: entry.id,
|
||||||
|
filename: entry.type,
|
||||||
|
chunkIndex: 0,
|
||||||
|
text: entry.content,
|
||||||
|
charCount: entry.content.length,
|
||||||
|
embedding
|
||||||
|
};
|
||||||
|
await vs.addVectors([item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 更新记忆向量 ──
|
||||||
|
|
||||||
|
export async function updateMemoryVector(entry: MemoryEntry, embedding: number[], colId: string): Promise<void> {
|
||||||
|
// 先删除旧的,再添加新的
|
||||||
|
await deleteMemoryVector(entry.id, colId);
|
||||||
|
await addMemoryVector(entry, embedding, colId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 删除记忆向量 ──
|
||||||
|
|
||||||
|
export async function deleteMemoryVector(entryId: string, colId: string): Promise<void> {
|
||||||
|
const vs = await initMemoryVectorStore();
|
||||||
|
await vs.deleteVectorsByDocument(colId, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 向量搜索记忆 ──
|
||||||
|
|
||||||
|
export async function searchMemoriesByVector(query: string, colId: string, topK = 8): Promise<SearchResult[]> {
|
||||||
|
const vs = await initMemoryVectorStore();
|
||||||
|
const col = await vs.getCollection(colId);
|
||||||
|
if (!col || !col.embeddingModel) return [];
|
||||||
|
|
||||||
|
const queryEmbedding = await embedText(query, col.embeddingModel);
|
||||||
|
const results = await vs.search(colId, queryEmbedding, topK);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 重新索引所有记忆 ──
|
||||||
|
|
||||||
|
export async function reindexAllMemories(
|
||||||
|
entries: MemoryEntry[],
|
||||||
|
embedModel: string,
|
||||||
|
colId: string,
|
||||||
|
onProgress?: (done: number, total: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const vs = await initMemoryVectorStore();
|
||||||
|
|
||||||
|
// 清空集合中的旧向量
|
||||||
|
const oldVectors = await vs.getVectorsByCollection(colId);
|
||||||
|
if (oldVectors.length > 0) {
|
||||||
|
await vs.deleteCollection(colId);
|
||||||
|
const col = await vs.createCollection(MEMORY_COLLECTION_NAME, embedModel);
|
||||||
|
memoryCollectionId = col.id;
|
||||||
|
colId = col.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
logMemory(`开始重新索引: ${entries.length} 条记忆`);
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
try {
|
||||||
|
const embedding = await embedMemoryEntry(entry, embedModel);
|
||||||
|
await addMemoryVector(entry, embedding, colId);
|
||||||
|
} catch (err) {
|
||||||
|
logError(`索引记忆失败: ${entry.id}`, (err as Error).message);
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(i + 1, entries.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
logMemory(`重新索引完成: ${entries.length} 条`);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export class VectorStore {
|
|||||||
|
|
||||||
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
|
async createCollection(name: string, embeddingModel = ''): Promise<VectorCollection> {
|
||||||
const col: VectorCollection = {
|
const col: VectorCollection = {
|
||||||
id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
name, embeddingModel,
|
name, embeddingModel,
|
||||||
docCount: 0, chunkCount: 0,
|
docCount: 0, chunkCount: 0,
|
||||||
createdAt: Date.now(), updatedAt: Date.now()
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
|||||||
+316
-538
@@ -500,12 +500,6 @@ html, body {
|
|||||||
border-color: rgba(108, 203, 95, 0.15);
|
border-color: rgba(108, 203, 95, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rag-badge {
|
|
||||||
color: #B388FF;
|
|
||||||
background: rgba(179, 136, 255, 0.06);
|
|
||||||
border-color: rgba(179, 136, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-badge {
|
.tools-badge {
|
||||||
color: #FFB74D;
|
color: #FFB74D;
|
||||||
background: rgba(255, 183, 77, 0.06);
|
background: rgba(255, 183, 77, 0.06);
|
||||||
@@ -1013,49 +1007,6 @@ html, body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* RAG 知识库来源 */
|
|
||||||
.rag-sources {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
border: 1px solid rgba(96, 205, 255, 0.1);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rag-sources-header {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rag-source-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 0;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rag-source-item + .rag-source-item {
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rag-source-name {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rag-source-score {
|
|
||||||
color: var(--success);
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ 输入区域 ═══ */
|
/* ═══ 输入区域 ═══ */
|
||||||
.input-area {
|
.input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1855,251 +1806,6 @@ html, body {
|
|||||||
background: var(--bg-layer-alt);
|
background: var(--bg-layer-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ 知识库管理 ═══ */
|
|
||||||
#kbModal .modal {
|
|
||||||
max-width: 95vw;
|
|
||||||
width: 1100px;
|
|
||||||
max-height: 88vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#kbModal .modal-body {
|
|
||||||
padding: 16px 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-layout {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-sidebar {
|
|
||||||
width: 240px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-right: 16px;
|
|
||||||
border-right: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-new-collection {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-new-collection .setting-input {
|
|
||||||
flex: 1;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-collection-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-collection-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-collection-item:hover {
|
|
||||||
background: var(--bg-layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-collection-item.active {
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
border: 1px solid rgba(96, 205, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-col-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-col-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-col-stats {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-col-delete {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-collection-item:hover .kb-col-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-layer);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-embed-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: var(--bg-layer-alt);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-embed-select:focus {
|
|
||||||
border-color: var(--border-focus);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-rag-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-left: 8px;
|
|
||||||
border-left: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-progress {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-item:hover {
|
|
||||||
background: var(--bg-layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-name {
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-delete {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-doc-item:hover .kb-doc-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 移动端响应式 ── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.kb-layout {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 160px;
|
|
||||||
border-right: none;
|
|
||||||
padding-right: 0;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kb-main {
|
|
||||||
min-height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-lg {
|
|
||||||
max-height: 92vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ Toast 通知 ═══ */
|
/* ═══ Toast 通知 ═══ */
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2436,250 +2142,6 @@ html, body {
|
|||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
|
||||||
Agent 记忆系统
|
|
||||||
═══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
/* ── 记忆面板 ── */
|
|
||||||
.memory-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 100px;
|
|
||||||
right: 16px;
|
|
||||||
width: 380px;
|
|
||||||
max-height: calc(100vh - 140px);
|
|
||||||
background: var(--bg-primary, #2d2d2d);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider-sm {
|
|
||||||
width: 28px;
|
|
||||||
height: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider-sm::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle input:checked + .toggle-slider-sm {
|
|
||||||
background: var(--accent-cyan, #00f5d4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle input:checked + .toggle-slider-sm::after {
|
|
||||||
transform: translateX(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel-search {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-search-input {
|
|
||||||
flex: 1;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--text-primary, #fff);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-search-input:focus {
|
|
||||||
border-color: var(--accent-cyan, #00f5d4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-type-filter {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
color: var(--text-primary, #fff);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel-actions-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 16px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger-outline {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid rgba(255, 82, 82, 0.3);
|
|
||||||
color: #ff6b6b;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger-outline:hover {
|
|
||||||
background: rgba(255, 82, 82, 0.1);
|
|
||||||
border-color: rgba(255, 82, 82, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px 16px;
|
|
||||||
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-type {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-importance {
|
|
||||||
font-size: 8px;
|
|
||||||
color: var(--accent-cyan, #00f5d4);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-delete {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary, rgba(255, 255, 255, 0.3));
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-delete:hover {
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-content {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary, rgba(255, 255, 255, 0.85));
|
|
||||||
line-height: 1.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-tag {
|
|
||||||
background: rgba(0, 245, 212, 0.08);
|
|
||||||
color: var(--accent-cyan, #00f5d4);
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-item-time {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary, rgba(255, 255, 255, 0.3));
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 记忆注入状态 */
|
|
||||||
.memory-status {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--accent-cyan, #00f5d4);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════ 工作空间面板 ═══════════════ */
|
|
||||||
|
|
||||||
.workspace-panel {
|
.workspace-panel {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
@@ -3090,4 +2552,320 @@ html, body {
|
|||||||
/* ── 主内容区自适应 ── */
|
/* ── 主内容区自适应 ── */
|
||||||
|
|
||||||
/* main-wrap.with-workspace 不再需要 margin-right,
|
/* main-wrap.with-workspace 不再需要 margin-right,
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
记忆管理模态框
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ═══ 记忆管理模态框 ═══ */
|
||||||
|
#memoryModal .modal {
|
||||||
|
max-width: 95vw;
|
||||||
|
width: 1000px;
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#memoryModal .modal-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-sidebar {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 16px;
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-stats {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-layer);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-stats-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-stats-count {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-category-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-category-item:hover {
|
||||||
|
background: var(--bg-layer);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-category-item.active {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(96, 205, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-category-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-category-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-search-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-search-input-lg {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-layer);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-search-input-lg:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 1px var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-vector-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-vector-badge.enabled {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-bg);
|
||||||
|
border: 1px solid rgba(108, 203, 95, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-vector-badge.disabled {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-layer);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-list-lg {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-layer);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--bg-layer-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-type {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-importance {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-delete:hover {
|
||||||
|
color: var(--critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-tag-lg {
|
||||||
|
background: rgba(96, 205, 255, 0.08);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item-lg-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-empty-lg {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 记忆面板 toggle 在模态框 header 内 */
|
||||||
|
.memory-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* btn-danger-outline (记忆用) */
|
||||||
|
.btn-danger-outline {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255, 82, 82, 0.3);
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-outline:hover {
|
||||||
|
background: rgba(255, 82, 82, 0.1);
|
||||||
|
border-color: rgba(255, 82, 82, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 记忆注入状态 */
|
||||||
|
.memory-status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.memory-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.memory-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 120px;
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.memory-categories {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
工作空间面板已改为 flex 子元素,自然占据宽度 */
|
工作空间面板已改为 flex 子元素,自然占据宽度 */
|
||||||
|
|||||||
Vendored
+6
-15
@@ -92,12 +92,6 @@ export interface FileContent {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RagSource {
|
|
||||||
filename: string;
|
|
||||||
score: number;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
@@ -109,7 +103,6 @@ export interface ChatMessage {
|
|||||||
images?: string[];
|
images?: string[];
|
||||||
files?: ChatFile[];
|
files?: ChatFile[];
|
||||||
_fileContents?: FileContent[];
|
_fileContents?: FileContent[];
|
||||||
ragSources?: RagSource[];
|
|
||||||
stopped?: boolean;
|
stopped?: boolean;
|
||||||
toolCalls?: ToolCallRecord[];
|
toolCalls?: ToolCallRecord[];
|
||||||
}
|
}
|
||||||
@@ -125,7 +118,7 @@ export interface ChatSession {
|
|||||||
|
|
||||||
// ── Agent 记忆系统类型 ──
|
// ── Agent 记忆系统类型 ──
|
||||||
|
|
||||||
export type MemoryType = 'fact' | 'preference' | 'rule' | 'episode';
|
export type MemoryType = 'fact' | 'preference' | 'rule';
|
||||||
|
|
||||||
export interface MemoryEntry {
|
export interface MemoryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -135,6 +128,7 @@ export interface MemoryEntry {
|
|||||||
tags: string[]; // 用于快速匹配
|
tags: string[]; // 用于快速匹配
|
||||||
source?: string; // 来源描述(如会话标题)
|
source?: string; // 来源描述(如会话标题)
|
||||||
sessionId?: string; // 关联的会话 ID
|
sessionId?: string; // 关联的会话 ID
|
||||||
|
embedding?: number[]; // 向量嵌入
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastUsedAt: number; // 最后一次被回忆的时间
|
lastUsedAt: number; // 最后一次被回忆的时间
|
||||||
@@ -154,7 +148,7 @@ export interface MemoryExtractionResult {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 知识库类型 ──
|
// ── 向量存储类型(用于记忆向量索引)──
|
||||||
|
|
||||||
export interface VectorCollection {
|
export interface VectorCollection {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -181,11 +175,7 @@ export interface SearchResult extends VectorItem {
|
|||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocInfo {
|
|
||||||
docId: string;
|
|
||||||
filename: string;
|
|
||||||
chunkCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 桌面 Bridge 类型 ──
|
// ── 桌面 Bridge 类型 ──
|
||||||
|
|
||||||
@@ -286,7 +276,8 @@ export type StateKey =
|
|||||||
| 'toolCallingEnabled'
|
| 'toolCallingEnabled'
|
||||||
| 'runCommandEnabled'
|
| 'runCommandEnabled'
|
||||||
| 'memoryEnabled'
|
| 'memoryEnabled'
|
||||||
| 'memoryEntries';
|
| 'memoryEntries'
|
||||||
|
| 'embeddingModel';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Tool Calling 类型
|
// Tool Calling 类型
|
||||||
|
|||||||
Reference in New Issue
Block a user