feat: 添加左侧实时执行日志面板
功能: - 日志面板位于聊天区左侧,可折叠/展开 - 实时展示所有执行事件: - 🤔 模型思考过程 - 📡 流式响应状态 - 🔧 工具调用(参数+结果) - ⚠️ 错误和警告 - ℹ️ Agent Loop 迭代 - ✅ 操作完成 - 日志自动滚动、最大 500 条、可清空 - 等宽字体显示,按级别着色 - 头部新增日志开关按钮
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
} from './tool-registry.js';
|
||||
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js';
|
||||
import type {
|
||||
OllamaMessage,
|
||||
OllamaStreamChunk,
|
||||
@@ -53,6 +54,7 @@ export async function runAgentLoop(
|
||||
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
||||
if (!modelSupportsTools) {
|
||||
console.warn(`[AgentEngine] 模型 ${model} 未声明 tools 能力,Agent Loop 可能不稳定`);
|
||||
logWarn(`模型 ${model} 不支持 Tool Calling`, 'Agent Loop 可能不稳定');
|
||||
}
|
||||
|
||||
const messages: OllamaMessage[] = [];
|
||||
@@ -94,6 +96,8 @@ export async function runAgentLoop(
|
||||
const tools = getEnabledToolDefinitions();
|
||||
const useTools = tools.length > 0;
|
||||
|
||||
logInfo(`Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`);
|
||||
|
||||
let loopCount = 0;
|
||||
const allToolRecords: ToolCallRecord[] = [];
|
||||
const loopStartTime = Date.now();
|
||||
@@ -105,16 +109,20 @@ export async function runAgentLoop(
|
||||
// 全局超时检查
|
||||
if (Date.now() - loopStartTime > MAX_LOOP_TIME) {
|
||||
console.warn('[AgentEngine] 达到最大运行时间(5分钟),自动停止');
|
||||
logWarn('Agent Loop 达到最大运行时间(5分钟),自动停止');
|
||||
callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已中止
|
||||
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
|
||||
logInfo('Agent Loop 已中止');
|
||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
logAgentLoop(loopCount, MAX_LOOPS);
|
||||
|
||||
let thinking = '';
|
||||
content = '';
|
||||
const toolCalls: ToolCall[] = [];
|
||||
@@ -141,6 +149,10 @@ export async function runAgentLoop(
|
||||
if (chunk.message?.thinking) {
|
||||
thinking += chunk.message.thinking;
|
||||
callbacks.onThinking(thinking);
|
||||
// 每收到 200 字符记录一次思考日志
|
||||
if (thinking.length % 200 < 10) {
|
||||
logThink(thinking);
|
||||
}
|
||||
}
|
||||
if (chunk.message?.content) {
|
||||
content += chunk.message.content;
|
||||
@@ -173,6 +185,7 @@ export async function runAgentLoop(
|
||||
]);
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
logInfo('流式调用已中止');
|
||||
if (content || thinking) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
@@ -186,6 +199,7 @@ export async function runAgentLoop(
|
||||
// 超时错误 — 保留已生成的内容
|
||||
if ((err as Error).message.includes('超时')) {
|
||||
console.warn('[AgentEngine] 流式调用超时:', (err as Error).message);
|
||||
logError('流式调用超时(2分钟无数据)', (err as Error).message);
|
||||
if (content || thinking) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
@@ -209,6 +223,8 @@ export async function runAgentLoop(
|
||||
}
|
||||
messages.push(assistantMsg);
|
||||
|
||||
logModelResponse(content.length, toolCalls.length);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
|
||||
return;
|
||||
@@ -222,6 +238,7 @@ export async function runAgentLoop(
|
||||
}
|
||||
|
||||
callbacks.onToolCallStart(call);
|
||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||
|
||||
if (needsConfirmation(call.function.name)) {
|
||||
const confirmed = await callbacks.onConfirmTool(call);
|
||||
@@ -231,6 +248,7 @@ export async function runAgentLoop(
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
logWarn(`工具取消: ${call.function.name}`, '用户取消了操作');
|
||||
const record: ToolCallRecord = {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments,
|
||||
@@ -273,9 +291,11 @@ export async function runAgentLoop(
|
||||
content: JSON.stringify(result)
|
||||
});
|
||||
callbacks.onToolCallResult(call.function.name, result, call);
|
||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
console.error(`[AgentEngine] 工具执行失败 (${call.function.name}):`, errorMsg);
|
||||
logError(`工具执行失败: ${call.function.name}`, errorMsg);
|
||||
const record: ToolCallRecord = {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments,
|
||||
@@ -295,5 +315,6 @@ export async function runAgentLoop(
|
||||
}
|
||||
}
|
||||
|
||||
logWarn('Agent Loop 达到最大工具调用次数限制');
|
||||
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* LogService - 实时日志面板服务
|
||||
* 收集应用内所有事件,渲染到左侧日志面板
|
||||
*/
|
||||
|
||||
export type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug' | 'tool' | 'think' | 'stream';
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
time: number;
|
||||
level: LogLevel;
|
||||
icon: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
let logBodyEl: HTMLElement | null = null;
|
||||
let logPanelEl: HTMLElement | null = null;
|
||||
const MAX_LOGS = 500;
|
||||
let logs: LogEntry[] = [];
|
||||
let autoScroll = true;
|
||||
let idCounter = 0;
|
||||
|
||||
const LEVEL_ICONS: Record<LogLevel, string> = {
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
warn: '⚠️',
|
||||
error: '❌',
|
||||
debug: '🔍',
|
||||
tool: '🔧',
|
||||
think: '🤔',
|
||||
stream: '📡',
|
||||
};
|
||||
|
||||
export function initLogPanel(): void {
|
||||
logBodyEl = document.querySelector('#logPanelBody')!;
|
||||
logPanelEl = document.querySelector('#logPanel')!;
|
||||
|
||||
const btnToggle = document.querySelector('#btnToggleLog')!;
|
||||
const btnClose = document.querySelector('#btnCloseLog')!;
|
||||
const btnClear = document.querySelector('#btnClearLog')!;
|
||||
|
||||
btnToggle.addEventListener('click', () => {
|
||||
const visible = logPanelEl!.style.display !== 'none';
|
||||
logPanelEl!.style.display = visible ? 'none' : '';
|
||||
document.querySelector('#mainWrap')!.classList.toggle('log-open', !visible);
|
||||
});
|
||||
|
||||
btnClose.addEventListener('click', () => {
|
||||
logPanelEl!.style.display = 'none';
|
||||
document.querySelector('#mainWrap')!.classList.remove('log-open');
|
||||
});
|
||||
|
||||
btnClear.addEventListener('click', () => {
|
||||
logs = [];
|
||||
if (logBodyEl) logBodyEl.innerHTML = '';
|
||||
});
|
||||
|
||||
logBodyEl.addEventListener('scroll', () => {
|
||||
const el = logBodyEl!;
|
||||
autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
});
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return `log_${++idCounter}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 便捷方法 ──
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function logToolResult(name: string, success: boolean, summary?: string): void {
|
||||
addLog(
|
||||
success ? 'success' : 'error',
|
||||
`工具完成: ${name}`,
|
||||
summary,
|
||||
success ? '✅' : '❌'
|
||||
);
|
||||
}
|
||||
|
||||
export function logThink(thinking: string): void {
|
||||
const preview = thinking.length > 200 ? thinking.slice(-200) + '...' : thinking;
|
||||
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} 个工具调用`);
|
||||
}
|
||||
Reference in New Issue
Block a user