mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
Merge pull request #2766 from zhayujie/feat-mulit-session
feat(web): add multi-session management for web console
This commit is contained in:
@@ -17,6 +17,45 @@
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
/* Generic Tooltip (via data-tooltip attribute) */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
[data-tooltip-pos="bottom"]::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
.dark [data-tooltip]::after {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
[data-tooltip=""]:hover::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar-item.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -24,9 +63,300 @@
|
||||
}
|
||||
.sidebar-item.active .item-icon { color: #4ABE6E; }
|
||||
|
||||
/* Session Panel */
|
||||
.session-panel {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.dark .session-panel {
|
||||
background: #111111;
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.session-panel.hidden { display: none; }
|
||||
.session-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dark .session-panel-header { border-bottom-color: rgba(255, 255, 255, 0.08); }
|
||||
.session-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.dark .session-panel-title { color: #d1d5db; }
|
||||
.session-panel-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-panel-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.dark .session-panel-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.session-panel-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 12px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d1d5db;
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.session-panel-new:hover {
|
||||
border-color: #9ca3af;
|
||||
color: #374151;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.dark .session-panel-new {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .session-panel-new:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Session List */
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.session-list:hover { scrollbar-width: thin; }
|
||||
.session-list::-webkit-scrollbar { width: 4px; background: transparent; }
|
||||
.session-list::-webkit-scrollbar-thumb { background: transparent; border-radius: 2px; }
|
||||
.session-list:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
|
||||
.dark .session-list:hover::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
|
||||
.session-group-label {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .session-group-label { color: #525252; }
|
||||
.session-empty {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
}
|
||||
.dark .session-item { color: #a3a3a3; }
|
||||
.session-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
.dark .session-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.session-item.active {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
.dark .session-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
.session-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.dark .session-icon { color: #525252; }
|
||||
.session-item.active .session-icon { color: #4ABE6E; }
|
||||
.session-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-delete {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.session-item:hover .session-delete { opacity: 1; }
|
||||
.session-delete:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
||||
|
||||
/* Context Divider */
|
||||
.context-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.context-divider::before, .context-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #d1d5db, transparent);
|
||||
}
|
||||
.dark .context-divider::before, .dark .context-divider::after {
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,0.12), transparent);
|
||||
}
|
||||
.context-divider span {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Confirm Modal */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.visible { opacity: 1; }
|
||||
.confirm-modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
padding: 28px 24px 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
transform: scale(0.92);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.visible .confirm-modal { transform: scale(1); }
|
||||
.dark .confirm-modal {
|
||||
background: #1e1e1e;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dark .confirm-title { color: #e5e7eb; }
|
||||
.confirm-message {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.dark .confirm-message { color: #9ca3af; }
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.confirm-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.confirm-btn-cancel {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.confirm-btn-cancel:hover { background: #e5e7eb; }
|
||||
.dark .confirm-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.dark .confirm-btn-cancel:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
.confirm-btn-ok {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
.confirm-btn-ok:hover { background: #dc2626; }
|
||||
|
||||
/* Mobile: session panel as overlay */
|
||||
@media (max-width: 768px) {
|
||||
.session-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 45;
|
||||
width: 220px;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dark .session-panel {
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Groups */
|
||||
.menu-group-items { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out; }
|
||||
.menu-group.open .menu-group-items { max-height: 500px; transition: max-height 0.35s ease-in; }
|
||||
.menu-group.open .menu-group-items { max-height: 2000px; transition: max-height 0.35s ease-in; }
|
||||
.menu-group .chevron { transition: transform 0.25s ease; }
|
||||
.menu-group.open .chevron { transform: rotate(90deg); }
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ const I18N = {
|
||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||
new_chat: '新对话',
|
||||
session_history: '历史会话',
|
||||
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
||||
delete_session_title: '删除会话',
|
||||
untitled_session: '新对话',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
tip_new_chat: '新建对话',
|
||||
tip_clear_context: '清除上下文',
|
||||
tip_attach_file: '上传附件',
|
||||
confirm_yes: '确认',
|
||||
confirm_cancel: '取消',
|
||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时',
|
||||
},
|
||||
@@ -152,6 +164,18 @@ const I18N = {
|
||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||
new_chat: 'New Chat',
|
||||
session_history: 'History',
|
||||
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
||||
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
||||
delete_session_title: 'Delete Session',
|
||||
untitled_session: 'New Chat',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
tip_new_chat: 'New Chat',
|
||||
tip_clear_context: 'Clear Context',
|
||||
tip_attach_file: 'Attach File',
|
||||
confirm_yes: 'Confirm',
|
||||
confirm_cancel: 'Cancel',
|
||||
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||
thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration',
|
||||
}
|
||||
@@ -176,13 +200,15 @@ function applyI18n() {
|
||||
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||
});
|
||||
document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文';
|
||||
const langLabel = document.getElementById('lang-label');
|
||||
if (langLabel) langLabel.textContent = currentLang === 'zh' ? 'EN' : '中文';
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
currentLang = currentLang === 'zh' ? 'en' : 'zh';
|
||||
localStorage.setItem('cow_lang', currentLang);
|
||||
applyI18n();
|
||||
_applyInputTooltips();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -793,8 +819,11 @@ function sendMessage() {
|
||||
}
|
||||
|
||||
const ws = document.getElementById('welcome-screen');
|
||||
const isFirstMessage = !!ws;
|
||||
if (ws) ws.remove();
|
||||
|
||||
const titleInfo = (isFirstMessage && text) ? { sid: sessionId, userMsg: text } : null;
|
||||
|
||||
const timestamp = new Date();
|
||||
const attachments = [...pendingAttachments];
|
||||
addUserMessage(text, timestamp, attachments);
|
||||
@@ -830,7 +859,7 @@ function sendMessage() {
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (data.stream) {
|
||||
startSSE(data.request_id, loadingEl, timestamp);
|
||||
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
|
||||
} else {
|
||||
loadingContainers[data.request_id] = loadingEl;
|
||||
}
|
||||
@@ -858,7 +887,7 @@ function sendMessage() {
|
||||
postWithRetry(0);
|
||||
}
|
||||
|
||||
function startSSE(requestId, loadingEl, timestamp) {
|
||||
function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
let botEl = null;
|
||||
let stepsEl = null; // .agent-steps (thinking summaries + tool indicators)
|
||||
let contentEl = null; // .answer-content (final streaming answer)
|
||||
@@ -1074,6 +1103,13 @@ function startSSE(requestId, loadingEl, timestamp) {
|
||||
}
|
||||
scrollChatToBottom();
|
||||
|
||||
if (titleInfo) {
|
||||
generateSessionTitle(titleInfo.sid, titleInfo.userMsg, '');
|
||||
titleInfo = null;
|
||||
} else if (sessionPanelOpen) {
|
||||
loadSessionList();
|
||||
}
|
||||
|
||||
} else if (item.type === 'error') {
|
||||
done = true;
|
||||
es.close();
|
||||
@@ -1358,10 +1394,23 @@ function loadHistory(page) {
|
||||
// Keep the "load more" sentinel in place (inserted below)
|
||||
}
|
||||
|
||||
const ctxStartSeq = data.context_start_seq || 0;
|
||||
let dividerInserted = false;
|
||||
|
||||
data.messages.forEach(msg => {
|
||||
const hasContent = msg.content && msg.content.trim();
|
||||
const hasToolCalls = msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0;
|
||||
if (!hasContent && !hasToolCalls) return;
|
||||
|
||||
// Insert context divider when transitioning from above to below boundary
|
||||
if (ctxStartSeq > 0 && !dividerInserted && msg._seq !== undefined && msg._seq >= ctxStartSeq) {
|
||||
dividerInserted = true;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
fragment.appendChild(divider);
|
||||
}
|
||||
|
||||
const ts = new Date(msg.created_at * 1000);
|
||||
const el = msg.role === 'user'
|
||||
? createUserMessageEl(msg.content, ts)
|
||||
@@ -1369,6 +1418,14 @@ function loadHistory(page) {
|
||||
fragment.appendChild(el);
|
||||
});
|
||||
|
||||
// If context was cleared but no new messages exist yet, append divider at the end
|
||||
if (ctxStartSeq > 0 && !dividerInserted) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
fragment.appendChild(divider);
|
||||
}
|
||||
|
||||
// Prepend history above any existing messages
|
||||
const sentinel = document.getElementById('history-load-more');
|
||||
const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild;
|
||||
@@ -1517,13 +1574,345 @@ function newChat() {
|
||||
});
|
||||
});
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
|
||||
// Show panel and load full session list, then prepend the new session on top
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (panel && !sessionPanelOpen) {
|
||||
sessionPanelOpen = true;
|
||||
panel.classList.remove('hidden');
|
||||
_persistPanelState();
|
||||
}
|
||||
const newSid = sessionId;
|
||||
loadSessionList(() => _addOptimisticSessionItem(newSid));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session Panel
|
||||
// =====================================================================
|
||||
|
||||
const SESSION_PANEL_KEY = 'cow_session_panel_open';
|
||||
let sessionPanelOpen = localStorage.getItem(SESSION_PANEL_KEY) === '1';
|
||||
|
||||
function _persistPanelState() {
|
||||
localStorage.setItem(SESSION_PANEL_KEY, sessionPanelOpen ? '1' : '0');
|
||||
}
|
||||
|
||||
function toggleSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel) return;
|
||||
sessionPanelOpen = !sessionPanelOpen;
|
||||
panel.classList.toggle('hidden', !sessionPanelOpen);
|
||||
_persistPanelState();
|
||||
if (sessionPanelOpen) loadSessionList();
|
||||
}
|
||||
|
||||
function openSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel || sessionPanelOpen) return;
|
||||
sessionPanelOpen = true;
|
||||
panel.classList.remove('hidden');
|
||||
_persistPanelState();
|
||||
loadSessionList();
|
||||
}
|
||||
|
||||
function _restoreSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel) return;
|
||||
if (sessionPanelOpen) {
|
||||
panel.classList.remove('hidden');
|
||||
loadSessionList();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function _applyInputTooltips() {
|
||||
const set = (id, key, pos) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.setAttribute('data-tooltip', t(key));
|
||||
el.removeAttribute('title');
|
||||
if (pos) el.setAttribute('data-tooltip-pos', pos);
|
||||
};
|
||||
set('new-chat-btn', 'tip_new_chat');
|
||||
set('clear-context-btn', 'tip_clear_context');
|
||||
set('attach-btn', 'tip_attach_file');
|
||||
set('session-toggle-btn', 'session_history', 'bottom');
|
||||
}
|
||||
|
||||
function _addOptimisticSessionItem(sid) {
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
|
||||
const emptyEl = container.querySelector('.session-empty');
|
||||
if (emptyEl) emptyEl.remove();
|
||||
|
||||
document.querySelectorAll('.session-item.active').forEach(el => el.classList.remove('active'));
|
||||
|
||||
const todayLabel = t('today');
|
||||
let firstGroup = container.querySelector('.session-group-label');
|
||||
if (!firstGroup || firstGroup.textContent !== todayLabel) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'session-group-label';
|
||||
header.textContent = todayLabel;
|
||||
container.prepend(header);
|
||||
firstGroup = header;
|
||||
}
|
||||
|
||||
const title = t('new_chat');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'session-item active';
|
||||
item.dataset.sessionId = sid;
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${sid}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
`;
|
||||
item.addEventListener('click', () => switchSession(sid));
|
||||
firstGroup.insertAdjacentElement('afterend', item);
|
||||
}
|
||||
|
||||
function _sessionTimeGroup(ts) {
|
||||
const now = new Date();
|
||||
const d = new Date(ts * 1000);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
|
||||
if (d >= today) return t('today');
|
||||
if (d >= yesterday) return t('yesterday');
|
||||
return t('earlier');
|
||||
}
|
||||
|
||||
let _sessionPage = 1;
|
||||
let _sessionHasMore = false;
|
||||
let _sessionLoading = false;
|
||||
const _SESSION_PAGE_SIZE = 50;
|
||||
|
||||
function loadSessionList(onDone) {
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
|
||||
_sessionPage = 1;
|
||||
_sessionHasMore = false;
|
||||
|
||||
_fetchSessionPage(1, true, onDone);
|
||||
}
|
||||
|
||||
function _fetchSessionPage(page, clear, onDone) {
|
||||
if (_sessionLoading) return;
|
||||
_sessionLoading = true;
|
||||
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) { _sessionLoading = false; return; }
|
||||
|
||||
// Remove existing "load more" sentinel before fetching
|
||||
const oldSentinel = container.querySelector('.session-load-more');
|
||||
if (oldSentinel) oldSentinel.remove();
|
||||
|
||||
fetch(`/api/sessions?page=${page}&page_size=${_SESSION_PAGE_SIZE}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
_sessionLoading = false;
|
||||
if (data.status !== 'success') return;
|
||||
|
||||
if (clear) container.innerHTML = '';
|
||||
|
||||
const sessions = data.sessions || [];
|
||||
_sessionPage = page;
|
||||
_sessionHasMore = !!data.has_more;
|
||||
|
||||
if (sessions.length === 0 && page === 1) {
|
||||
container.innerHTML = '<div class="session-empty">' + t('untitled_session') + '</div>';
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track last group label already in the container
|
||||
const existingLabels = container.querySelectorAll('.session-group-label');
|
||||
let lastGroup = existingLabels.length > 0
|
||||
? existingLabels[existingLabels.length - 1].textContent
|
||||
: '';
|
||||
|
||||
sessions.forEach(s => {
|
||||
const group = _sessionTimeGroup(s.last_active);
|
||||
if (group !== lastGroup) {
|
||||
lastGroup = group;
|
||||
const header = document.createElement('div');
|
||||
header.className = 'session-group-label';
|
||||
header.textContent = group;
|
||||
container.appendChild(header);
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
const isActive = s.session_id === sessionId;
|
||||
item.className = 'session-item' + (isActive ? ' active' : '');
|
||||
item.dataset.sessionId = s.session_id;
|
||||
|
||||
const title = s.title || t('untitled_session');
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
`;
|
||||
item.addEventListener('click', () => switchSession(s.session_id));
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (typeof onDone === 'function') onDone();
|
||||
})
|
||||
.catch(() => { _sessionLoading = false; });
|
||||
}
|
||||
|
||||
function _onSessionListScroll() {
|
||||
if (!_sessionHasMore || _sessionLoading) return;
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
// Trigger when scrolled near the bottom (within 60px)
|
||||
if (container.scrollHeight - container.scrollTop - container.clientHeight < 60) {
|
||||
_fetchSessionPage(_sessionPage + 1, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach scroll listener once DOM is ready
|
||||
(function _initSessionScroll() {
|
||||
const el = document.getElementById('session-list');
|
||||
if (el) {
|
||||
el.addEventListener('scroll', _onSessionListScroll);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const el2 = document.getElementById('session-list');
|
||||
if (el2) el2.addEventListener('scroll', _onSessionListScroll);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function switchSession(newSessionId) {
|
||||
if (newSessionId === sessionId) {
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
activeStreams = {};
|
||||
loadingContainers = {};
|
||||
|
||||
sessionId = newSessionId;
|
||||
localStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||
|
||||
historyPage = 0;
|
||||
historyHasMore = false;
|
||||
historyLoading = false;
|
||||
|
||||
messagesDiv.innerHTML = '';
|
||||
loadHistory(1);
|
||||
startPolling();
|
||||
|
||||
document.querySelectorAll('.session-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.sessionId === sessionId);
|
||||
});
|
||||
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
}
|
||||
|
||||
function deleteSession(sid) {
|
||||
showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sid)}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
if (sid === sessionId) {
|
||||
newChat();
|
||||
} else {
|
||||
loadSessionList();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function showConfirmModal(title, message, onConfirm) {
|
||||
let overlay = document.getElementById('confirm-modal-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'confirm-modal-overlay';
|
||||
overlay.className = 'confirm-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'confirm-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="confirm-title">${escapeHtml(title)}</div>
|
||||
<div class="confirm-message">${escapeHtml(message)}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn-cancel">${t('confirm_cancel')}</button>
|
||||
<button class="confirm-btn confirm-btn-ok">${t('confirm_yes')}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||
|
||||
const close = () => {
|
||||
overlay.classList.remove('visible');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
modal.querySelector('.confirm-btn-cancel').addEventListener('click', close);
|
||||
modal.querySelector('.confirm-btn-ok').addEventListener('click', () => {
|
||||
close();
|
||||
onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function clearContext() {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
// Insert a visual divider in the chat
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
messagesDiv.appendChild(divider);
|
||||
scrollChatToBottom();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function generateSessionTitle(sid, userMsg, assistantReply) {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sid)}/generate_title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_message: userMsg, assistant_reply: assistantReply }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && sessionPanelOpen) {
|
||||
loadSessionList();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Utilities
|
||||
// =====================================================================
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const now = new Date();
|
||||
const sameDay = date.getFullYear() === now.getFullYear()
|
||||
&& date.getMonth() === now.getMonth()
|
||||
&& date.getDate() === now.getDate();
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (sameDay) return time;
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
if (date.getFullYear() === now.getFullYear()) return `${m}-${d} ${time}`;
|
||||
return `${date.getFullYear()}-${m}-${d} ${time}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -3561,6 +3950,10 @@ window.fetch = function(...args) {
|
||||
};
|
||||
|
||||
function initApp() {
|
||||
applyI18n();
|
||||
_applyInputTooltips();
|
||||
_restoreSessionPanel();
|
||||
|
||||
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') _knowledgeTreeData = data.tree || [];
|
||||
}).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user