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
+110 -111
View File
@@ -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<LogLevel, string> = {
info: '️',
@@ -33,33 +36,40 @@ const LEVEL_ICONS: Record<LogLevel, string> = {
};
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 = `<span class="log-time">${formatTime(entry.time)}</span>`;
html += `<span class="log-icon">${entry.icon}</span>`;
html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
if (entry.detail) {
html += `<pre class="log-detail">${escapeHtml(entry.detail)}</pre>`;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 便捷方法 ──
/** 批量渲染队列 — 使用 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 = `<span class="log-time">${formatTime(entry.time)}</span>`;
html += `<span class="log-icon">${entry.icon}</span>`;
html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
if (entry.detail) {
html += `<pre class="log-detail">${escapeHtml(entry.detail)}</pre>`;
}
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}`); }
+6
View File
@@ -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<string, string> = {
@@ -41,6 +42,7 @@ export async function initMemoryManager(): Promise<void> {
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<void> {
const db = state.get<ChatDB | null>(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;
}
}
+3
View File
@@ -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}`
+4
View File
@@ -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<string, unknown>): Promise<ToolResult> {
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<string, unknown
const result = await bridge.tool.execute(toolName, args);
return result;
} catch (err) {
logError(`工具异常: ${toolName}`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}