From af70054875eab9a0555c28ae2948ae692f81e01a Mon Sep 17 00:00:00 2001 From: Metona Fix Date: Mon, 6 Apr 2026 19:23:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=87=8F=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20+=20=E6=97=A5=E5=BF=97=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=88=E9=9D=9E=E9=98=BB=E5=A1=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log-service 重构: - DOM 渲染改用 requestAnimationFrame 批量队列 - 全程 try-catch,任何异常都不阻塞主流程 - 新增 logConnection/logModel/logSession/logMemory/logRAG/logSetting/logIPC/logInit 便捷方法 全量接入(12 个文件): - header.ts: 连接状态变化(connected/disconnected/error) - model-bar.ts: 模型列表加载、模型切换、能力检测结果 - settings-modal.ts: 所有设置变更(系统提示词/上下文/温度/工具调用/命令执行/记忆)、显存释放、导出/导入 - tool-confirm-modal.ts: 工具确认弹窗、确认/取消操作 - memory-manager.ts: 记忆初始化、新增/删除、自动提取 - tool-registry.ts: 工具执行异常 - rag.ts: 文档分块、向量检索 - kb-modal.ts: 知识库创建、文档上传、批量上传 - history-modal.ts: 会话加载、会话删除 - main.ts: 应用初始化各阶段、桌面集成、菜单/托盘操作、新建会话 - input-area.ts: 消息发送、流式完成/错误、文件/图片添加、Agent Loop 错误 --- src/renderer/components/header.ts | 5 + src/renderer/components/history-modal.ts | 8 +- src/renderer/components/input-area.ts | 2 + src/renderer/components/kb-modal.ts | 5 + src/renderer/components/model-bar.ts | 9 + src/renderer/components/settings-modal.ts | 28 ++- src/renderer/components/tool-confirm-modal.ts | 4 + src/renderer/main.ts | 20 +- src/renderer/services/log-service.ts | 221 +++++++++--------- src/renderer/services/memory-manager.ts | 6 + src/renderer/services/rag.ts | 3 + src/renderer/services/tool-registry.ts | 4 + 12 files changed, 199 insertions(+), 116 deletions(-) diff --git a/src/renderer/components/header.ts b/src/renderer/components/header.ts index c486dd4..5dec427 100644 --- a/src/renderer/components/header.ts +++ b/src/renderer/components/header.ts @@ -5,6 +5,7 @@ import { state, KEYS } from '../state/state.js'; import { formatSize } from '../utils/utils.js'; import { OllamaAPI } from '../api/ollama.js'; +import { logConnection, logError, logDebug } from '../services/log-service.js'; let connStatusEl: HTMLElement; @@ -24,11 +25,13 @@ export async function checkConnection(): Promise<{ ok: boolean; version?: { vers connStatusEl.className = 'conn-status connected'; connStatusEl.title = `已连接 (v${version.version})`; connStatusEl.querySelector('.status-label')!.textContent = '已连接'; + logConnection('connected', `Ollama v${version.version}`); return { ok: true, version }; } catch (err) { connStatusEl.className = 'conn-status disconnected'; connStatusEl.title = '未连接'; connStatusEl.querySelector('.status-label')!.textContent = '未连接'; + logConnection('disconnected', (err as Error).message); return { ok: false, error: err as Error }; } } @@ -51,10 +54,12 @@ export async function updateConnectionInfo(): Promise { badge.className = 'status-badge success'; verEl.textContent = `v${version.version}`; corsHint.style.display = 'none'; + logConnection('info', `Ollama v${version.version}`); } catch (err) { badge.textContent = '✗ 未连接'; badge.className = 'status-badge error'; verEl.textContent = ''; + logError('连接检测失败', (err as Error).message); if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError') || (err as Error).message.includes('CORS')) { corsHint.style.display = ''; diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts index d8f93c5..69a1abc 100644 --- a/src/renderer/components/history-modal.ts +++ b/src/renderer/components/history-modal.ts @@ -6,6 +6,7 @@ import { state, KEYS } from '../state/state.js'; import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js'; import { ChatDB } from '../db/chat-db.js'; +import { logSession, logWarn } from '../services/log-service.js'; import type { ChatSession } from '../types.js'; const HISTORY_PAGE_SIZE = 10; @@ -57,6 +58,7 @@ export function initHistoryModal(): void { if (target.classList.contains('btn-delete-session')) { if (confirm('确定删除此会话?')) { await db!.deleteSession(sessionId); + logSession('删除', sessionId); const currentSession = state.get(KEYS.CURRENT_SESSION); if (currentSession && currentSession.id === sessionId) { (document.querySelector('#btnNewChat') as HTMLElement).click(); @@ -164,8 +166,12 @@ async function loadHistorySession(sessionId: string): Promise { if (!db) return; const session = await db.getSession(sessionId); - if (!session) return; + if (!session) { + logWarn('会话不存在', sessionId); + return; + } + logSession('加载', `${session.title} (${session.messages.length}条消息)`); state.set(KEYS.CURRENT_SESSION, session); state.set(KEYS.IS_HISTORY_VIEW, true); diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 12e6711..1a11ced 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -133,6 +133,7 @@ async function handleImageSelect(files: FileList): Promise { try { const base64 = await fileToBase64(file); pendingImages.push({ name: file.name, base64 }); + logInfo(`图片已添加: ${file.name}`, formatSize(file.size)); renderImagePreviews(); } catch (err) { console.error('[InputArea] 图片读取失败:', err); @@ -172,6 +173,7 @@ async function handleTextFileSelect(files: FileList): Promise { try { const content = await fileToText(file); pendingFiles.push({ name: file.name, content, language: detectLanguage(file.name), size: file.size }); + logInfo(`文件已添加: ${file.name}`, formatSize(file.size)); renderFilePreviews(); } catch (err) { console.error('[InputArea] 文件读取失败:', err); diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts index e3983dd..4b8447a 100644 --- a/src/renderer/components/kb-modal.ts +++ b/src/renderer/components/kb-modal.ts @@ -11,6 +11,7 @@ import { import { fileToText, formatSize, escapeHtml, truncate } from '../utils/utils.js'; import { showToast } from './toast.js'; import { OllamaAPI } from '../api/ollama.js'; +import { logRAG, logError } from '../services/log-service.js'; import type { VectorCollection, SearchResult } from '../types.js'; let kbModalEl: HTMLElement; @@ -209,6 +210,7 @@ async function createCollection(): Promise { currentColId = col.id; nameInput.value = ''; showToast(`知识库「${name}」已创建`, 'success'); + logRAG(`创建集合: ${name}`); await refreshCollections(); await refreshDocList(); } @@ -248,10 +250,12 @@ async function handleDocUpload(e: Event): Promise { ); showToast(`${file.name} 已添加到知识库(${result.chunkCount} 个分块)`, 'success'); + logRAG(`文档上传: ${file.name}`, `${result.chunkCount} 个分块`); successCount++; } catch (err) { console.error('[KB] 文档处理失败:', err); showToast(`${file.name} 处理失败: ${(err as Error).message}`, 'error'); + logError(`RAG 文档处理失败: ${file.name}`, (err as Error).message); failCount++; } } @@ -260,6 +264,7 @@ async function handleDocUpload(e: Event): Promise { if (fileArr.length > 1) { showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount} 个`, failCount > 0 ? 'warning' : 'success'); + logRAG(`批量上传完成`, `成功 ${successCount}, 失败 ${failCount}`); } await refreshCollections(); diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index f467475..737a2ff 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -6,6 +6,7 @@ import { state, KEYS } from '../state/state.js'; import { formatSize } from '../utils/utils.js'; import { OllamaAPI } from '../api/ollama.js'; import { ChatDB } from '../db/chat-db.js'; +import { logModel, logDebug, logError, logWarn } from '../services/log-service.js'; import type { OllamaModelDetail, ModelCaps } from '../types.js'; let modelSelectEl: HTMLSelectElement; @@ -34,6 +35,7 @@ export function initModelBar(): void { state.set('_defaultModel', model); state.update(KEYS.CURRENT_SESSION, (session: any) => session ? ({ ...session, model }) : session); if (model) { + logModel(model, '切换'); await checkModelCapability(model); } else { setThinkAvailable(false); @@ -58,9 +60,12 @@ export async function loadModels(): Promise { modelSelectEl.innerHTML = ''; badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none'; setThinkAvailable(false); setVisionAvailable(false); + logWarn('未检测到已安装模型'); return; } + logModel(`${data.models.length} 个模型`, '加载列表'); + data.models .sort((a, b) => { const nameA = a.name.toLowerCase(), nameB = b.name.toLowerCase(); @@ -86,6 +91,7 @@ export async function loadModels(): Promise { filterEmbedModels(data.models); } catch (err) { console.error('[ModelBar] 加载模型失败:', err); + logError('加载模型列表失败', (err as Error).message); modelSelectEl.innerHTML = ''; badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none'; setThinkAvailable(false); setVisionAvailable(false); @@ -125,6 +131,7 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise if (!isCompletion) { const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`); if (opt) opt.remove(); + logDebug(`模型 ${m.name} 不是 completion 模型,已移除`); } } catch { /* ignore */ } } @@ -159,9 +166,11 @@ async function checkModelCapability(modelName: string): Promise { completion: capabilities.includes('completion'), }; modelCapabilityCache.set(modelName, caps); + logDebug(`能力检测: ${modelName}`, capabilities.join(', ')); applyCapability(modelName, caps); } catch (err) { console.warn('[ModelBar] 获取模型能力失败:', err); + logWarn(`能力检测失败: ${modelName}`, (err as Error).message); const fallback = { think: false, vision: false, tools: false, completion: true }; modelCapabilityCache.set(modelName, fallback); applyCapability(modelName, fallback); diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 5cddf77..2398eb4 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -7,6 +7,7 @@ import { debounce } from '../utils/utils.js'; import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js'; import { setMemoryEnabled } from '../services/memory-manager.js'; import { setToolEnabled } from '../services/tool-registry.js'; +import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { updateConnectionInfo, updateRunningModels } from './header.js'; import { loadModels } from './model-bar.js'; import { showToast } from './toast.js'; @@ -39,9 +40,11 @@ export function initSettingsModal(): void { document.querySelector('#toggleSystemPrompt')!.addEventListener('change', async (e) => { const db = state.get(KEYS.DB); - state.set(KEYS.SYSTEM_PROMPT_ENABLED, (e.target as HTMLInputElement).checked); - (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !(e.target as HTMLInputElement).checked; - if (db) await db.saveSetting('systemPromptEnabled', (e.target as HTMLInputElement).checked); + const checked = (e.target as HTMLInputElement).checked; + state.set(KEYS.SYSTEM_PROMPT_ENABLED, checked); + (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !checked; + if (db) await db.saveSetting('systemPromptEnabled', checked); + logSetting('系统提示词', checked ? '开启' : '关闭'); }); const saveSystemPrompt = debounce(async () => { @@ -58,6 +61,7 @@ export function initSettingsModal(): void { const numCtx = val ? parseInt(val) : 24576; state.set(KEYS.NUM_CTX, numCtx); if (db) await db.saveSetting('numCtx', numCtx); + logSetting('上下文长度', `${numCtx} tokens`); }, 500); document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx); @@ -71,27 +75,33 @@ export function initSettingsModal(): void { const val = parseFloat(tempSlider.value); state.set('temperature', val); if (db) await db.saveSetting('temperature', val); + logSetting('温度', val.toFixed(1)); }, 300); tempSlider.addEventListener('change', saveTemperature); document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { const api = state.get(KEYS.API); const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; + logInfo('释放显存', model || '所有模型'); try { await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 } as any); showToast('显存已释放', 'success'); + logSuccess('显存已释放'); updateRunningModels(); } catch (err) { showToast(`释放失败: ${(err as Error).message}`, 'error'); + logError('释放显存失败', (err as Error).message); } }); document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => { const db = state.get(KEYS.DB); if (confirm('确定清空所有历史记录?此操作不可恢复!')) { + logWarn('清空所有历史记录'); await db!.clearAll(); (document.querySelector('#btnNewChat') as HTMLElement).click(); showToast('已清空所有历史记录', 'success'); + logSuccess('历史记录已清空'); } }); @@ -117,11 +127,14 @@ export function initSettingsModal(): void { const modelSupportsTools = state.get('modelSupportsTools', false); if (!modelSupportsTools) { showToast('⚠️ 当前模型可能不支持 Tool Calling,建议使用 Qwen3、Llama 3.1+ 等模型', 'warning', 5000); + logWarn('Tool Calling 已开启(模型不支持)'); } else { showToast('工具调用已开启', 'info'); + logInfo('Tool Calling 已开启'); } } else { showToast('工具调用已关闭', 'info'); + logInfo('Tool Calling 已关闭'); } }); } @@ -135,6 +148,9 @@ export function initSettingsModal(): void { setToolEnabled('run_command', toggleRunCommand.checked); if (toggleRunCommand.checked) { showToast('⚠️ 命令执行已开启,请谨慎使用', 'warning'); + logWarn('命令执行已开启(高风险)'); + } else { + logInfo('命令执行已关闭'); } }); } @@ -145,6 +161,7 @@ export function initSettingsModal(): void { toggleMemory.addEventListener('change', () => { setMemoryEnabled(toggleMemory.checked); showToast(toggleMemory.checked ? 'Agent 记忆已开启' : 'Agent 记忆已关闭', 'info'); + logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭'); }); } } @@ -185,9 +202,11 @@ async function exportAllSessions(): Promise { a.download = `metona-backup-${ts}.metona`; a.click(); showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success'); + logSuccess(`导出完成: ${sessions.length} 个会话`); } catch (err) { console.error('[Settings] 导出失败:', err); showToast(`导出失败: ${(err as Error).message}`, 'error'); + logError('导出失败', (err as Error).message); } } @@ -203,6 +222,7 @@ async function importSessions(file: File): Promise { try { const buffer = await file.arrayBuffer(); const data = await decryptData(buffer); + logInfo('导入会话', `文件: ${file.name}`); let sessions: ChatSession[]; if (Array.isArray(data)) { @@ -216,8 +236,10 @@ async function importSessions(file: File): Promise { const result = await db.importSessions(sessions); showToast(`导入完成:${result.imported} 个会话${result.skipped > 0 ? `,跳过 ${result.skipped} 个` : ''}`, 'success', 4000); + logSuccess(`导入完成: ${result.imported} 个, 跳过 ${result.skipped} 个`); } catch (err) { console.error('[Settings] 导入失败:', err); showToast(`导入失败: ${(err as Error).message}`, 'error'); + logError('导入失败', (err as Error).message); } } diff --git a/src/renderer/components/tool-confirm-modal.ts b/src/renderer/components/tool-confirm-modal.ts index be467fe..c408420 100644 --- a/src/renderer/components/tool-confirm-modal.ts +++ b/src/renderer/components/tool-confirm-modal.ts @@ -4,6 +4,7 @@ import type { ToolCall } from '../types.js'; import { getToolIcon, formatToolName } from '../services/tool-registry.js'; +import { logDebug, logInfo } from '../services/log-service.js'; let modalEl: HTMLElement | null = null; let resolveConfirm: ((confirmed: boolean) => void) | null = null; @@ -45,6 +46,7 @@ export function showToolConfirm(call: ToolCall): Promise { const icon = getToolIcon(call.function.name); const name = formatToolName(call.function.name); + logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments)); const titleEl = modalEl.querySelector('#toolConfirmTitle')!; const bodyEl = modalEl.querySelector('#toolConfirmBody')!; @@ -83,11 +85,13 @@ export function showToolConfirm(call: ToolCall): Promise { const onConfirm = (e: Event) => { e.stopPropagation(); cleanup(); + logInfo(`已确认: ${call.function.name}`); resolveConfirm?.(true); }; const onCancel = (e: Event) => { e.stopPropagation(); cleanup(); + logInfo(`已取消: ${call.function.name}`); resolveConfirm?.(false); }; const cleanup = () => { diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 579173d..1865e63 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -26,6 +26,7 @@ import { setToolEnabled } from './services/tool-registry.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initMemoryPanel } from './components/memory-panel.js'; import { initLogPanel } from './services/log-service.js'; +import { logInfo, logSuccess, logError, logDebug, logInit } from './services/log-service.js'; import type { ChatSession } from './types.js'; function initHelpModal(): void { @@ -43,11 +44,17 @@ function initHelpModal(): void { function setupDesktopIntegration(): void { const bridge = window.__metonaBridge; - if (!bridge || !bridge.isDesktop) return; + if (!bridge || !bridge.isDesktop) { + logDebug('非桌面环境,跳过桌面集成'); + return; + } + + logInit('桌面集成...'); console.log('[Desktop Integration] 初始化桌面集成...'); bridge.onMenuAction((action: string) => { + logInfo(`菜单操作: ${action}`); switch (action) { case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break; case 'import': document.querySelector('#btnImportSessions')?.dispatchEvent(new Event('click')); break; @@ -57,6 +64,7 @@ function setupDesktopIntegration(): void { }); bridge.onTrayAction((action: string) => { + logInfo(`托盘操作: ${action}`); switch (action) { case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break; } @@ -80,6 +88,7 @@ function setupDesktopIntegration(): void { }); console.log('[Desktop Integration] 桌面集成完成 ✓'); + logSuccess('桌面集成完成'); } console.log('[Metona] TypeScript + Electron v3.0.0 ' + new Date().toISOString()); @@ -110,6 +119,7 @@ async function startNewSession(): Promise { if (api && model) { try { await api.chat({ model, messages: [], keep_alive: 0 } as any); + logDebug('释放旧模型显存'); } catch (e) { console.warn('[App] 释放显存失败:', e); } @@ -117,6 +127,7 @@ async function startNewSession(): Promise { const session = createNewSession(); state.set(KEYS.CURRENT_SESSION, session); + logInfo('新建会话'); state.set(KEYS.IS_HISTORY_VIEW, false); (document.querySelector('#historyBar') as HTMLElement).style.display = 'none'; @@ -132,14 +143,18 @@ async function init(): Promise { let db: ChatDB | undefined; let api: OllamaAPI; + logInit('启动...'); + try { db = new ChatDB(); await db.init(); state.set(KEYS.DB, db); + logInit('IndexedDB 已连接'); const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434'); api = new OllamaAPI(serverUrl); state.set(KEYS.API, api); + logInit(`Ollama 地址: ${serverUrl}`); state.set(KEYS.CURRENT_SESSION, createNewSession()); state.set(KEYS.IS_STREAMING, false); @@ -167,6 +182,7 @@ async function init(): Promise { await loadModels(); await initVectorStore(); await initMemoryManager(); + logInit('所有组件已就绪'); const savedModel = await db.getSetting('selectedModel', ''); if (savedModel) { @@ -210,8 +226,10 @@ async function init(): Promise { (document.querySelector('#chatInput') as HTMLElement)?.focus(); console.log('[App] 初始化完成'); + logSuccess('应用初始化完成'); } catch (err) { console.error('[App] 初始化失败:', err); + logError('初始化失败', (err as Error).message); if (!api!) { api = new OllamaAPI(); state.set(KEYS.API, api); diff --git a/src/renderer/services/log-service.ts b/src/renderer/services/log-service.ts index b347d16..eda0f5a 100644 --- a/src/renderer/services/log-service.ts +++ b/src/renderer/services/log-service.ts @@ -1,6 +1,7 @@ /** * LogService - 实时日志面板服务 * 收集应用内所有事件,渲染到左侧日志面板 + * 全程 try-catch,任何情况下不阻塞主流程 */ export type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug' | 'tool' | 'think' | 'stream'; @@ -20,6 +21,8 @@ const MAX_LOGS = 500; let logs: LogEntry[] = []; let autoScroll = true; let idCounter = 0; +let renderQueued = false; +const pendingEntries: LogEntry[] = []; const LEVEL_ICONS: Record = { info: 'ℹ️', @@ -33,33 +36,40 @@ const LEVEL_ICONS: Record = { }; export function initLogPanel(): void { - logBodyEl = document.querySelector('#logPanelBody')!; - logPanelEl = document.querySelector('#logPanel')!; + try { + logBodyEl = document.querySelector('#logPanelBody')!; + logPanelEl = document.querySelector('#logPanel')!; - const btnToggle = document.querySelector('#btnToggleLog')!; - const btnClose = document.querySelector('#btnCloseLog')!; - const btnClear = document.querySelector('#btnClearLog')!; + document.querySelector('#btnToggleLog')!.addEventListener('click', () => { + try { + const visible = logPanelEl!.style.display !== 'none'; + logPanelEl!.style.display = visible ? 'none' : ''; + document.querySelector('#mainWrap')!.classList.toggle('log-open', !visible); + } catch { /* ignore */ } + }); - btnToggle.addEventListener('click', () => { - const visible = logPanelEl!.style.display !== 'none'; - logPanelEl!.style.display = visible ? 'none' : ''; - document.querySelector('#mainWrap')!.classList.toggle('log-open', !visible); - }); + document.querySelector('#btnCloseLog')!.addEventListener('click', () => { + try { + logPanelEl!.style.display = 'none'; + document.querySelector('#mainWrap')!.classList.remove('log-open'); + } catch { /* ignore */ } + }); - btnClose.addEventListener('click', () => { - logPanelEl!.style.display = 'none'; - document.querySelector('#mainWrap')!.classList.remove('log-open'); - }); + document.querySelector('#btnClearLog')!.addEventListener('click', () => { + try { + logs = []; + pendingEntries.length = 0; + if (logBodyEl) logBodyEl.innerHTML = ''; + } catch { /* ignore */ } + }); - btnClear.addEventListener('click', () => { - logs = []; - if (logBodyEl) logBodyEl.innerHTML = ''; - }); - - logBodyEl.addEventListener('scroll', () => { - const el = logBodyEl!; - autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40; - }); + logBodyEl.addEventListener('scroll', () => { + try { + const el = logBodyEl!; + autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } catch { /* ignore */ } + }); + } catch { /* ignore */ } } function genId(): string { @@ -68,113 +78,102 @@ function genId(): string { function formatTime(ts: number): string { const d = new Date(ts); - const h = String(d.getHours()).padStart(2, '0'); - const m = String(d.getMinutes()).padStart(2, '0'); - const s = String(d.getSeconds()).padStart(2, '0'); - return `${h}:${m}:${s}`; -} - -export function addLog(level: LogLevel, message: string, detail?: string, icon?: string): void { - const entry: LogEntry = { - id: genId(), - time: Date.now(), - level, - icon: icon || LEVEL_ICONS[level] || '•', - message, - detail, - }; - - logs.push(entry); - if (logs.length > MAX_LOGS) { - const removed = logs.splice(0, logs.length - MAX_LOGS); - for (const r of removed) { - const el = document.getElementById(r.id); - if (el) el.remove(); - } - } - - appendLogDOM(entry); -} - -function appendLogDOM(entry: LogEntry): void { - if (!logBodyEl) return; - - const div = document.createElement('div'); - div.className = `log-entry log-${entry.level}`; - div.id = entry.id; - - let html = `${formatTime(entry.time)}`; - html += `${entry.icon}`; - html += `${escapeHtml(entry.message)}`; - - if (entry.detail) { - html += `
${escapeHtml(entry.detail)}
`; - } - - div.innerHTML = html; - logBodyEl.appendChild(div); - - if (autoScroll) { - logBodyEl.scrollTop = logBodyEl.scrollHeight; - } + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; } function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -// ── 便捷方法 ── +/** 批量渲染队列 — 使用 rAF 避免阻塞主线程 */ +function flushPending(): void { + if (!logBodyEl || pendingEntries.length === 0) return; + renderQueued = false; -export function logInfo(msg: string, detail?: string): void { - addLog('info', msg, detail); + try { + const frag = document.createDocumentFragment(); + const toRender = pendingEntries.splice(0, pendingEntries.length); + + for (const entry of toRender) { + const div = document.createElement('div'); + div.className = `log-entry log-${entry.level}`; + div.id = entry.id; + + let html = `${formatTime(entry.time)}`; + html += `${entry.icon}`; + html += `${escapeHtml(entry.message)}`; + if (entry.detail) { + html += `
${escapeHtml(entry.detail)}
`; + } + div.innerHTML = html; + frag.appendChild(div); + } + + logBodyEl.appendChild(frag); + + // 清理超出的旧 DOM 节点 + while (logBodyEl.children.length > MAX_LOGS) { + logBodyEl.removeChild(logBodyEl.firstChild!); + } + + if (autoScroll) { + logBodyEl.scrollTop = logBodyEl.scrollHeight; + } + } catch { /* 绝不抛出 */ } } -export function logSuccess(msg: string, detail?: string): void { - addLog('success', msg, detail); +/** 核心日志方法 — 永远不阻塞调用方 */ +export function addLog(level: LogLevel, message: string, detail?: string, icon?: string): void { + try { + const entry: LogEntry = { + id: genId(), + time: Date.now(), + level, + icon: icon || LEVEL_ICONS[level] || '•', + message, + detail, + }; + + logs.push(entry); + if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS); + + pendingEntries.push(entry); + if (!renderQueued) { + renderQueued = true; + requestAnimationFrame(flushPending); + } + } catch { /* 绝不抛出 */ } } -export function logWarn(msg: string, detail?: string): void { - addLog('warn', msg, detail); -} +// ── 便捷方法(全部安全包装)── -export function logError(msg: string, detail?: string): void { - addLog('error', msg, detail); -} - -export function logDebug(msg: string, detail?: string): void { - addLog('debug', msg, detail); -} +export function logInfo(msg: string, detail?: string): void { addLog('info', msg, detail); } +export function logSuccess(msg: string, detail?: string): void { addLog('success', msg, detail); } +export function logWarn(msg: string, detail?: string): void { addLog('warn', msg, detail); } +export function logError(msg: string, detail?: string): void { addLog('error', msg, detail); } +export function logDebug(msg: string, detail?: string): void { addLog('debug', msg, detail); } export function logToolStart(name: string, args: string): void { - addLog('tool', `工具调用: ${name}`, args); + addLog('tool', `▶ ${name}`, args); } export function logToolResult(name: string, success: boolean, summary?: string): void { - addLog( - success ? 'success' : 'error', - `工具完成: ${name}`, - summary, - success ? '✅' : '❌' - ); + addLog(success ? 'success' : 'error', `◀ ${name} ${success ? '✓' : '✗'}`, summary, success ? '✅' : '❌'); } export function logThink(thinking: string): void { const preview = thinking.length > 200 ? thinking.slice(-200) + '...' : thinking; - addLog('think', '模型思考中', preview); + addLog('think', '思考中', preview); } -export function logStream(msg: string): void { - addLog('stream', msg); -} - -export function logAgentLoop(iteration: number, maxLoops: number): void { - addLog('info', `Agent Loop #${iteration}/${maxLoops}`); -} - -export function logModelResponse(contentLen: number, toolCalls: number): void { - addLog('stream', `模型响应: ${contentLen} 字符, ${toolCalls} 个工具调用`); -} +export function logStream(msg: string): void { addLog('stream', msg); } +export function logAgentLoop(iteration: number, maxLoops: number): void { addLog('info', `Loop #${iteration}/${maxLoops}`); } +export function logModelResponse(contentLen: number, toolCalls: number): void { addLog('stream', `响应: ${contentLen}字, ${toolCalls}工具`); } +export function logConnection(status: string, detail?: string): void { addLog(status === 'connected' ? 'success' : 'warn', `连接: ${status}`, detail); } +export function logModel(name: string, action: string): void { addLog('info', `模型: ${action}`, name); } +export function logSession(action: string, detail?: string): void { addLog('info', `会话: ${action}`, detail); } +export function logMemory(action: string, detail?: string): void { addLog('debug', `记忆: ${action}`, detail); } +export function logRAG(action: string, detail?: string): void { addLog('info', `RAG: ${action}`, detail); } +export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); } +export function logIPC(action: string, detail?: string): void { addLog('debug', `IPC: ${action}`, detail); } +export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); } diff --git a/src/renderer/services/memory-manager.ts b/src/renderer/services/memory-manager.ts index 8b4f537..b94ad99 100644 --- a/src/renderer/services/memory-manager.ts +++ b/src/renderer/services/memory-manager.ts @@ -11,6 +11,7 @@ import { state, KEYS } from '../state/state.js'; import { generateId } from '../utils/utils.js'; +import { logMemory, logDebug, logWarn } from './log-service.js'; import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js'; const TYPE_ICONS: Record = { @@ -41,6 +42,7 @@ export async function initMemoryManager(): Promise { state.set('memoryEntries', memoryCache); console.log(`[Memory] 初始化完成,加载 ${memoryCache.length} 条记忆`); + logMemory(`初始化完成, 加载 ${memoryCache.length} 条`); } export function isMemoryEnabled(): boolean { @@ -152,6 +154,7 @@ export async function addMemory(data: { state.set('memoryEntries', [...memoryCache]); if (db) await db.saveMemory(entry); + logMemory(`新增: ${data.type}`, entry.content.slice(0, 60)); return entry; } @@ -177,6 +180,7 @@ export async function deleteMemory(id: string): Promise { const db = state.get(KEYS.DB); if (db) await db.deleteMemory(id); + logMemory(`删除`, id); } // ── 清空所有记忆 ── @@ -316,9 +320,11 @@ ${conversationText.slice(0, 4000)} count++; } + if (count > 0) logMemory(`自动提取 ${count} 条`); return count; } catch (err) { console.warn('[Memory] 自动提取失败:', err); + logWarn('记忆自动提取失败', (err as Error).message); return 0; } } diff --git a/src/renderer/services/rag.ts b/src/renderer/services/rag.ts index de9c3c0..3743f9e 100644 --- a/src/renderer/services/rag.ts +++ b/src/renderer/services/rag.ts @@ -5,6 +5,7 @@ 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; @@ -50,6 +51,7 @@ export async function addDocumentToKB( 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)}`; @@ -120,6 +122,7 @@ export async function retrieveContext(query: string, colId: string, topK = 5): P 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}` diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index e47e6bf..a8dca20 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -4,6 +4,7 @@ */ import type { ToolDefinition, ToolResult } from '../types.js'; +import { logToolResult, logError } from './log-service.js'; export const TOOL_DEFINITIONS: ToolDefinition[] = [ { @@ -147,11 +148,13 @@ export function getEnabledToolDefinitions(): ToolDefinition[] { export async function executeTool(toolName: string, args: Record): Promise { if (!isToolEnabled(toolName)) { + logError(`工具未启用: ${toolName}`); return { success: false, error: `工具 ${toolName} 未启用` }; } const bridge = window.metonaDesktop; if (!bridge?.isDesktop) { + logError(`工具调用仅支持桌面版: ${toolName}`); return { success: false, error: '工具调用仅支持桌面版' }; } @@ -159,6 +162,7 @@ export async function executeTool(toolName: string, args: Record