feat: 主对话仅限管理员访问 + 记忆面板按时间排序与话题关联

- chat_handler.go: HandleWebSocket 新增 admin_ 前缀检查,非管理员返回 403
- index.html: 记忆面板新增时间排序下拉框和话题(会话)列
- renderMemoryResults 拆分为缓存+排序渲染两阶段
- 修复: 修复 typo 'seession_id' -> 'session_id'
This commit is contained in:
2026-05-16 22:13:30 +08:00
parent 4af9414646
commit 937742df02
2 changed files with 103 additions and 26 deletions
@@ -66,6 +66,16 @@ func (h *ChatHandler) HandleWebSocket(c *gin.Context) {
return return
} }
// 主对话仅限管理员访问
if !strings.HasPrefix(userID, "admin_") {
c.JSON(http.StatusForbidden, gin.H{
"error": "主对话仅限管理员使用",
"errorType": "admin_only",
"hint": "请使用管理员账号 (admin_ 前缀) 登录以访问主对话功能",
})
return
}
if sessionID == "" { if sessionID == "" {
sessionID = "session_" + generateID() sessionID = "session_" + generateID()
} }
+92 -25
View File
@@ -368,11 +368,11 @@ const STATE = {
serviceStatus: {}, serviceStatus: {},
// 日志 // 日志
activeLogTab: 'ai-core', activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [] }, logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [] },
maxLogLines: 500, maxLogLines: 500,
logLayout: 'tabs', logLayout: 'tabs',
// 性能 // 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'frontend': [] }, perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
// 会话 // 会话
sessionsData: [], sessionsData: [],
sessionsAutoRefresh: null, sessionsAutoRefresh: null,
@@ -468,7 +468,7 @@ function statusBadge(status) {
} }
function escapeId(id) { function escapeId(id) {
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend' }; const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug' };
return map[id] || id; return map[id] || id;
} }
@@ -477,13 +477,23 @@ async function api(url, opts = {}) {
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts }); const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
let err; let parsed;
try { err = JSON.parse(text).error || text; } catch { err = text; } try { parsed = JSON.parse(text); } catch { parsed = null; }
return { error: err, status: res.status }; return {
error: parsed?.error || text,
errorType: parsed?.errorType || null,
hint: parsed?.hint || null,
status: res.status,
};
} }
return await res.json(); const body = await res.json();
// 即使 HTTP 200body 中也可能包含 error 字段(如 Gateway 代理失败返回的 502
if (body && body.error) {
return { ...body, status: res.status };
}
return body;
} catch (err) { } catch (err) {
return { error: err.message }; return { error: err.message, status: 0 };
} }
} }
@@ -606,7 +616,7 @@ async function renderDashboard() {
<button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button> <button class="btn btn-sm btn-red" onclick="svcAction('stop-all')">⏹ 全部停止</button>
</div> </div>
</div> </div>
<div class="cards-grid cards-3" id="dashboard-svc-cards"></div> <div class="cards-grid cards-4" id="dashboard-svc-cards"></div>
</div> </div>
<!-- 性能快照 + 性能仪表盘 --> <!-- 性能快照 + 性能仪表盘 -->
@@ -826,13 +836,19 @@ function renderMemoryPanel() {
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title">📋 记忆列表</span> <span class="card-title">📋 记忆列表</span>
<span style="display:flex;align-items:center;gap:8px">
<select id="mem-sort-order" onchange="sortAndRenderMemories()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px">
<option value="desc">🕐 最新优先</option>
<option value="asc">🕐 最早优先</option>
</select>
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span> <span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span>
</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead> <thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>话题 (会话)</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead>
<tbody id="mem-table-body"> <tbody id="mem-table-body">
<tr><td colspan="6"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr> <tr><td colspan="7"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -863,8 +879,17 @@ function renderMemoryResults(data) {
const countEl = document.getElementById('mem-result-count'); const countEl = document.getElementById('mem-result-count');
if (data.error) { if (data.error) {
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div></td></tr>`; let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
}
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div></td></tr>`;
countEl.textContent = ''; countEl.textContent = '';
STATE.memoryCache = [];
return; return;
} }
@@ -874,23 +899,47 @@ function renderMemoryResults(data) {
else if (data.memories) memories = data.memories; else if (data.memories) memories = data.memories;
else if (data.results) memories = data.results; else if (data.results) memories = data.results;
// 缓存记忆数据用于排序
STATE.memoryCache = memories;
sortAndRenderMemories();
}
function sortAndRenderMemories() {
const tbody = document.getElementById('mem-table-body');
const countEl = document.getElementById('mem-result-count');
const sortOrder = document.getElementById('mem-sort-order')?.value || 'desc';
let memories = [...(STATE.memoryCache || [])];
// 按创建时间排序
memories.sort((a, b) => {
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return sortOrder === 'asc' ? ta - tb : tb - ta;
});
countEl.textContent = `${memories.length}`; countEl.textContent = `${memories.length}`;
if (memories.length === 0) { if (memories.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>'; tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
return; return;
} }
tbody.innerHTML = memories.map(m => ` tbody.innerHTML = memories.map(m => {
// 会话ID 简短显示
const sid = m.session_id || '—';
const sidShort = sid.length > 16 ? sid.substring(0, 14) + '…' : sid;
return `
<tr> <tr>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(m.content || '')}">${escHtml((m.content || '').substring(0, 80))}</td> <td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(m.content || '')}">${escHtml((m.content || '').substring(0, 80))}</td>
<td><span class="badge badge-idle">${escHtml(m.category || 'other')}</span></td> <td><span class="badge badge-idle">${escHtml(m.category || 'other')}</span></td>
<td>${m.priority ?? 1}</td> <td>${m.priority ?? 1}</td>
<td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td> <td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td>
<td style="color:var(--text2);font-size:11px" title="${escHtml(sid)}">${escHtml(sidShort)}</td>
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td> <td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
<td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td> <td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td>
</tr> </tr>
`).join(''); `}).join('');
} }
async function addMemory() { async function addMemory() {
@@ -981,7 +1030,16 @@ async function fetchActiveSessions() {
if (!container) return; if (!container) return;
if (data.error) { if (data.error) {
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}<br><span style="font-size:11px">请确认 Gateway 服务已启动</span></div>`; const errMsg = escHtml(data.error);
let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 服务已启动</span>';
}
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${errMsg}${hint}</div>`;
return; return;
} }
@@ -1048,7 +1106,15 @@ async function toggleSessionDetail(index) {
const data = await api(`/api/sessions/${session.session_id}`); const data = await api(`/api/sessions/${session.session_id}`);
if (data.error) { if (data.error) {
contentEl.innerHTML = `<div style="color:var(--red);text-align:center">${escHtml(data.error)}</div>`; let hint = '';
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
} else if (data.errorType === 'gateway_unreachable') {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
} else if (data.status === 502) {
hint = '<br><span style="font-size:11px;color:var(--text2)">💡 提示: 请确认 Gateway 服务已启动</span>';
}
contentEl.innerHTML = `<div style="color:var(--red);text-align:center">${escHtml(data.error)}${hint}</div>`;
return; return;
} }
@@ -1105,7 +1171,7 @@ function renderServicesPanel() {
<!-- 服务状态卡片 --> <!-- 服务状态卡片 -->
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">📡 服务管理</span></div> <div class="card-header"><span class="card-title">📡 服务管理</span></div>
<div class="cards-grid cards-3" id="services-svc-cards"></div> <div class="cards-grid cards-4" id="services-svc-cards"></div>
</div> </div>
<!-- 实时日志 --> <!-- 实时日志 -->
@@ -1124,9 +1190,10 @@ function renderServicesPanel() {
</div> </div>
</div> </div>
<div id="services-log-grid" style="display:none"> <div id="services-log-grid" style="display:none">
<div class="cards-grid cards-3"> <div class="cards-grid cards-4">
<div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div> <div class="log-container" id="log-panel-ai-core" style="height:280px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
<div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div> <div class="log-container" id="log-panel-gateway" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
<div class="log-container" id="log-panel-iot-debug-service" style="height:280px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div>
<div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div> <div class="log-container" id="log-panel-frontend" style="height:280px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
</div> </div>
</div> </div>
@@ -1142,7 +1209,7 @@ function renderServiceCards() {
const container = document.getElementById('services-svc-cards'); const container = document.getElementById('services-svc-cards');
if (!container) return; if (!container) return;
const status = STATE.serviceStatus; const status = STATE.serviceStatus;
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ['ai-core', 'gateway', 'frontend']; const ids = Object.keys(status).length > 0 ? Object.keys(status) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
container.innerHTML = ids.map(id => { container.innerHTML = ids.map(id => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null }; const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
@@ -1176,7 +1243,7 @@ function renderServiceCards() {
function initSvcLogTabs() { function initSvcLogTabs() {
const tabs = document.getElementById('services-log-tabs'); const tabs = document.getElementById('services-log-tabs');
if (!tabs) return; if (!tabs) return;
tabs.innerHTML = ['ai-core', 'gateway', 'frontend'].map(id => tabs.innerHTML = ['ai-core', 'gateway', 'iot-debug-service', 'frontend'].map(id =>
`<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>` `<button class="log-tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchSvcLogTab('${id}')">${escapeId(id)}</button>`
).join(''); ).join('');
} }
@@ -1227,7 +1294,7 @@ function toggleSvcLogLayout() {
gridPanel.style.display = ''; gridPanel.style.display = '';
logTabs.style.display = 'none'; logTabs.style.display = 'none';
btn.textContent = '📋 标签页'; btn.textContent = '📋 标签页';
['ai-core', 'gateway', 'frontend'].forEach(id => renderGridLog(id)); ['ai-core', 'gateway', 'iot-debug-service', 'frontend'].forEach(id => renderGridLog(id));
} else { } else {
STATE.logLayout = 'tabs'; STATE.logLayout = 'tabs';
tabsPanel.style.display = ''; tabsPanel.style.display = '';
@@ -1289,7 +1356,7 @@ function renderPerformancePanel() {
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span> <span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
</div> </div>
</div> </div>
<div class="cards-grid cards-3" id="perf-panels"></div> <div class="cards-grid cards-4" id="perf-panels"></div>
</div> </div>
`; `;
refreshPerf(); refreshPerf();
@@ -1313,7 +1380,7 @@ async function refreshPerf() {
function renderPerfPanels(snap) { function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels'); const container = document.getElementById('perf-panels');
if (!container) return; if (!container) return;
const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'frontend']; const ids = Object.keys(snap).length > 0 ? Object.keys(snap) : ['ai-core', 'gateway', 'iot-debug-service', 'frontend'];
container.innerHTML = ids.map(id => { container.innerHTML = ids.map(id => {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 }; const s = snap[id] || { pid: null, cpu: 0, mem: 0 };