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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return text.replace(/&/g, '&').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 = `<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}`); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user