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
}
// 主对话仅限管理员访问
if !strings.HasPrefix(userID, "admin_") {
c.JSON(http.StatusForbidden, gin.H{
"error": "主对话仅限管理员使用",
"errorType": "admin_only",
"hint": "请使用管理员账号 (admin_ 前缀) 登录以访问主对话功能",
})
return
}
if sessionID == "" {
sessionID = "session_" + generateID()
}
+93 -26
View File
@@ -368,11 +368,11 @@ const STATE = {
serviceStatus: {},
// 日志
activeLogTab: 'ai-core',
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [] },
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [] },
maxLogLines: 500,
logLayout: 'tabs',
// 性能
perfHistory: { 'ai-core': [], 'gateway': [], 'frontend': [] },
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [] },
// 会话
sessionsData: [],
sessionsAutoRefresh: null,
@@ -468,7 +468,7 @@ function statusBadge(status) {
}
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;
}
@@ -477,13 +477,23 @@ async function api(url, opts = {}) {
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!res.ok) {
const text = await res.text();
let err;
try { err = JSON.parse(text).error || text; } catch { err = text; }
return { error: err, status: res.status };
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = null; }
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) {
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>
</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>
<!-- 性能快照 + 性能仪表盘 -->
@@ -826,13 +836,19 @@ function renderMemoryPanel() {
<div class="card">
<div class="card-header">
<span class="card-title">📋 记忆列表</span>
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></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>
</div>
<div class="table-wrap">
<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">
<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>
</table>
</div>
@@ -863,8 +879,17 @@ function renderMemoryResults(data) {
const countEl = document.getElementById('mem-result-count');
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 = '';
STATE.memoryCache = [];
return;
}
@@ -874,23 +899,47 @@ function renderMemoryResults(data) {
else if (data.memories) memories = data.memories;
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}`;
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;
}
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>
<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>${m.priority ?? 1}</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><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td>
</tr>
`).join('');
`}).join('');
}
async function addMemory() {
@@ -981,7 +1030,16 @@ async function fetchActiveSessions() {
if (!container) return;
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;
}
@@ -1048,7 +1106,15 @@ async function toggleSessionDetail(index) {
const data = await api(`/api/sessions/${session.session_id}`);
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;
}
@@ -1105,7 +1171,7 @@ function renderServicesPanel() {
<!-- 服务状态卡片 -->
<div class="card">
<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>
<!-- 实时日志 -->
@@ -1124,9 +1190,10 @@ function renderServicesPanel() {
</div>
</div>
<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-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>
</div>
@@ -1142,7 +1209,7 @@ function renderServiceCards() {
const container = document.getElementById('services-svc-cards');
if (!container) return;
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 => {
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
@@ -1176,7 +1243,7 @@ function renderServiceCards() {
function initSvcLogTabs() {
const tabs = document.getElementById('services-log-tabs');
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>`
).join('');
}
@@ -1227,7 +1294,7 @@ function toggleSvcLogLayout() {
gridPanel.style.display = '';
logTabs.style.display = 'none';
btn.textContent = '📋 标签页';
['ai-core', 'gateway', 'frontend'].forEach(id => renderGridLog(id));
['ai-core', 'gateway', 'iot-debug-service', 'frontend'].forEach(id => renderGridLog(id));
} else {
STATE.logLayout = 'tabs';
tabsPanel.style.display = '';
@@ -1289,7 +1356,7 @@ function renderPerformancePanel() {
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
</div>
</div>
<div class="cards-grid cards-3" id="perf-panels"></div>
<div class="cards-grid cards-4" id="perf-panels"></div>
</div>
`;
refreshPerf();
@@ -1313,7 +1380,7 @@ async function refreshPerf() {
function renderPerfPanels(snap) {
const container = document.getElementById('perf-panels');
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 => {
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };