@@ -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 `
-
-
-
-
-
-
`;
- }).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 '
' +
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '
';
+ }).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 = `
`;
+ panel.innerHTML = '
⚠️
' + escHtml(data.error) + '
';
+ STATE.dbInitialized = false;
return;
}
@@ -1614,71 +1787,132 @@ async function renderDatabasePanel() {
}
}
- panel.innerHTML = `
-
-
-
-
-
-
${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 =
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' + 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 (
+

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();
+ }
+ }
},
/**