feat: 全量日志接入 + 日志服务重构(非阻塞)

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 错误
This commit is contained in:
Metona Fix
2026-04-06 19:23:03 +08:00
parent 1033cefdfb
commit af70054875
12 changed files with 199 additions and 116 deletions
+5
View File
@@ -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<void> {
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 = '';
+7 -1
View File
@@ -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<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession && currentSession.id === sessionId) {
(document.querySelector('#btnNewChat') as HTMLElement).click();
@@ -164,8 +166,12 @@ async function loadHistorySession(sessionId: string): Promise<void> {
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);
+2
View File
@@ -133,6 +133,7 @@ async function handleImageSelect(files: FileList): Promise<void> {
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<void> {
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);
+5
View File
@@ -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<void> {
currentColId = col.id;
nameInput.value = '';
showToast(`知识库「${name}」已创建`, 'success');
logRAG(`创建集合: ${name}`);
await refreshCollections();
await refreshDocList();
}
@@ -248,10 +250,12 @@ async function handleDocUpload(e: Event): Promise<void> {
);
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<void> {
if (fileArr.length > 1) {
showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount}`, failCount > 0 ? 'warning' : 'success');
logRAG(`批量上传完成`, `成功 ${successCount}, 失败 ${failCount}`);
}
await refreshCollections();
+9
View File
@@ -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<void> {
modelSelectEl.innerHTML = '<option value="">未安装任何模型</option>';
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<void> {
filterEmbedModels(data.models);
} catch (err) {
console.error('[ModelBar] 加载模型失败:', err);
logError('加载模型列表失败', (err as Error).message);
modelSelectEl.innerHTML = '<option value="">加载失败 - 检查连接</option>';
badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
setThinkAvailable(false); setVisionAvailable(false);
@@ -125,6 +131,7 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
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<void> {
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);
+25 -3
View File
@@ -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<ChatDB | null>(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<OllamaAPI>(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<ChatDB | null>(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<boolean>('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<void> {
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<void> {
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<void> {
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);
}
}
@@ -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<boolean> {
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<boolean> {
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 = () => {