|
@@ -6,6 +6,7 @@
|
|
|
const state = {
|
|
const state = {
|
|
|
config: null,
|
|
config: null,
|
|
|
sessionId: null,
|
|
sessionId: null,
|
|
|
|
|
+ sessionNumber: null,
|
|
|
messages: [],
|
|
messages: [],
|
|
|
expandedMessages: new Set(),
|
|
expandedMessages: new Set(),
|
|
|
historyPage: 0,
|
|
historyPage: 0,
|
|
@@ -23,6 +24,8 @@
|
|
|
myExports: [],
|
|
myExports: [],
|
|
|
adminUsers: [],
|
|
adminUsers: [],
|
|
|
adminExports: [],
|
|
adminExports: [],
|
|
|
|
|
+ activeAbortController: null,
|
|
|
|
|
+ userMenuOpen: false,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const dom = {};
|
|
const dom = {};
|
|
@@ -87,6 +90,13 @@
|
|
|
dom.sendButton = document.getElementById('send-btn');
|
|
dom.sendButton = document.getElementById('send-btn');
|
|
|
dom.fileInput = document.getElementById('file-input');
|
|
dom.fileInput = document.getElementById('file-input');
|
|
|
dom.chatStatus = document.getElementById('chat-status');
|
|
dom.chatStatus = document.getElementById('chat-status');
|
|
|
|
|
+ dom.sessionIndicator = document.getElementById('session-indicator');
|
|
|
|
|
+ dom.userMenu = document.getElementById('user-menu');
|
|
|
|
|
+ dom.userMenuToggle = document.getElementById('user-menu-toggle');
|
|
|
|
|
+ dom.userMenuDropdown = document.getElementById('user-menu-dropdown');
|
|
|
|
|
+ dom.userAvatarInitials = document.getElementById('user-avatar-initials');
|
|
|
|
|
+ dom.stopButton = document.getElementById('end-chat-btn');
|
|
|
|
|
+ dom.adminRoleIndicator = document.getElementById('admin-role-indicator');
|
|
|
dom.toast = document.getElementById('toast');
|
|
dom.toast = document.getElementById('toast');
|
|
|
|
|
|
|
|
if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
|
|
if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
|
|
@@ -114,6 +124,7 @@
|
|
|
}
|
|
}
|
|
|
if (dom.adminButton) {
|
|
if (dom.adminButton) {
|
|
|
dom.adminButton.addEventListener('click', () => {
|
|
dom.adminButton.addEventListener('click', () => {
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
void openAdminPanel();
|
|
void openAdminPanel();
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
@@ -139,12 +150,15 @@
|
|
|
}
|
|
}
|
|
|
if (dom.exportButton) {
|
|
if (dom.exportButton) {
|
|
|
dom.exportButton.addEventListener('click', () => {
|
|
dom.exportButton.addEventListener('click', () => {
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
void openExportPanel();
|
|
void openExportPanel();
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
if (dom.exportClose) {
|
|
if (dom.exportClose) {
|
|
|
dom.exportClose.addEventListener('click', hideExportPanel);
|
|
dom.exportClose.addEventListener('click', hideExportPanel);
|
|
|
}
|
|
}
|
|
|
|
|
+ setupOverlayDismiss(dom.adminPanel, hideAdminPanel);
|
|
|
|
|
+ setupOverlayDismiss(dom.exportPanel, hideExportPanel);
|
|
|
dom.modelSelect.addEventListener('change', () => {
|
|
dom.modelSelect.addEventListener('change', () => {
|
|
|
state.model = dom.modelSelect.value;
|
|
state.model = dom.modelSelect.value;
|
|
|
});
|
|
});
|
|
@@ -190,7 +204,7 @@
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
const data = await fetchJSON('/api/session/new', { method: 'POST' });
|
|
const data = await fetchJSON('/api/session/new', { method: 'POST' });
|
|
|
- state.sessionId = data.session_id;
|
|
|
|
|
|
|
+ updateActiveSession(data.session_id, data.session_number, { updateUrl: true });
|
|
|
state.messages = [];
|
|
state.messages = [];
|
|
|
state.historyCount = 0;
|
|
state.historyCount = 0;
|
|
|
state.searchQuery = '';
|
|
state.searchQuery = '';
|
|
@@ -200,7 +214,6 @@
|
|
|
renderSidebar();
|
|
renderSidebar();
|
|
|
renderMessages();
|
|
renderMessages();
|
|
|
renderHistory();
|
|
renderHistory();
|
|
|
- updateSessionInUrl(state.sessionId, { replace: false });
|
|
|
|
|
showToast('当前会话已清空。', 'success');
|
|
showToast('当前会话已清空。', 'success');
|
|
|
await loadHistory();
|
|
await loadHistory();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
@@ -222,10 +235,48 @@
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
window.addEventListener('popstate', handlePopState);
|
|
window.addEventListener('popstate', handlePopState);
|
|
|
|
|
+
|
|
|
|
|
+ if (dom.stopButton) {
|
|
|
|
|
+ dom.stopButton.addEventListener('click', handleAbortConversation);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.userMenuToggle) {
|
|
|
|
|
+ dom.userMenuToggle.addEventListener('click', (event) => {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ setUserMenuOpen(!state.userMenuOpen);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ document.addEventListener('click', (event) => {
|
|
|
|
|
+ if (!state.userMenuOpen || !dom.userMenu) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (event.target instanceof Node && dom.userMenu.contains(event.target)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
|
|
+ });
|
|
|
|
|
+ document.addEventListener('keydown', (event) => {
|
|
|
|
|
+ if (event.key === 'Escape') {
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
|
|
+ hideAdminPanel();
|
|
|
|
|
+ hideExportPanel();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setupOverlayDismiss(overlay, closeHandler) {
|
|
|
|
|
+ if (!overlay || typeof closeHandler !== 'function') {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ overlay.addEventListener('click', (event) => {
|
|
|
|
|
+ if (event.target === overlay) {
|
|
|
|
|
+ closeHandler();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function resetChatState() {
|
|
function resetChatState() {
|
|
|
state.sessionId = null;
|
|
state.sessionId = null;
|
|
|
|
|
+ state.sessionNumber = null;
|
|
|
state.messages = [];
|
|
state.messages = [];
|
|
|
state.expandedMessages = new Set();
|
|
state.expandedMessages = new Set();
|
|
|
state.historyItems = [];
|
|
state.historyItems = [];
|
|
@@ -235,12 +286,15 @@
|
|
|
state.myExports = [];
|
|
state.myExports = [];
|
|
|
state.adminUsers = [];
|
|
state.adminUsers = [];
|
|
|
state.adminExports = [];
|
|
state.adminExports = [];
|
|
|
|
|
+ state.activeAbortController = null;
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
renderSidebar();
|
|
renderSidebar();
|
|
|
renderMessages();
|
|
renderMessages();
|
|
|
renderHistory();
|
|
renderHistory();
|
|
|
renderMyExports();
|
|
renderMyExports();
|
|
|
renderAdminUsers();
|
|
renderAdminUsers();
|
|
|
renderAdminExports();
|
|
renderAdminExports();
|
|
|
|
|
+ updateSessionIndicator();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function showAuthView(mode = 'login') {
|
|
function showAuthView(mode = 'login') {
|
|
@@ -360,6 +414,23 @@
|
|
|
if (dom.logoutButton) {
|
|
if (dom.logoutButton) {
|
|
|
dom.logoutButton.disabled = !state.token;
|
|
dom.logoutButton.disabled = !state.token;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (dom.userMenu) {
|
|
|
|
|
+ dom.userMenu.classList.toggle('hidden', !state.token);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.userMenuToggle) {
|
|
|
|
|
+ dom.userMenuToggle.disabled = !state.token;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!state.token) {
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.userAvatarInitials) {
|
|
|
|
|
+ const initials = state.user ? extractInitials(state.user.username) : '';
|
|
|
|
|
+ dom.userAvatarInitials.textContent = initials;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.adminRoleIndicator) {
|
|
|
|
|
+ dom.adminRoleIndicator.classList.toggle('hidden', !isAdmin());
|
|
|
|
|
+ }
|
|
|
|
|
+ updateSessionIndicator();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function isAdmin() {
|
|
function isAdmin() {
|
|
@@ -431,6 +502,7 @@
|
|
|
|
|
|
|
|
async function handleLogout(event) {
|
|
async function handleLogout(event) {
|
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
|
|
|
+ setUserMenuOpen(false);
|
|
|
if (!state.token) {
|
|
if (!state.token) {
|
|
|
showAuthView('login');
|
|
showAuthView('login');
|
|
|
return;
|
|
return;
|
|
@@ -445,6 +517,8 @@
|
|
|
resetChatState();
|
|
resetChatState();
|
|
|
showAuthView('login');
|
|
showAuthView('login');
|
|
|
updateUserUi();
|
|
updateUserUi();
|
|
|
|
|
+ hideAdminPanel();
|
|
|
|
|
+ hideExportPanel();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -805,7 +879,7 @@
|
|
|
}
|
|
}
|
|
|
const { updateUrl = true, replaceUrl = false } = options;
|
|
const { updateUrl = true, replaceUrl = false } = options;
|
|
|
const data = await fetchJSON('/api/session/latest');
|
|
const data = await fetchJSON('/api/session/latest');
|
|
|
- state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
|
|
|
|
|
|
|
+ updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
|
|
|
state.messages = Array.isArray(data.messages) ? data.messages : [];
|
|
state.messages = Array.isArray(data.messages) ? data.messages : [];
|
|
|
state.expandedMessages = new Set();
|
|
state.expandedMessages = new Set();
|
|
|
state.historyCount = Math.min(state.historyCount, state.messages.length);
|
|
state.historyCount = Math.min(state.historyCount, state.messages.length);
|
|
@@ -815,10 +889,6 @@
|
|
|
renderSidebar();
|
|
renderSidebar();
|
|
|
renderMessages();
|
|
renderMessages();
|
|
|
renderHistory();
|
|
renderHistory();
|
|
|
-
|
|
|
|
|
- if (updateUrl) {
|
|
|
|
|
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadSession(sessionId, options = {}) {
|
|
async function loadSession(sessionId, options = {}) {
|
|
@@ -836,7 +906,7 @@
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
const data = await fetchJSON(`/api/session/${sessionId}`);
|
|
const data = await fetchJSON(`/api/session/${sessionId}`);
|
|
|
- state.sessionId = data.session_id;
|
|
|
|
|
|
|
+ updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
|
|
|
state.messages = Array.isArray(data.messages) ? data.messages : [];
|
|
state.messages = Array.isArray(data.messages) ? data.messages : [];
|
|
|
state.historyCount = Math.min(state.historyCount, state.messages.length);
|
|
state.historyCount = Math.min(state.historyCount, state.messages.length);
|
|
|
state.expandedMessages = new Set();
|
|
state.expandedMessages = new Set();
|
|
@@ -846,10 +916,6 @@
|
|
|
renderMessages();
|
|
renderMessages();
|
|
|
renderHistory();
|
|
renderHistory();
|
|
|
|
|
|
|
|
- if (updateUrl) {
|
|
|
|
|
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
return true;
|
|
return true;
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
if (!silent) {
|
|
if (!silent) {
|
|
@@ -939,9 +1005,37 @@
|
|
|
if (dom.newChatButton) {
|
|
if (dom.newChatButton) {
|
|
|
dom.newChatButton.disabled = active;
|
|
dom.newChatButton.disabled = active;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (dom.stopButton) {
|
|
|
|
|
+ dom.stopButton.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
|
|
|
+ dom.stopButton.title = '提前结束此次对话';
|
|
|
|
|
+ }
|
|
|
if (active) {
|
|
if (active) {
|
|
|
setStatus('正在生成回复…', 'running');
|
|
setStatus('正在生成回复…', 'running');
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!active) {
|
|
|
|
|
+ if (dom.stopButton) {
|
|
|
|
|
+ dom.stopButton.classList.add('hidden');
|
|
|
|
|
+ dom.stopButton.disabled = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ state.activeAbortController = null;
|
|
|
|
|
+ } else if (dom.stopButton) {
|
|
|
|
|
+ dom.stopButton.classList.remove('hidden');
|
|
|
|
|
+ dom.stopButton.disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleAbortConversation() {
|
|
|
|
|
+ if (!state.streaming || !state.activeAbortController) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ state.activeAbortController.abort();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('Abort failed', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.stopButton) {
|
|
|
|
|
+ dom.stopButton.disabled = true;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderHistory() {
|
|
function renderHistory() {
|
|
@@ -983,7 +1077,12 @@
|
|
|
const loadLink = document.createElement('a');
|
|
const loadLink = document.createElement('a');
|
|
|
loadLink.className = 'history-title-link';
|
|
loadLink.className = 'history-title-link';
|
|
|
loadLink.href = buildSessionUrl(item.session_id);
|
|
loadLink.href = buildSessionUrl(item.session_id);
|
|
|
- const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`;
|
|
|
|
|
|
|
+ const sessionNumber = Number.isFinite(Number(item.session_number))
|
|
|
|
|
+ ? Number(item.session_number)
|
|
|
|
|
+ : item.session_id;
|
|
|
|
|
+ const displayTitle = (item.title && item.title.trim())
|
|
|
|
|
+ ? item.title.trim()
|
|
|
|
|
+ : `会话 #${sessionNumber}`;
|
|
|
const primary = document.createElement('span');
|
|
const primary = document.createElement('span');
|
|
|
primary.className = 'history-title-text';
|
|
primary.className = 'history-title-text';
|
|
|
primary.textContent = displayTitle;
|
|
primary.textContent = displayTitle;
|
|
@@ -991,7 +1090,7 @@
|
|
|
|
|
|
|
|
const subtitle = document.createElement('span');
|
|
const subtitle = document.createElement('span');
|
|
|
subtitle.className = 'history-subtitle';
|
|
subtitle.className = 'history-subtitle';
|
|
|
- subtitle.textContent = item.filename ? item.filename : `会话 #${item.session_id}`;
|
|
|
|
|
|
|
+ subtitle.textContent = `会话 #${sessionNumber}`;
|
|
|
loadLink.appendChild(subtitle);
|
|
loadLink.appendChild(subtitle);
|
|
|
loadLink.title = `会话 #${item.session_id} · 点击加载`;
|
|
loadLink.title = `会话 #${item.session_id} · 点击加载`;
|
|
|
|
|
|
|
@@ -1017,12 +1116,16 @@
|
|
|
moveButton.addEventListener('click', async (event) => {
|
|
moveButton.addEventListener('click', async (event) => {
|
|
|
event.stopPropagation();
|
|
event.stopPropagation();
|
|
|
try {
|
|
try {
|
|
|
|
|
+ const isActive = item.session_id === state.sessionId;
|
|
|
await fetchJSON('/api/history/move', {
|
|
await fetchJSON('/api/history/move', {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
body: { session_id: item.session_id },
|
|
body: { session_id: item.session_id },
|
|
|
});
|
|
});
|
|
|
showToast('已移动到备份。', 'success');
|
|
showToast('已移动到备份。', 'success');
|
|
|
await loadHistory();
|
|
await loadHistory();
|
|
|
|
|
+ if (isActive) {
|
|
|
|
|
+ await loadLatestSession({ updateUrl: true, replaceUrl: true });
|
|
|
|
|
+ }
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
showToast(err.message || '移动失败', 'error');
|
|
showToast(err.message || '移动失败', 'error');
|
|
|
}
|
|
}
|
|
@@ -1540,26 +1643,38 @@
|
|
|
stream: state.outputMode === '流式输出 (Stream)',
|
|
stream: state.outputMode === '流式输出 (Stream)',
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ const controller = new AbortController();
|
|
|
|
|
+ state.activeAbortController = controller;
|
|
|
setStreaming(true);
|
|
setStreaming(true);
|
|
|
try {
|
|
try {
|
|
|
if (payload.stream) {
|
|
if (payload.stream) {
|
|
|
- await streamAssistantReply(payload, assistantMessage, assistantIndex);
|
|
|
|
|
|
|
+ await streamAssistantReply(payload, assistantMessage, assistantIndex, controller);
|
|
|
} else {
|
|
} else {
|
|
|
const data = await fetchJSON('/api/chat', {
|
|
const data = await fetchJSON('/api/chat', {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
body: payload,
|
|
body: payload,
|
|
|
|
|
+ signal: controller.signal,
|
|
|
});
|
|
});
|
|
|
|
|
+ if (Number.isFinite(Number(data.session_id))) {
|
|
|
|
|
+ updateActiveSession(data.session_id, data.session_number);
|
|
|
|
|
+ }
|
|
|
assistantMessage.content = data.message || '';
|
|
assistantMessage.content = data.message || '';
|
|
|
updateMessageContent(assistantIndex, assistantMessage.content);
|
|
updateMessageContent(assistantIndex, assistantMessage.content);
|
|
|
showToast('已生成回复', 'success');
|
|
showToast('已生成回复', 'success');
|
|
|
setStatus('');
|
|
setStatus('');
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- state.messages.splice(assistantIndex, 1);
|
|
|
|
|
- renderMessages();
|
|
|
|
|
- const message = err.message || '发送失败';
|
|
|
|
|
- setStatus(message, 'error');
|
|
|
|
|
- showToast(message, 'error');
|
|
|
|
|
|
|
+ const aborted = err && (err.name === 'AbortError');
|
|
|
|
|
+ if (!aborted) {
|
|
|
|
|
+ state.messages.splice(assistantIndex, 1);
|
|
|
|
|
+ renderMessages();
|
|
|
|
|
+ const message = err.message || '发送失败';
|
|
|
|
|
+ setStatus(message, 'error');
|
|
|
|
|
+ showToast(message, 'error');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setStatus('对话已提前结束', 'error');
|
|
|
|
|
+ showToast('当前对话已中止', 'error');
|
|
|
|
|
+ }
|
|
|
} finally {
|
|
} finally {
|
|
|
try {
|
|
try {
|
|
|
state.historyPage = 0;
|
|
state.historyPage = 0;
|
|
@@ -1634,11 +1749,12 @@
|
|
|
return { content: combined.trim() };
|
|
return { content: combined.trim() };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
|
|
|
|
|
|
|
+ async function streamAssistantReply(payload, assistantMessage, assistantIndex, controller) {
|
|
|
const response = await fetch('/api/chat', {
|
|
const response = await fetch('/api/chat', {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
|
body: JSON.stringify(payload),
|
|
body: JSON.stringify(payload),
|
|
|
|
|
+ signal: controller ? controller.signal : undefined,
|
|
|
});
|
|
});
|
|
|
if (response.status === 401) {
|
|
if (response.status === 401) {
|
|
|
handleUnauthorized();
|
|
handleUnauthorized();
|
|
@@ -1685,6 +1801,10 @@
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (payload.type === 'meta') {
|
|
|
|
|
+ updateActiveSession(payload.session_id, payload.session_number);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
if (payload.type === 'delta') {
|
|
if (payload.type === 'delta') {
|
|
|
if (typeof assistantMessage.content !== 'string') {
|
|
if (typeof assistantMessage.content !== 'string') {
|
|
|
assistantMessage.content = '';
|
|
assistantMessage.content = '';
|
|
@@ -1847,4 +1967,59 @@
|
|
|
toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
|
|
toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
|
|
|
}, 2500);
|
|
}, 2500);
|
|
|
}
|
|
}
|
|
|
|
|
+ function extractInitials(name = '') {
|
|
|
|
|
+ if (!name) {
|
|
|
|
|
+ return '?';
|
|
|
|
|
+ }
|
|
|
|
|
+ const trimmed = name.trim();
|
|
|
|
|
+ if (!trimmed) {
|
|
|
|
|
+ return '?';
|
|
|
|
|
+ }
|
|
|
|
|
+ return trimmed.slice(0, 2).toUpperCase();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setUserMenuOpen(open) {
|
|
|
|
|
+ const nextState = Boolean(open && state.token);
|
|
|
|
|
+ state.userMenuOpen = nextState;
|
|
|
|
|
+ if (dom.userMenuDropdown) {
|
|
|
|
|
+ dom.userMenuDropdown.classList.toggle('hidden', !nextState);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.userMenu) {
|
|
|
|
|
+ dom.userMenu.classList.toggle('open', nextState);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dom.userMenuToggle) {
|
|
|
|
|
+ dom.userMenuToggle.setAttribute('aria-expanded', nextState ? 'true' : 'false');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateSessionIndicator() {
|
|
|
|
|
+ if (!dom.sessionIndicator) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!state.token || state.sessionId === null) {
|
|
|
|
|
+ dom.sessionIndicator.textContent = '';
|
|
|
|
|
+ dom.sessionIndicator.classList.add('hidden');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const displayNumber = Number.isInteger(state.sessionNumber) ? state.sessionNumber : state.sessionId;
|
|
|
|
|
+ dom.sessionIndicator.textContent = `会话 #${displayNumber}`;
|
|
|
|
|
+ dom.sessionIndicator.classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateActiveSession(sessionId, sessionNumber, options = {}) {
|
|
|
|
|
+ const parsedId = Number(sessionId);
|
|
|
|
|
+ const parsedNumber = Number(sessionNumber);
|
|
|
|
|
+ const nextId = Number.isFinite(parsedId) ? parsedId : null;
|
|
|
|
|
+ const previousId = state.sessionId;
|
|
|
|
|
+ state.sessionId = nextId;
|
|
|
|
|
+ state.sessionNumber = Number.isFinite(parsedNumber) ? parsedNumber : null;
|
|
|
|
|
+ updateSessionIndicator();
|
|
|
|
|
+ if (options.updateUrl === false) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (previousId === state.sessionId && !options.replace) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ updateSessionInUrl(state.sessionId, { replace: Boolean(options.replace) });
|
|
|
|
|
+ }
|
|
|
})();
|
|
})();
|