diff --git a/devtools/public/index.html b/devtools/public/index.html index 11865f7..3e5b11f 100644 --- a/devtools/public/index.html +++ b/devtools/public/index.html @@ -491,6 +491,10 @@ const STATE = { dashboardInterval: null, statusInterval: null, dbInterval: null, + // 仪表盘增量刷新 (Bug 7) + dashboardRenderCount: 0, + // 资源使用 60s 滑动窗口历史 (Bug 6) + resourceHistory: {}, }; // ========== WebSocket ========== @@ -543,6 +547,26 @@ function escHtml(s) { return s.replace(/&/g,'&').replace(//g,'>'); } +function drawSparkline(canvas, data, color) { + if (!canvas || data.length < 2) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const range = max - min || 1; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = (i / (data.length - 1)) * w; + const y = h - ((data[i] - min) / range) * (h - 4) - 2; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); +} + function formatUptime(ms) { if (!ms || ms < 0) return '—'; const s = Math.floor(ms / 1000); @@ -701,7 +725,8 @@ function stopDbAutoRefresh() { async function renderDashboard() { const data = await api('/api/dashboard'); if (data.error) { - document.getElementById('panel-dashboard').innerHTML = `
⚠️
${escHtml(data.error)}
`; + document.getElementById('panel-dashboard').innerHTML = '
⚠️
' + escHtml(data.error) + '
'; + STATE.dashboardRenderCount = 0; return; } STATE.dashboardData = data; @@ -709,106 +734,167 @@ async function renderDashboard() { const svcs = data.services?.list || {}; const runningCount = data.services?.running || 0; const totalSvcs = data.services?.total || Object.keys(svcs).length; + const isFirstRender = STATE.dashboardRenderCount === 0; - document.getElementById('panel-dashboard').innerHTML = ` - -
-
-
${runningCount}/${totalSvcs}
-
服务运行中
-
-
-
${data.sessions?.active ?? '—'}
-
活跃会话
-
-
-
${data.memory?.total ?? '—'}
-
记忆条目
-
-
-
${data.system?.heapUsedMB ?? '—'} MB
-
DevTools 内存
-
-
+ // Bug 7: 首次渲染完整 DOM,后续只做增量更新 + if (isFirstRender) { + document.getElementById('panel-dashboard').innerHTML = + '' + + '
' + + '
' + + '
' + runningCount + '/' + totalSvcs + '
' + + '
服务运行中
' + + '
' + + '
' + + '
' + (data.sessions?.active ?? '—') + '
' + + '
活跃会话
' + + '
' + + '
' + + '
' + (data.memory?.total ?? '—') + '
' + + '
记忆条目
' + + '
' + + '
' + + '
' + (data.system?.heapUsedMB ?? '—') + ' MB
' + + '
DevTools 内存
' + + '
' + + '
' + - -
-
- 📡 服务状态 -
- - - -
-
-
-
+ '' + + '
' + + '
' + + '📡 服务状态' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + - -
-
- 🗄️ 数据库 - 检查中... -
-
-
PostgreSQL
类型
-
5432
端口
-
状态
-
-
- - - - 🔍 详情 → -
-
+ '' + + '
' + + '
' + + '🗄️ 数据库' + + '检查中...' + + '
' + + '
' + + '
PostgreSQL
类型
' + + '
5432
端口
' + + '
状态
' + + '
' + + '
' + + '' + + '' + + '' + + '🔍 详情 →' + + '
' + + '
' + - -
-
-
⚡ 资源使用
-
-
-
-
📊 性能仪表盘
-
-
📊
等待性能数据...
-
-
-
+ '' + + '
' + + '
' + + '
⚡ 资源使用
' + + '
' + + '
' + + '
' + + '
📊 性能仪表盘
' + + '
' + + '
📊
等待性能数据...
' + + '
' + + '
' + + '
' + - -
-
💻 系统信息
-
-
运行时间: ${formatUptime((data.system?.uptime || 0) * 1000)}
-
堆内存: ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB
-
总消息数: ${data.sessions?.totalMessages ?? 0}
-
更新时间: ${formatTime(data.timestamp)}
-
-
- `; + '' + + '
' + + '
💻 系统信息
' + + '
' + + '
运行时间: ' + formatUptime((data.system?.uptime || 0) * 1000) + '
' + + '
堆内存: ' + (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB
' + + '
总消息数: ' + (data.sessions?.totalMessages ?? 0) + '
' + + '
更新时间: ' + formatTime(data.timestamp) + '
' + + '
' + + '
'; + } else { + // Bug 7: 增量更新 — 只更新动态数值,不重建整个 DOM + var el; + el = document.getElementById('stat-running'); if (el) el.textContent = runningCount + '/' + totalSvcs; + el = document.getElementById('stat-sessions'); if (el) el.textContent = data.sessions?.active ?? '—'; + el = document.getElementById('stat-memory'); if (el) el.textContent = data.memory?.total ?? '—'; + el = document.getElementById('stat-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB'; + + // 系统信息 + el = document.getElementById('sys-uptime'); if (el) el.textContent = formatUptime((data.system?.uptime || 0) * 1000); + el = document.getElementById('sys-heap'); if (el) el.textContent = (data.system?.heapUsedMB ?? '—') + ' MB / ' + (data.system?.heapTotalMB ?? '—') + ' MB'; + el = document.getElementById('sys-msgs'); if (el) el.textContent = data.sessions?.totalMessages ?? 0; + el = document.getElementById('sys-time'); if (el) el.textContent = formatTime(data.timestamp); + } // 渲染服务卡片 renderDashboardSvcCards(svcs); - // 渲染数据库卡片 + // 渲染数据库卡片 (renderDBCard 本身就只更新 textContent,见 Bug 7 fix) renderDBCard(); - // 渲染性能快照 - const perfContainer = document.getElementById('dashboard-perf'); - const perf = data.performance?.perService || {}; - perfContainer.innerHTML = Object.entries(perf).map(([id, p]) => ` -
- ${escapeId(id)} - - CPU ${p.cpu || 0}% | MEM ${p.mem || 0}MB - -
- `).join('') || '
📊
等待采样数据...
'; + // Bug 6: 渲染资源使用卡片 (增量更新 + sparkline) + renderResourceUsage(data.performance?.perService || {}); - // 渲染性能仪表盘 + // 渲染性能仪表盘 (updatePerformanceDashboard 内联更新) updatePerformanceDashboard(data.performance?.perService || {}); + + STATE.dashboardRenderCount++; +} + +// ========== 资源使用卡片渲染 (Bug 6: sparkline + 增量更新) ========== +function renderResourceUsage(perfData) { + const container = document.getElementById('dashboard-perf'); + if (!container) return; + + const entries = Object.entries(perfData); + const MAX_HISTORY = 60; + const firstRender = STATE.dashboardRenderCount === 0; + + // 首次渲染: 创建完整 DOM 结构 (含 canvas) + if (firstRender || entries.length > 0 && !container.querySelector('.resource-row')) { + if (entries.length === 0) { + container.innerHTML = '
📊
等待采样数据...
'; + return; + } + container.innerHTML = entries.map(function (kv) { + const id = kv[0], p = kv[1]; + return '
' + + '' + escapeId(id) + '' + + '' + + '' + + 'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB' + + '' + + '
'; + }).join(''); + } + + // 增量更新: 只更新数值和 sparkline + const rows = container.querySelectorAll('.resource-row'); + rows.forEach(function (row) { + const svcId = row.getAttribute('data-svc'); + const p = perfData[svcId]; + if (!p) return; + + // 更新数值 + const valEl = row.querySelector('.resource-val'); + if (valEl) valEl.textContent = 'CPU ' + (p.cpu || 0) + '% | MEM ' + (p.mem || 0) + 'MB'; + + // 更新 60s 滑动窗口历史 + if (!STATE.resourceHistory[svcId]) STATE.resourceHistory[svcId] = { cpu: [], mem: [] }; + var h = STATE.resourceHistory[svcId]; + h.cpu.push(p.cpu || 0); + h.mem.push(p.mem || 0); + if (h.cpu.length > MAX_HISTORY) h.cpu.shift(); + if (h.mem.length > MAX_HISTORY) h.mem.shift(); + + // 绘制 CPU sparkline + var cpuCanvas = document.getElementById('sparkline-' + svcId); + if (cpuCanvas) drawSparkline(cpuCanvas, h.cpu, '#3b82f6'); + }); } // ========== 性能仪表盘渲染 ========== @@ -859,59 +945,73 @@ async function updatePerformanceDashboard(perfData) { } } catch { /* 忽略: 使用本地计算的数据 */ } - container.innerHTML = ` -
- -
- 🖥 CPU -
-
-
- ${avgCpu}% ${trendCpu} -
+ // Bug 7: 增量更新 — 首次创建 DOM 结构,后续只更新数值 + const isFirstRender = !container.querySelector('.perf-dashboard'); + if (isFirstRender) { + container.innerHTML = + '
' + + '' + + '
' + + '🖥 CPU' + + '
' + + '
' + + '
' + + '' + avgCpu + '% ' + trendCpu + '' + + '
' + - -
- 💾 内存 -
-
-
- ${Math.round(totalMem)} MB ${trendMem} -
+ '' + + '
' + + '💾 内存' + + '
' + + '
' + + '
' + + '' + Math.round(totalMem) + ' MB ' + trendMem + '' + + '
' + - -
-
- - 平均请求延迟 - ${avgLatency} -
-
- 🔗 - 活跃连接数 - ${activeCount} -
-
- 📦 - 监控服务数 - ${entries.length} -
-
+ '' + + '
' + + '
' + + '' + + '平均请求延迟' + + '' + avgLatency + '' + + '
' + + '
' + + '🔗' + + '活跃连接数' + + '' + activeCount + '' + + '
' + + '
' + + '📦' + + '监控服务数' + + '' + entries.length + '' + + '
' + + '
' + + '
'; + } else { + // 增量更新: 只更新数值 + var cpuBar = document.getElementById('perf-cpu-bar'); + if (cpuBar) { + cpuBar.className = 'perf-bar ' + cpuLevel; + cpuBar.style.width = Math.min(avgCpu, 100) + '%'; + } + var cpuVal = document.getElementById('perf-cpu-val'); + if (cpuVal) cpuVal.textContent = avgCpu + '% ' + trendCpu; - -
- ${entries.map(([id, p]) => ` -
- ${p.pid ? '🟢' : '🔴'} - ${escapeId(id)} - - CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB - -
- `).join('')} -
-
- `; + var memBar = document.getElementById('perf-mem-bar'); + if (memBar) { + memBar.className = 'perf-bar ' + memLevel; + memBar.style.width = Math.min(totalMem / 1024 * 100, 100) + '%'; + } + var memVal = document.getElementById('perf-mem-val'); + if (memVal) memVal.textContent = Math.round(totalMem) + ' MB ' + trendMem; + + var latEl = document.getElementById('perf-latency'); + if (latEl) latEl.textContent = avgLatency; + var connEl = document.getElementById('perf-conns'); + if (connEl) connEl.textContent = activeCount; + var svcEl = document.getElementById('perf-svcs'); + if (svcEl) svcEl.textContent = entries.length; + } } function renderDashboardSvcCards(svcs) { @@ -1192,17 +1292,30 @@ async function fetchActiveSessions() { if (Object.keys(users).length === 0) { container.innerHTML = '
💤
当前没有活跃会话
'; + STATE.expandedSessions = []; return; } + // Bug 5: 保存当前展开的 session ID 列表,以便重建 DOM 后恢复 + const previouslyExpanded = []; + const existingDetails = container.querySelectorAll('[id^="session-detail-"]'); + existingDetails.forEach(function(el) { + if (el.style.display !== 'none') { + const match = el.id.match(/^session-detail-(\d+)$/); + if (match) previouslyExpanded.push(parseInt(match[1])); + } + }); + let html = ''; let globalIndex = 0; + const flatSessionMap = []; // 记录 index -> session 映射,用于恢复展开 for (const [userID, sessions] of Object.entries(users)) { html += `
`; html += `
👤 User: ${escHtml(userID)}
`; for (const s of sessions) { const idx = globalIndex++; + flatSessionMap.push({ index: idx, session: s, userID: userID }); html += `
@@ -1223,6 +1336,104 @@ async function fetchActiveSessions() { } container.innerHTML = html; + + // Bug 5: 恢复之前展开的 session (通过 session_id 匹配新旧 index) + if (previouslyExpanded.length > 0) { + // 构建 session_id -> 旧 index 的映射 + const oldSessionIdToIndex = {}; + STATE.sessionsData.forEach(function(s, i) { oldSessionIdToIndex[s.session_id] = i; }); + + // 对每个之前展开的 index,找到对应的 session_id,再找到新的 index + const toExpandNewIndices = []; + previouslyExpanded.forEach(function(oldIdx) { + const oldSession = STATE.sessionsData[oldIdx]; + if (oldSession) { + const sid = oldSession.session_id; + // 在新的 flatSessionMap 中按 session_id 查找 + for (let j = 0; j < flatSessionMap.length; j++) { + if (flatSessionMap[j].session.session_id === sid) { + toExpandNewIndices.push(j); + break; + } + } + } + }); + + // 恢复展开 + toExpandNewIndices.forEach(function(newIdx) { + restoreSessionExpand(newIdx); + }); + } +} + +// Bug 5 helper: 恢复展开的 session UI 并自动加载详情 +async function restoreSessionExpand(index) { + const detailRow = document.getElementById('session-detail-' + index); + const arrow = document.getElementById('session-arrow-' + index); + if (!detailRow || !arrow) return; + detailRow.style.display = ''; + arrow.classList.add('open'); + // 直接加载详情内容 (不调用 toggleSessionDetail 以避免 flip-flop) + const session = STATE.sessionsData[index]; + if (!session) return; + const contentEl = document.getElementById('session-detail-content-' + index); + if (!contentEl) return; + await loadSessionDetailContent(session, contentEl); +} + +// 提取 session 详情加载逻辑为独立函数 (Bug 5 复用) +async function loadSessionDetailContent(session, contentEl) { + const data = await api('/api/sessions/' + session.session_id); + if (data.error) { + let hint = ''; + if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') { + hint = '
💡 提示: 请先在「服务管理」面板中启动 Gateway 服务'; + } else if (data.errorType === 'gateway_unreachable') { + hint = '
💡 提示: Gateway 服务无响应,请检查网络连接和服务状态'; + } else if (data.status === 502) { + hint = '
💡 提示: 请确认 Gateway 服务已启动'; + } + contentEl.innerHTML = '
' + escHtml(data.error) + hint + '
'; + return; + } + const messages = data.recent_messages || []; + contentEl.innerHTML = + '
' + + '会话ID:' + + '' + escHtml(data.session_id || session.session_id) + '' + + '
' + + '
' + + '用户ID:' + + '' + escHtml(data.user_id || session.user_id) + '' + + '
' + + '
' + + '状态:' + + '' + (data.state || 'idle') + '' + + '
' + + '
' + + '消息数:' + + '' + (data.message_count || 0) + '' + + '
' + + '
' + + '连接时间:' + + '' + formatTime(data.connected_at) + '' + + '
' + + '
' + + '最后活跃:' + + '' + formatTime(data.last_activity) + '' + + '
' + + (messages.length > 0 ? + '
📝 最近消息 (' + messages.length + ')
' + + '
' + + messages.map(function(m) { + return '
' + + '' + m.role + '' + + '' + formatTime(m.timestamp) + '' + + escHtml(m.content || '') + + '
'; + }).join('') + + '
' + : '
暂无消息记录
'); } // 保留旧 loadSessions 兼容其他调用 @@ -1231,9 +1442,9 @@ async function loadSessions() { } async function toggleSessionDetail(index) { - const detailRow = document.getElementById(`session-detail-${index}`); - const arrow = document.getElementById(`session-arrow-${index}`); - const contentEl = document.getElementById(`session-detail-content-${index}`); + const detailRow = document.getElementById('session-detail-' + index); + const arrow = document.getElementById('session-arrow-' + index); + const contentEl = document.getElementById('session-detail-content-' + index); if (detailRow.style.display !== 'none') { // 折叠 @@ -1249,61 +1460,8 @@ async function toggleSessionDetail(index) { const session = STATE.sessionsData[index]; if (!session) return; - // 获取详情 - const data = await api(`/api/sessions/${session.session_id}`); - - if (data.error) { - let hint = ''; - if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') { - hint = '
💡 提示: 请先在「服务管理」面板中启动 Gateway 服务'; - } else if (data.errorType === 'gateway_unreachable') { - hint = '
💡 提示: Gateway 服务无响应,请检查网络连接和服务状态'; - } else if (data.status === 502) { - hint = '
💡 提示: 请确认 Gateway 服务已启动'; - } - contentEl.innerHTML = `
${escHtml(data.error)}${hint}
`; - return; - } - - const messages = data.recent_messages || []; - contentEl.innerHTML = ` -
- 会话ID: - ${escHtml(data.session_id || session.session_id)} -
-
- 用户ID: - ${escHtml(data.user_id || session.user_id)} -
-
- 状态: - ${data.state || 'idle'} -
-
- 消息数: - ${data.message_count || 0} -
-
- 连接时间: - ${formatTime(data.connected_at)} -
-
- 最后活跃: - ${formatTime(data.last_activity)} -
- ${messages.length > 0 ? ` -
📝 最近消息 (${messages.length})
-
- ${messages.map(m => ` -
- ${m.role} - ${formatTime(m.timestamp)} - ${escHtml(m.content || '')} -
- `).join('')} -
- ` : '
暂无消息记录
'} - `; + // 委托给共用函数 + await loadSessionDetailContent(session, contentEl); } // ========== 面板4: 服务管理 ========== @@ -1529,21 +1687,36 @@ function renderPerfPanels(snap) { if (!container) return; const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend']; - container.innerHTML = ids.map(id => { - const s = snap[id] || { pid: null, cpu: 0, mem: 0 }; - return ` -
-
- ${escapeId(id)} - CPU ${s.cpu}% | MEM ${s.mem}MB -
-
- - ${drawChart(STATE.perfHistory[id] || [])} - -
-
`; - }).join(''); + // Bug 7: 增量更新 — 首次创建完整 DOM,后续只更新图表和数值 + const isFirstRender = !container.querySelector('.perf-card'); + + if (isFirstRender) { + container.innerHTML = ids.map(function(id) { + const s = snap[id] || { pid: null, cpu: 0, mem: 0 }; + return '
' + + '
' + + '' + escapeId(id) + '' + + 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB' + + '
' + + '
' + + '' + + drawChart(STATE.perfHistory[id] || []) + + '' + + '
' + + '
'; + }).join(''); + } else { + // 增量更新: 只更新 SVG 图表内容和快照数值 + ids.forEach(function(id) { + var card = container.querySelector('.perf-card[data-svc="' + id + '"]'); + if (!card) return; + var s = snap[id] || { pid: null, cpu: 0, mem: 0 }; + var valEl = card.querySelector('.perf-snap-val'); + if (valEl) valEl.textContent = 'CPU ' + s.cpu + '% | MEM ' + s.mem + 'MB'; + var svg = card.querySelector('.chart-svg'); + if (svg) svg.innerHTML = drawChart(STATE.perfHistory[id] || []); + }); + } } function drawChart(history) { @@ -1582,14 +1755,14 @@ async function fetchDatabaseStatus() { async function renderDatabasePanel() { const data = await fetchDatabaseStatus(); - document.getElementById('panel-actions').innerHTML = ` - - ⏱ 每5秒自动刷新 - `; + document.getElementById('panel-actions').innerHTML = + '' + + '⏱ 每5秒自动刷新'; const panel = document.getElementById('panel-database'); if (data.error) { - panel.innerHTML = `
⚠️
${escHtml(data.error)}
`; + panel.innerHTML = '
⚠️
' + escHtml(data.error) + '
'; + STATE.dbInitialized = false; return; } @@ -1614,71 +1787,132 @@ async function renderDatabasePanel() { } } - panel.innerHTML = ` - -
-
- 🔌 SSH 隧道状态 - ${tunnelRunning ? '运行中' : '未运行'} -
-
-
-
${aliveCount}/${totalPorts}
-
数据库端口通联
-
- ${pg ? ` -
-
${pg.memories ?? '—'}
-
记忆条目 (${escHtml(pg.database || '')})
-
- ` : ''} -
-
${formatTime(data.timestamp)}
-
最后检查时间
-
-
-
- ${ports.map(p => ` -
-
-
-
${escHtml(p.name)}
-
:${p.port} ${p.alive ? '✅' : '❌'}
-
-
- `).join('')} -
-
+ // Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新动态元素 + const isFirstRender = !STATE.dbInitialized; + if (isFirstRender) { + panel.innerHTML = + '' + + '
' + + '
' + + '🔌 SSH 隧道状态' + + '' + (tunnelRunning ? '运行中' : '未运行') + '' + + '
' + + '
' + + '
' + + '
' + aliveCount + '/' + totalPorts + '
' + + '
数据库端口通联
' + + '
' + + (pg ? + '
' + + '
' + (pg.memories ?? '—') + '
' + + '
记忆条目 (' + escHtml(pg.database || '') + ')
' + + '
' + : '') + + '
' + + '
' + formatTime(data.timestamp) + '
' + + '
最后检查时间
' + + '
' + + '
' + + '
' + + ports.map(function(p) { + return '
' + + '
' + + '
' + + '
' + escHtml(p.name) + '
' + + '
:' + p.port + ' ' + (p.alive ? '✅' : '❌') + '
' + + '
' + + '
'; + }).join('') + + '
' + + '
' + - -
-
🕹️ 隧道控制
-
- - - - -
- ${tunnelRunning && !allAlive ? '
⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道
' : ''} - -
+ '' + + '
' + + '
🕹️ 隧道控制
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道
' + + '' + + '
' + - -
-
📋 连接说明
-
-
🔑 SSH 服务器: root@cd.yeij.top
-
📁 隧道脚本: scripts/tunnel.sh
-
💡 所有数据库端口通过 SSH 转发至 localhost,无需修改 .env
-
-
- `; + '' + + '
' + + '
📋 连接说明
' + + '
' + + '
🔑 SSH 服务器: root@cd.yeij.top
' + + '
📁 隧道脚本: scripts/tunnel.sh
' + + '
💡 所有数据库端口通过 SSH 转发至 localhost,无需修改 .env
' + + '
' + + '
'; + STATE.dbInitialized = true; + } else { + // Bug 7: 增量更新 — 只更新状态徽章、计数器、端口卡片、检查时间 + var el; + + // 隧道状态徽章 + el = document.getElementById('db-tunnel-badge'); + if (el) { + el.className = 'badge ' + (tunnelRunning ? 'badge-running' : 'badge-stopped'); + el.textContent = tunnelRunning ? '运行中' : '未运行'; + } + + // 端口通联计数 + el = document.getElementById('db-alive-count'); + if (el) { + el.textContent = aliveCount + '/' + totalPorts; + el.style.color = allAlive ? 'var(--green)' : 'var(--red)'; + } + + // 记忆条目 + var memStat = document.getElementById('db-mem-stat'); + el = document.getElementById('db-mem-count'); + if (pg) { + if (memStat) memStat.style.display = ''; + if (el) el.textContent = pg.memories ?? '—'; + } else { + if (memStat) memStat.style.display = 'none'; + } + + // 检查时间 + el = document.getElementById('db-check-time'); + if (el) el.textContent = formatTime(data.timestamp); + + // 更新端口卡片 + var grid = document.getElementById('db-ports-grid'); + if (grid) { + ports.forEach(function(p) { + var card = grid.querySelector('.db-port-card[data-port="' + p.port + '"]'); + if (card) { + card.className = 'db-port-card ' + (p.alive ? 'alive' : 'dead'); + var lbl = card.querySelector('.db-port-label'); + if (lbl) lbl.textContent = ':' + p.port + ' ' + (p.alive ? '✅' : '❌'); + } + }); + } + + // 更新按钮 disable 状态 + el = document.getElementById('db-tunnel-start'); + if (el) el.disabled = !!(tunnelRunning && allAlive); + el = document.getElementById('db-tunnel-stop'); + if (el) el.disabled = !tunnelRunning; + + // 僵尸警告 + el = document.getElementById('db-zombie-warn'); + if (el) el.style.display = (tunnelRunning && !allAlive) ? 'block' : 'none'; + } } function refreshDatabasePanel() { diff --git a/devtools/public/iot-panel.js b/devtools/public/iot-panel.js index aa76f27..36c3e82 100644 --- a/devtools/public/iot-panel.js +++ b/devtools/public/iot-panel.js @@ -35,19 +35,21 @@ var IOT_COLOR_OPTIONS = [ ]; async function renderIoTPanel() { + var result = await fetchIoTDevices(); + var panel = document.getElementById('panel-iot'); + + // 更新操作栏 (只更新时间戳文本) document.getElementById('panel-actions').innerHTML = '' + '⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + ''; - var result = await fetchIoTDevices(); - var panel = document.getElementById('panel-iot'); - if (result.error) { var hint = ''; if (result.error.errorType === 'iot_not_running') { hint = '
💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务'; } panel.innerHTML = '
⚠️
' + escHtml(result.error.error) + hint + '
'; + STATE.iotInitialized = false; return; } @@ -68,14 +70,133 @@ async function renderIoTPanel() { badge.style.display = devices.length > 0 ? 'inline-block' : 'none'; } - var html = '
' + - '📡 模拟 IoT 设备 (' + devices.length + ')' + - '通过 IoT 调试服务 (端口 8083) 管理' + - '
' + - devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') + - '
'; + // Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值 + var grid = document.getElementById('iot-device-grid'); + var firstRender = !STATE.iotInitialized; - panel.innerHTML = html; + if (firstRender || !grid) { + // 首次渲染: 创建完整结构 + var html = '
' + + '📡 模拟 IoT 设备 (' + devices.length + ')' + + '通过 IoT 调试服务 (端口 8083) 管理' + + '
' + + devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') + + '
'; + panel.innerHTML = html; + STATE.iotInitialized = true; + } else { + // 增量更新: 只更新设备数量、最后更新时间 + var countEl = document.getElementById('iot-device-count'); + if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')'; + + // 对每个已有设备卡片做增量更新 + devices.forEach(function(device) { + updateIoTDeviceCardInPlace(device); + }); + } +} + +// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM +function updateIoTDeviceCardInPlace(device) { + var card = document.getElementById('iot-card-' + device.id); + if (!card) return; + + var isOn = device.status === 'on'; + + // 更新卡片 class (on/off 边框) + card.className = 'iot-device-card ' + (isOn ? 'on' : 'off'); + + // 更新状态圆点和文字 + var statusDot = card.querySelector('.iot-status-dot'); + if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off'); + var statusText = card.querySelector('.iot-device-status span:last-child'); + if (statusText) { + statusText.textContent = isOn ? '开启' : '关闭'; + statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)'; + } + + // 更新开关按钮 + var toggleBtn = card.querySelector('.iot-toggle-btn'); + if (toggleBtn) { + toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off'); + toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启'; + } + + // 更新设备属性值 (温度、亮度、位置等) + var propValues = card.querySelectorAll('.iot-prop-value'); + var type = device.type; + + if (type === 'ac') { + // AC: 温度 slider + 模式按钮 + var tempSlider = card.querySelector('.iot-prop-control input[type="range"]'); + if (tempSlider) { + tempSlider.value = device.temperature || 26; + var tempVal = tempSlider.nextElementSibling; + if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C'; + } + // 更新模式按钮 + var modeBtns = card.querySelectorAll('.iot-mode-btn'); + modeBtns.forEach(function(btn) { + var btnMode = btn.textContent.trim(); + if (btnMode === (device.mode || 'cool')) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + } else if (type === 'light') { + var brightSlider = card.querySelector('.iot-prop-control input[type="range"]'); + if (brightSlider) { + brightSlider.value = device.brightness || 80; + var brightVal = brightSlider.nextElementSibling; + if (brightVal) brightVal.textContent = (device.brightness || 80) + '%'; + } + // 更新颜色按钮 + var colorBtns = card.querySelectorAll('.iot-color-btn'); + colorBtns.forEach(function(btn) { + // 颜色值从 onclick 属性解析 + var onclick = btn.getAttribute('onclick') || ''; + var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/); + if (match && match[1] === (device.color || 'warm_white')) { + btn.classList.add('active'); + } else if (!match || match[1] !== (device.color || 'warm_white')) { + btn.classList.remove('active'); + } + }); + } else if (type === 'curtain') { + var posSlider = card.querySelector('.iot-prop-control input[type="range"]'); + if (posSlider) { + posSlider.value = device.position != null ? device.position : 100; + var posVal = posSlider.nextElementSibling; + if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%'; + } + } else if (device.temperature != null) { + // sensor/thermostat: 只读温度 + if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C'); + } + + // 更新电量 + var batteryEls = card.querySelectorAll('.iot-prop-value'); + batteryEls.forEach(function(el) { + if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') && + el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) { + if (device.battery != null) el.textContent = device.battery + '%'; + } + }); + + // 更新 AC 温度按钮的 onclick 引用 + if (type === 'ac') { + var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs'); + acBtns.forEach(function(btn) { + var text = btn.textContent.trim(); + var currentTemp = device.temperature || 26; + if (text === '⬇ -2°C') { + btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')"); + } else if (text === '⬆ +2°C') { + btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')"); + } + }); + } } function renderIoTDeviceCard(device) { diff --git a/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png new file mode 100755 index 0000000..a4928f2 Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png differ diff --git a/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Tangle-1.png b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Tangle-1.png new file mode 100755 index 0000000..0bffd7a Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Tangle-1.png differ diff --git a/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png new file mode 100755 index 0000000..d1f1473 Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png differ diff --git a/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png new file mode 100755 index 0000000..868e6f0 Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png new file mode 100755 index 0000000..8b140da Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png new file mode 100755 index 0000000..738b9c4 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/1st_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png new file mode 100755 index 0000000..00fa4e8 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png differ diff --git a/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png new file mode 100755 index 0000000..738b9c4 Binary files /dev/null and b/frontend/web/public/images/Cyrene_ChatBackground/Vertical/2nd_Form/2.png differ diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 63e6318..0db192b 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -64,6 +64,7 @@ export default function App() { const found = currentSessions.find((s) => s.id === hashId); if (found) { setCurrentSessionId(found.id); + setHashSessionId(found.id); await loadMessagesFromServer(found.id); return; } @@ -73,6 +74,7 @@ export default function App() { if (resp.messages && resp.messages.length > 0) { // 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的) setCurrentSessionId(hashId); + setHashSessionId(hashId); const msgs = resp.messages.map((m: any, i: number) => ({ id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`, role: m.role, diff --git a/frontend/web/src/components/chat/ChatContainer.tsx b/frontend/web/src/components/chat/ChatContainer.tsx index 2bdc4da..d92714e 100644 --- a/frontend/web/src/components/chat/ChatContainer.tsx +++ b/frontend/web/src/components/chat/ChatContainer.tsx @@ -12,7 +12,7 @@ export function ChatContainer() { const statusLabel = continuousMode ? '主对话 · 进行中' : ''; return ( -
+
{/* 状态指示器栏 */}
diff --git a/frontend/web/src/components/chat/MessageBubble.tsx b/frontend/web/src/components/chat/MessageBubble.tsx index 0e8795e..b45270a 100644 --- a/frontend/web/src/components/chat/MessageBubble.tsx +++ b/frontend/web/src/components/chat/MessageBubble.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { CyreneAvatar } from '@/components/persona/CyreneAvatar'; +import { useAuthStore } from '@/store/authStore'; interface MessageBubbleProps { role: 'user' | 'assistant' | 'system'; @@ -120,12 +121,35 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message )}
- {/* 用户头像占位 */} - {isUser && ( -
- 开拓者 -
- )} + {/* 用户头像 */} + {isUser && }
); } + +/** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */ +function UserAvatar() { + const [imgError, setImgError] = useState(false); + const userId = useAuthStore((s) => s.userId); + const isAdmin = userId?.startsWith('admin_') ?? false; + const avatarSrc = isAdmin + ? '/images/User_Avatar/Admin_Avatar.jpg' + : '/images/User_Avatar/Default_Avatar.png'; + + if (imgError) { + return ( +
+ 开拓者 +
+ ); + } + + return ( + {isAdmin setImgError(true)} + /> + ); +} diff --git a/frontend/web/src/components/persona/CyreneAvatar.tsx b/frontend/web/src/components/persona/CyreneAvatar.tsx index 9a4b94a..85c92fb 100644 --- a/frontend/web/src/components/persona/CyreneAvatar.tsx +++ b/frontend/web/src/components/persona/CyreneAvatar.tsx @@ -1,12 +1,39 @@ +import { useState } from 'react'; import { usePersonaStore } from '@/store/personaStore'; -import type { CyreneForm } from '@/types/persona'; +import type { CyreneForm, Mood } from '@/types/persona'; interface CyreneAvatarProps { size?: 'sm' | 'md' | 'lg'; className?: string; } -const FORM_AVATAR: Record = { +/** + * 根据昔涟形态和心情获取对应的头像图片路径。 + * + * 图片目录结构: + * - 1st_Form/ (空 — 尚未收集,回退到 2nd_Form) + * - 2nd_Form/: Cyrene-2F-N-Happy-1.png, Cyrene-2F-N-Tangle-1.png + * - 3rd_Form/: Cyrene-3F-Q-Happy-1.png, Cyrene-3F-Q-Happy-2.png + */ +function getAvatarPath(form: CyreneForm, _mood: Mood): string { + const base = '/images/Cyrene_Avatar'; + + // de_moi_ge 形态使用 3rd_Form,其余形态(mimi/default)使用 2nd_Form + // 注意:1st_Form 目录为空,mimi 也回退到 2nd_Form + if (form === 'de_moi_ge') { + return `${base}/3rd_Form/Cyrene-3F-Q-Happy-1.png`; + } + + // mimi / default 形态:happy 用 Happy 图,thoughtful/worried 用 Tangle 图 + if (_mood === 'thoughtful' || _mood === 'worried') { + return `${base}/2nd_Form/Cyrene-2F-N-Tangle-1.png`; + } + + return `${base}/2nd_Form/Cyrene-2F-N-Happy-1.png`; +} + +/** 图片加载失败时的回退 emoji */ +const FORM_FALLBACK_EMOJI: Record = { mimi: '🌸', default: '🌺', de_moi_ge: '🌌', @@ -18,18 +45,35 @@ const SIZE_CLASS = { lg: 'w-20 h-20 text-4xl', }; +const FORM_LABEL: Record = { + mimi: '迷迷', + default: '小昔涟', + de_moi_ge: '德谬歌', +}; + export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) { - const { currentForm } = usePersonaStore(); - const emoji = FORM_AVATAR[currentForm] || '🌸'; + const { currentForm, mood } = usePersonaStore(); + const [imgError, setImgError] = useState(false); + const avatarSrc = getAvatarPath(currentForm, mood); + const fallbackEmoji = FORM_FALLBACK_EMOJI[currentForm] || '🌸'; return (
- - {emoji} - + {imgError ? ( + + {fallbackEmoji} + + ) : ( + {`昔涟 setImgError(true)} + /> + )}
); } diff --git a/frontend/web/src/hooks/useSession.ts b/frontend/web/src/hooks/useSession.ts index 521543f..d50c802 100644 --- a/frontend/web/src/hooks/useSession.ts +++ b/frontend/web/src/hooks/useSession.ts @@ -1,9 +1,19 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useSessionStore, isAdminUser } from '@/store/sessionStore'; import { useChatStore } from '@/store/chatStore'; import { createSession as apiCreateSession } from '@/api/sessions'; import type { Session } from '@/types/session'; +const SESSION_HASH_PREFIX = 'session='; + +function setHashSessionId(sessionId: string | null) { + if (sessionId) { + window.location.hash = SESSION_HASH_PREFIX + sessionId; + } else { + history.replaceState(null, '', window.location.pathname + window.location.search); + } +} + /** 生成简易随机ID */ function randomID(n: number = 12): string { const letters = 'abcdefghijklmnopqrstuvwxyz0123456789'; @@ -73,6 +83,7 @@ export function useSession() { }; addSession(newSession); setCurrentSessionId(newSession.id); + setHashSessionId(newSession.id); return newSession; } return null; @@ -111,6 +122,7 @@ export function useSession() { const setCurrentSession = useCallback( async (id: string) => { setCurrentSessionId(id); + setHashSessionId(id); // 加载该会话的消息历史 await loadMessagesFromServer(id); }, diff --git a/frontend/web/src/hooks/useWebSocket.ts b/frontend/web/src/hooks/useWebSocket.ts index 97beffa..52b7932 100644 --- a/frontend/web/src/hooks/useWebSocket.ts +++ b/frontend/web/src/hooks/useWebSocket.ts @@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { useChatStore } from '@/store/chatStore'; import { useSessionStore } from '@/store/sessionStore'; import { getToken } from '@/api/client'; -import { fetchMessages } from '@/api/sessions'; import type { WSClientMessage, WSServerMessage } from '@/types/chat'; const WS_BASE_URL = @@ -14,7 +13,6 @@ export function useWebSocket() { const reconnectTimerRef = useRef | null>(null); const shouldReconnectRef = useRef(true); const activeSessionRef = useRef(null); - const loadingRef = useRef(false); // 防止重复加载消息 // 订阅 sessionStore 中的 currentSessionId 变化 const currentSessionId = useSessionStore((s) => s.currentSessionId); @@ -83,38 +81,11 @@ export function useWebSocket() { wsRef.current = ws; }, []); - // 会话切换时:先通过 REST API 加载历史消息,再建立 WS 连接 + // 会话切换时:重建 WebSocket 连接(消息历史由 useSession.setCurrentSession 负责加载) useEffect(() => { activeSessionRef.current = currentSessionId; - const loadAndConnect = async () => { - // 如果是从 URL 恢复的 session,先加载历史消息 - if (currentSessionId && !loadingRef.current) { - loadingRef.current = true; - try { - const resp = await fetchMessages(currentSessionId); - const rawMessages = resp.messages || []; - const msgs = rawMessages.map((m: any, i: number) => ({ - id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`, - role: m.role, - content: m.content, - timestamp: - typeof m.created_at === 'number' ? m.created_at : Date.now(), - isStreaming: false, - })); - useSessionStore.getState().setMessages(msgs); - useChatStore.getState().setMessages(msgs); - } catch { - // 加载失败不影响后续连接 - } finally { - loadingRef.current = false; - } - } - - connect(); - }; - - loadAndConnect(); + connect(); return () => { if (reconnectTimerRef.current) { diff --git a/frontend/web/src/index.css b/frontend/web/src/index.css index 8b5dd89..76080a0 100644 --- a/frontend/web/src/index.css +++ b/frontend/web/src/index.css @@ -103,6 +103,21 @@ margin-left: 2px; } +/* ===== 聊天背景 ===== */ +.chat-background { + background-image: url('/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png'); + background-size: cover; + background-position: center; + background-attachment: fixed; +} + +/* 横屏时使用 Landscape 背景 */ +@media (orientation: landscape) { + .chat-background { + background-image: url('/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png'); + } +} + /* 深色模式 */ @media (prefers-color-scheme: dark) { body { diff --git a/frontend/web/src/store/sessionStore.ts b/frontend/web/src/store/sessionStore.ts index bacb520..9b181cd 100644 --- a/frontend/web/src/store/sessionStore.ts +++ b/frontend/web/src/store/sessionStore.ts @@ -66,9 +66,10 @@ export const useSessionStore = create((set, get) => ({ messages: state.currentSessionId === id ? [] : state.messages, })), setCurrentSessionId: (id) => { + const oldId = get().currentSessionId; set({ currentSessionId: id }); // 切换会话时清空旧消息,等待加载 - if (id !== get().currentSessionId) { + if (id !== oldId) { set({ messages: [], loading: true }); useChatStore.getState().clearMessages(); } @@ -141,22 +142,31 @@ export const useSessionStore = create((set, get) => ({ if (!ok) return; const state = get(); - const remaining = state.sessions.filter((s) => s.id !== id); const wasCurrent = state.currentSessionId === id; - // 更新本地列表 - set({ sessions: remaining }); + // 从服务端重新加载会话列表,保证侧边栏同步 + await get().loadSessionsFromServer(userId); + const refreshed = get().sessions; if (wasCurrent) { - if (remaining.length > 0) { + if (refreshed.length > 0) { // 切换到列表中的第一个会话 - const nextId = remaining[0].id; + const nextId = refreshed[0].id; set({ currentSessionId: nextId }); await get().loadMessagesFromServer(nextId); } else { // 没有会话了:管理员回到主对话,普通用户创建新对话 set({ currentSessionId: null, messages: [] }); useChatStore.getState().clearMessages(); + + // 管理员:自动创建主对话 + if (isAdminUser(userId)) { + const mainSession = await get().ensureMainSession(userId); + if (mainSession) { + set({ currentSessionId: mainSession.id }); + useChatStore.getState().clearMessages(); + } + } } } }, @@ -168,8 +178,31 @@ export const useSessionStore = create((set, get) => ({ const ok = await apiDeleteAllSessions(userId); if (!ok) return; + // 从服务端重新加载会话列表 + await get().loadSessionsFromServer(userId); + const refreshed = get().sessions; + + if (refreshed.length > 0) { + // 服务端仍有会话(不应该发生,但做防御性处理) + const latest = refreshed[0]; + set({ currentSessionId: latest.id, messages: [] }); + useChatStore.getState().clearMessages(); + await get().loadMessagesFromServer(latest.id); + return; + } + + // 所有会话已删除 set({ sessions: [], currentSessionId: null, messages: [] }); useChatStore.getState().clearMessages(); + + // 管理员:自动创建主对话 + if (isAdminUser(userId)) { + const mainSession = await get().ensureMainSession(userId); + if (mainSession) { + set({ currentSessionId: mainSession.id }); + useChatStore.getState().clearMessages(); + } + } }, /**