feat: Round 5 - Memory Service, Tool Engine, Call Records, Thinking Logs

- Fix: Session history flash (race condition + WS guard)
- Fix: Chat background overlay + sidebar transparency
- Fix: IoT device control (Chinese action names, status field)
- Feat: Independent memory-service (port 8091, 13 endpoints)
- Feat: Independent tool-engine service (port 8092, 13 tools)
- Feat: Tool call logs with paginated DevTools panel
- Feat: Thinking log records with DevTools panel
- Feat: Future development roadmap document
- Chore: Updated .gitignore, go.work, DevTools config
- Chore: 5-service health check, project review docs
This commit is contained in:
2026-05-18 20:05:14 +08:00
parent b6ec36886c
commit 78e3f450c2
54 changed files with 7846 additions and 106 deletions
+344
View File
@@ -450,10 +450,16 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<span class="nav-icon">🏠</span><span class="nav-label">IoT 设备</span>
<span class="nav-badge" id="iot-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="toolCalls">
<span class="nav-icon">🔧</span><span class="nav-label">工具调用</span>
</button>
<button class="nav-item" data-panel="database">
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
<span class="nav-badge" id="db-badge" style="display:none"></span>
</button>
<button class="nav-item" data-panel="thinking">
<span class="nav-icon">💭</span><span class="nav-label">自主思考</span>
</button>
</nav>
<div class="sidebar-footer">
<span id="ws-dot" class="disconnected"></span>
@@ -482,6 +488,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<div class="panel" id="panel-performance"></div>
<!-- 数据库监看 -->
<div class="panel" id="panel-database"></div>
<!-- 工具调用记录 -->
<div class="panel" id="panel-toolCalls"></div>
<!-- 自主思考日志 -->
<div class="panel" id="panel-thinking"></div>
</div>
</div>
@@ -695,6 +705,7 @@ function switchPanel(name) {
const titles = {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
toolCalls: '🔧 工具调用记录', thinking: '💭 自主思考',
};
document.getElementById('panel-title').textContent = titles[name] || name;
@@ -714,6 +725,8 @@ function switchPanel(name) {
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); break;
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
}
}
@@ -2284,6 +2297,337 @@ async function controlDB(action) {
}
}
// ========== 面板8: 工具调用记录 ==========
async function renderToolCallsPanel() {
var container = document.getElementById('panel-toolCalls');
if (!STATE.toolCallsPage) STATE.toolCallsPage = 1;
if (!STATE.toolCallsFilter) STATE.toolCallsFilter = '';
if (!STATE.toolCallsAutoRefresh) STATE.toolCallsAutoRefresh = null;
if (!STATE.toolCallsLimit) STATE.toolCallsLimit = 20;
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.toolCallsAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="toolcalls-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleToolCallsAutoRefresh(this.checked)">' +
'自动刷新</label>';
var statsData = null;
try {
var statsResp = await fetch('/api/tool-calls/stats');
if (statsResp.ok) statsData = await statsResp.json();
} catch(e) {}
var callsData = null;
try {
var params = '?page=' + STATE.toolCallsPage + '&limit=' + STATE.toolCallsLimit;
if (STATE.toolCallsFilter) params += '&tool_name=' + encodeURIComponent(STATE.toolCallsFilter);
var callsResp = await fetch('/api/tool-calls' + params);
if (callsResp.ok) callsData = await callsResp.json();
} catch(e) {}
if (!callsData || callsData.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
(callsData && callsData.error ? escHtml(callsData.error) : '无法连接到 Tool-Engine 服务,请在「服务管理」中启动 Tool-Engine') +
(callsData && callsData.hint ? '<br><small>' + escHtml(callsData.hint) + '</small>' : '') +
'</div>';
return;
}
var totalCalls = statsData ? statsData.total_calls : callsData.total;
var successRate = statsData ? (statsData.success_rate || 0).toFixed(1) : 0;
var avgDuration = statsData ? (statsData.avg_duration_ms || 0).toFixed(0) : 0;
var byTool = statsData && statsData.by_tool ? statsData.by_tool : [];
var toolNamesSet = {};
if (byTool.length > 0) {
for (var i = 0; i < byTool.length; i++) { toolNamesSet[byTool[i].tool_name] = true; }
}
if (callsData.calls) {
for (var i = 0; i < callsData.calls.length; i++) { toolNamesSet[callsData.calls[i].tool_name] = true; }
}
var toolNames = Object.keys(toolNamesSet).sort();
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalCalls + '</div><div class="stat-label">总调用</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + successRate + '%</div><div class="stat-label">成功率</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgDuration + 'ms</div><div class="stat-label">平均耗时</div></div>' +
'<div class="stat-card orange"><div class="stat-value">' + toolNames.length + '</div><div class="stat-label">工具数</div></div>' +
'</div>';
var filterHtml = '<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;">' +
'<select id="toolcalls-filter-select" onchange="changeToolCallsFilter(this.value)" style="width:auto;min-width:160px;">' +
'<option value="">全部工具</option>';
for (var i = 0; i < toolNames.length; i++) {
filterHtml += '<option value="' + escHtml(toolNames[i]) + '"' + (STATE.toolCallsFilter === toolNames[i] ? ' selected' : '') + '>' + escHtml(toolNames[i]) + '</option>';
}
filterHtml += '</select>' +
'<button class="btn btn-sm" onclick="refreshToolCallsPanel()">🔄 刷新</button>' +
'</div>';
var distHtml = '';
if (byTool.length > 0) {
distHtml = '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">';
for (var i = 0; i < byTool.length; i++) {
var t = byTool[i];
distHtml += '<span class="badge" style="background:var(--bg3);font-size:11px;cursor:pointer;" onclick="changeToolCallsFilter(\'' + escHtml(t.tool_name) + '\')" title="' + escHtml(t.tool_name) + ': ' + t.count + ' 次, 成功率 ' + (t.count > 0 ? (t.success_count / t.count * 100).toFixed(0) : 0) + '%">' +
escHtml(t.tool_name) + ' ' + t.count + '</span>';
}
distHtml += '</div>';
}
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr><th style="width:100px;">时间</th><th style="width:110px;">工具</th><th>参数</th><th>结果</th><th style="width:50px;">状态</th><th style="width:55px;">耗时</th></tr></thead><tbody>';
if (callsData.calls && callsData.calls.length > 0) {
for (var i = 0; i < callsData.calls.length; i++) {
var call = callsData.calls[i];
var argsStr = '';
try {
if (typeof call.arguments === 'string') {
var parsed = JSON.parse(call.arguments);
argsStr = JSON.stringify(parsed);
} else if (call.arguments) {
argsStr = JSON.stringify(call.arguments);
}
} catch(e) { argsStr = String(call.arguments || ''); }
if (argsStr.length > 60) argsStr = argsStr.slice(0, 57) + '...';
var outputStr = (call.output || call.error || '');
if (outputStr.length > 80) outputStr = outputStr.slice(0, 77) + '...';
var timeStr = call.created_at ? new Date(call.created_at).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
var statusIcon = call.success ? '✅' : '❌';
var statusColor = call.success ? 'var(--green)' : 'var(--red)';
var durationStr = call.duration_ms ? call.duration_ms + 'ms' : '—';
var callId = 'tc-' + call.id;
tableHtml += '<tr class="toolcall-row" data-callid="' + callId + '" onclick="toggleToolCallExpand(\'' + callId + '\')" style="cursor:pointer;">' +
'<td style="font-size:11px;color:var(--text2);">' + escHtml(timeStr) + '</td>' +
'<td><span class="badge" style="background:var(--bg3);">' + escHtml(call.tool_name) + '</span></td>' +
'<td style="font-family:monospace;font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escHtml(argsStr) + '</td>' +
'<td style="font-family:monospace;font-size:11px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:' + (call.success ? 'var(--text2)' : 'var(--red)') + ';">' + escHtml(outputStr) + '</td>' +
'<td style="text-align:center;color:' + statusColor + ';">' + statusIcon + '</td>' +
'<td style="text-align:right;font-size:11px;color:var(--text2);">' + durationStr + '</td>' +
'</tr>';
// Expand row showing full args and output
var argsDisplay = call.arguments;
if (typeof argsDisplay === 'string') {
try { argsDisplay = JSON.parse(argsDisplay); } catch(e) {}
}
tableHtml += '<tr class="toolcall-expand" id="' + callId + '-expand" style="display:none;"><td colspan="6" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">完整参数:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;">' + escHtml(JSON.stringify(argsDisplay, null, 2)) + '</pre></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">完整输出:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:200px;overflow:auto;white-space:pre-wrap;color:' + (call.success ? 'var(--text)' : 'var(--red)') + ';">' + escHtml(call.output || call.error || '') + '</pre></div>' +
'</div>' +
'<div style="margin-top:8px;font-size:11px;color:var(--text3);">Call ID: ' + escHtml(call.call_id || '—') + ' | User: ' + escHtml(call.user_id || '—') + ' | Session: ' + escHtml(call.session_id || '—') + ' | ' + (call.created_at ? new Date(call.created_at).toLocaleString('zh-CN', {hour12: false}) : '') + '</div>' +
'</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:30px;">暂无调用记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
var paginationHtml = '';
if (callsData.total_pages > 0) {
paginationHtml = '<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px;font-size:12px;">' +
'<button class="btn btn-sm" ' + (callsData.page <= 1 ? 'disabled' : '') + ' onclick="goToolCallsPage(' + (callsData.page - 1) + ')">← 上一页</button>' +
'<span style="color:var(--text2);">第 ' + callsData.page + '/' + callsData.total_pages + ' 页</span>' +
'<button class="btn btn-sm" ' + (callsData.page >= callsData.total_pages ? 'disabled' : '') + ' onclick="goToolCallsPage(' + (callsData.page + 1) + ')">下一页 →</button>' +
'</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + distHtml + tableHtml + paginationHtml;
}
function toggleToolCallExpand(callId) {
var expandRow = document.getElementById(callId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
function changeToolCallsFilter(toolName) {
STATE.toolCallsFilter = toolName;
STATE.toolCallsPage = 1;
renderToolCallsPanel();
}
function goToolCallsPage(page) {
STATE.toolCallsPage = page;
renderToolCallsPanel();
document.getElementById('panel-toolCalls').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function refreshToolCallsPanel() {
renderToolCallsPanel();
}
function toggleToolCallsAutoRefresh(on) {
if (STATE.toolCallsAutoRefresh) {
clearInterval(STATE.toolCallsAutoRefresh);
STATE.toolCallsAutoRefresh = null;
}
if (on) {
STATE.toolCallsAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'toolCalls') renderToolCallsPanel();
}, 5000);
}
}
// ========== 面板9: 自主思考日志 ==========
async function renderThinkingPanel() {
var container = document.getElementById('panel-thinking');
if (!STATE.thinkingPage) STATE.thinkingPage = 1;
if (!STATE.thinkingAutoRefresh) STATE.thinkingAutoRefresh = null;
if (!STATE.thinkingLimit) STATE.thinkingLimit = 20;
if (!STATE.thinkingUserId) STATE.thinkingUserId = 'admin_admin';
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.thinkingAutoRefresh !== null;
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
'<input type="checkbox" id="thinking-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleThinkingAutoRefresh(this.checked)">' +
'自动刷新 (5s)</label>';
var statsData = null;
try {
var statsResp = await fetch('/api/v1/thinking/stats?user_id=' + encodeURIComponent(STATE.thinkingUserId));
if (statsResp.ok) statsData = await statsResp.json();
} catch(e) {}
var logsData = null;
try {
var params = '?user_id=' + encodeURIComponent(STATE.thinkingUserId) + '&limit=' + STATE.thinkingLimit + '&offset=' + ((STATE.thinkingPage - 1) * STATE.thinkingLimit);
var logsResp = await fetch('/api/v1/thinking' + params);
if (logsResp.ok) logsData = await logsResp.json();
} catch(e) {}
if (!logsData || logsData.error) {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' +
(logsData && logsData.error ? escHtml(logsData.error) : '无法连接到 Memory-Service,请在「服务管理」中启动 Memory-Service') +
(logsData && logsData.hint ? '<br><small>' + escHtml(logsData.hint) + '</small>' : '') +
'</div>';
return;
}
var totalLogs = statsData ? statsData.total_logs : (logsData.total || 0);
var totalToolCalls = statsData ? statsData.total_tool_calls : 0;
var avgContentLen = statsData ? (statsData.avg_content_length || 0).toFixed(0) : 0;
var latestAt = statsData ? (statsData.latest_at ? formatTime(statsData.latest_at) : '—') : '—';
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
'<div class="stat-card accent"><div class="stat-value">' + totalLogs + '</div><div class="stat-label">总思考次数</div></div>' +
'<div class="stat-card green"><div class="stat-value">' + totalToolCalls + '</div><div class="stat-label">总工具调用</div></div>' +
'<div class="stat-card blue"><div class="stat-value">' + avgContentLen + '</div><div class="stat-label">平均长度(字符)</div></div>' +
'<div class="stat-card orange"><div class="stat-value">' + latestAt + '</div><div class="stat-label">最近思考</div></div>' +
'</div>';
var filterHtml = '<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;">' +
'<button class="btn btn-sm" onclick="refreshThinkingPanel()">🔄 刷新</button>' +
'<span style="font-size:11px;color:var(--text3);">用户: ' + escHtml(STATE.thinkingUserId) + '</span>' +
'</div>';
var tableHtml = '<div class="table-wrap"><table>' +
'<thead><tr><th style="width:130px;">时间</th><th style="width:80px;">工具调用</th><th style="width:80px;">内容长度</th><th>内容摘要</th></tr></thead><tbody>';
var logs = logsData.logs || [];
if (logs.length > 0) {
for (var i = 0; i < logs.length; i++) {
var log = logs[i];
var timeStr = log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '—';
var toolCallCount = log.tool_call_count || 0;
var contentLen = log.content_length || 0;
var summary = log.content || '';
// Extract first line or first 120 chars as summary
var firstLine = summary.split('\n')[0];
if (firstLine.length > 120) firstLine = firstLine.slice(0, 117) + '...';
var logId = 'th-' + log.id;
tableHtml += '<tr class="thinking-row" data-logid="' + logId + '" onclick="toggleThinkingExpand(\'' + logId + '\')" style="cursor:pointer;">' +
'<td style="font-size:11px;color:var(--text2);">' + escHtml(timeStr) + '</td>' +
'<td style="text-align:center;"><span class="badge" style="background:' + (toolCallCount > 0 ? 'var(--accent)' : 'var(--bg3)') + ';">' + toolCallCount + '</span></td>' +
'<td style="text-align:right;font-size:11px;color:var(--text2);">' + contentLen + '</td>' +
'<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;">' + escHtml(firstLine) + '</td>' +
'</tr>';
// Expand row with full content and tool calls
var toolCallsDisplay = '无';
try {
var parsed = typeof log.tool_calls === 'string' ? JSON.parse(log.tool_calls) : log.tool_calls;
if (parsed && Array.isArray(parsed) && parsed.length > 0) {
toolCallsDisplay = '';
for (var j = 0; j < parsed.length; j++) {
var tc = parsed[j];
var tcName = tc.function ? (tc.function.name || '未知') : (tc.name || '未知');
var tcArgs = tc.function ? (tc.function.arguments || '') : (tc.arguments || '');
if (typeof tcArgs === 'object') tcArgs = JSON.stringify(tcArgs);
toolCallsDisplay += '<div style="margin-bottom:6px;padding:6px;background:var(--bg2);border-radius:4px;">' +
'<strong style="color:var(--accent2);">' + escHtml(tcName) + '</strong>' +
'<pre style="font-size:10px;margin:4px 0 0;white-space:pre-wrap;color:var(--text2);">' + escHtml(String(tcArgs).slice(0, 500)) + '</pre>' +
'</div>';
}
}
} catch(e) { toolCallsDisplay = '<span style="color:var(--text3);">解析失败</span>'; }
tableHtml += '<tr class="thinking-expand" id="' + logId + '-expand" style="display:none;"><td colspan="4" style="background:var(--bg);padding:12px 24px;">' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">' +
'<div><strong style="color:var(--text2);font-size:11px;">完整思考内容:</strong><pre style="background:var(--bg3);padding:8px;border-radius:4px;font-size:11px;margin-top:4px;max-height:300px;overflow:auto;white-space:pre-wrap;">' + escHtml(log.content || '') + '</pre></div>' +
'<div><strong style="color:var(--text2);font-size:11px;">工具调用详情:</strong><div style="margin-top:4px;max-height:300px;overflow:auto;">' + toolCallsDisplay + '</div></div>' +
'</div>' +
'<div style="margin-top:8px;font-size:11px;color:var(--text3);">ID: ' + escHtml(log.id || '—') + ' | User: ' + escHtml(log.user_id || '—') + ' | 内容长度: ' + (log.content_length || 0) + ' | ' + (log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', {hour12: false}) : '') + '</div>' +
'</td></tr>';
}
} else {
tableHtml += '<tr><td colspan="4" style="text-align:center;color:var(--text3);padding:30px;">暂无自主思考记录</td></tr>';
}
tableHtml += '</tbody></table></div>';
var totalPages = Math.max(1, Math.ceil((logsData.total || 0) / STATE.thinkingLimit));
var paginationHtml = '';
if (totalPages > 1) {
paginationHtml = '<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px;font-size:12px;">' +
'<button class="btn btn-sm" ' + (STATE.thinkingPage <= 1 ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage - 1) + ')">← 上一页</button>' +
'<span style="color:var(--text2);">第 ' + STATE.thinkingPage + '/' + totalPages + ' 页</span>' +
'<button class="btn btn-sm" ' + (STATE.thinkingPage >= totalPages ? 'disabled' : '') + ' onclick="goThinkingPage(' + (STATE.thinkingPage + 1) + ')">下一页 →</button>' +
'</div>';
}
container.innerHTML = statsCardsHtml + filterHtml + tableHtml + paginationHtml;
}
function toggleThinkingExpand(logId) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
}
}
function goThinkingPage(page) {
STATE.thinkingPage = page;
renderThinkingPanel();
document.getElementById('panel-thinking').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function refreshThinkingPanel() {
renderThinkingPanel();
}
function toggleThinkingAutoRefresh(on) {
if (STATE.thinkingAutoRefresh) {
clearInterval(STATE.thinkingAutoRefresh);
STATE.thinkingAutoRefresh = null;
}
if (on) {
STATE.thinkingAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'thinking') renderThinkingPanel();
}, 5000);
}
}
</script>
<script src="iot-panel.js"></script>
<script>