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:
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ const ROOT = path.resolve(__dirname, '../..');
|
||||
export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
||||
export const TOOL_ENGINE_URL = process.env.TOOL_ENGINE_URL || 'http://localhost:8092';
|
||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
|
||||
|
||||
@@ -54,6 +55,8 @@ export const SERVICES = {
|
||||
GATEWAY_PORT: '8080',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
|
||||
TOOL_ENGINE_URL: process.env.TOOL_ENGINE_URL || 'http://localhost:8092',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
|
||||
|
||||
+130
-1
@@ -17,7 +17,9 @@ import { execSync, spawn } from 'child_process';
|
||||
|
||||
import { processManager } from './process-manager.js';
|
||||
import { performanceMonitor } from './performance.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||
|
||||
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
|
||||
@@ -562,6 +564,133 @@ app.get('/api/iot/devices/:id/history', async (req, res) => {
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 工具调用记录代理 (转发到 tool-engine) ----
|
||||
|
||||
/**
|
||||
* 代理请求到 Tool-Engine
|
||||
* @param {string} path - Tool-Engine API 路径
|
||||
* @param {object} opts - fetch 选项
|
||||
*/
|
||||
async function proxyToToolEngine(path, opts = {}) {
|
||||
const url = `${TOOL_ENGINE_URL}${path}`;
|
||||
const logPrefix = `[ToolEngine代理]`;
|
||||
try {
|
||||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||||
const resp = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||||
}
|
||||
return { status: resp.status, body };
|
||||
} catch (err) {
|
||||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
error: `Tool-Engine 不可达: ${err.message}`,
|
||||
errorType: isConnRefused ? 'tool_engine_not_running' : 'tool_engine_unreachable',
|
||||
hint: isConnRefused
|
||||
? 'Tool-Engine 服务未启动,请先在「服务管理」面板中启动 Tool-Engine'
|
||||
: 'Tool-Engine 服务无响应,请检查网络连接和服务状态',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/tool-calls — 查询工具调用记录
|
||||
app.get('/api/tool-calls', async (req, res) => {
|
||||
const { tool_name, page, limit } = req.query;
|
||||
const params = new URLSearchParams();
|
||||
if (tool_name) params.set('tool_name', tool_name);
|
||||
params.set('page', page || '1');
|
||||
params.set('limit', limit || '20');
|
||||
const result = await proxyToToolEngine(`/api/v1/tools/calls?${params.toString()}`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/tool-calls/stats — 工具调用统计
|
||||
app.get('/api/tool-calls/stats', async (_req, res) => {
|
||||
const result = await proxyToToolEngine('/api/v1/tools/calls/stats');
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 自主思考日志代理 (转发到 memory-service) ----
|
||||
|
||||
/**
|
||||
* 代理请求到 Memory-Service
|
||||
* @param {string} path - Memory-Service API 路径
|
||||
* @param {object} opts - fetch 选项
|
||||
*/
|
||||
async function proxyToMemoryService(path, opts = {}) {
|
||||
const url = `${MEMORY_SERVICE_URL}${path}`;
|
||||
const logPrefix = `[MemoryService代理]`;
|
||||
try {
|
||||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||||
const resp = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||||
}
|
||||
return { status: resp.status, body };
|
||||
} catch (err) {
|
||||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
error: `Memory-Service 不可达: ${err.message}`,
|
||||
errorType: isConnRefused ? 'memory_service_not_running' : 'memory_service_unreachable',
|
||||
hint: isConnRefused
|
||||
? 'Memory-Service 服务未启动,请先在「服务管理」面板中启动 Memory-Service'
|
||||
: 'Memory-Service 服务无响应,请检查网络连接和服务状态',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/thinking — 查询自主思考日志列表
|
||||
app.get('/api/v1/thinking', async (req, res) => {
|
||||
const { user_id, limit, offset } = req.query;
|
||||
const params = new URLSearchParams();
|
||||
if (user_id) params.set('user_id', user_id);
|
||||
if (limit) params.set('limit', limit);
|
||||
if (offset) params.set('offset', offset);
|
||||
const result = await proxyToMemoryService(`/api/v1/thinking?${params.toString()}`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// POST /api/v1/thinking — 创建自主思考日志
|
||||
app.post('/api/v1/thinking', async (req, res) => {
|
||||
const result = await proxyToMemoryService('/api/v1/thinking', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req.body),
|
||||
});
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/v1/thinking/stats — 自主思考统计
|
||||
app.get('/api/v1/thinking/stats', async (req, res) => {
|
||||
const { user_id } = req.query;
|
||||
const params = user_id ? `?user_id=${encodeURIComponent(user_id)}` : '';
|
||||
const result = await proxyToMemoryService(`/api/v1/thinking/stats${params}`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/v1/thinking/:id — 获取单条自主思考日志
|
||||
app.get('/api/v1/thinking/:id', async (req, res) => {
|
||||
const result = await proxyToMemoryService(`/api/v1/thinking/${req.params.id}`);
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 健康检查代理 ----
|
||||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
const svc = SERVICES[req.params.id];
|
||||
|
||||
Reference in New Issue
Block a user