-
Channels
-
View and manage messaging channels
+
通道管理
+
管理已接入的消息通道
@@ -799,8 +830,8 @@
-
Scheduled Tasks
-
View and manage scheduled tasks
+
定时任务
+
查看和管理定时任务
@@ -822,8 +853,8 @@
-
Logs
-
Real-time log output (run.log)
+
日志
+
实时日志输出 (run.log)
@@ -838,11 +869,11 @@
- Live
+ 实时
-
Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.
+
日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 075d53a1..fd4be272 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -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); }
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 2f5f19ef..50634c44 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -79,6 +79,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: '请求超时,请再试一次。',
},
en: {
@@ -149,6 +161,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.',
}
};
@@ -172,13 +196,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();
}
// =====================================================================
@@ -789,8 +815,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);
@@ -826,7 +855,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;
}
@@ -854,7 +883,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)
@@ -1073,6 +1102,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();
@@ -1362,10 +1398,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 = `
${t('context_cleared')}`;
+ fragment.appendChild(divider);
+ }
+
const ts = new Date(msg.created_at * 1000);
const el = msg.role === 'user'
? createUserMessageEl(msg.content, ts)
@@ -1373,6 +1422,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 = `
${t('context_cleared')}`;
+ fragment.appendChild(divider);
+ }
+
// Prepend history above any existing messages
const sentinel = document.getElementById('history-load-more');
const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild;
@@ -1521,13 +1578,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 = `
+
+
${escapeHtml(title)}
+
+ `;
+ 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 = '
' + t('untitled_session') + '
';
+ 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 = `
+
+
${escapeHtml(title)}
+
+ `;
+ 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 = `
+
${escapeHtml(title)}
+
${escapeHtml(message)}
+
+
+
+
+ `;
+ 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 = `
${t('context_cleared')}`;
+ 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) {
@@ -3563,6 +3952,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(() => {});
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 57b95217..8fb6f65b 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -90,6 +90,42 @@ def _get_upload_dir() -> str:
return tmp_dir
+def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
+ """
+ Generate a short session title by calling the current bot's reply_text.
+ """
+ import re
+ fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
+ try:
+ from bridge.bridge import Bridge
+ from models.session_manager import Session
+ bot = Bridge().get_bot("chat")
+
+ prompt_parts = [f"User: {user_message[:300]}"]
+ if assistant_reply:
+ prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
+
+ session = Session("__title_gen__", system_prompt="")
+ session.messages = [
+ {"role": "user", "content": (
+ "Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
+ "summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
+ + "\n".join(prompt_parts)
+ )}
+ ]
+
+ result = bot.reply_text(session)
+ raw = (result.get("content") or "").strip()
+ # Strip
... reasoning blocks
+ title = re.sub(r'
.*?', '', raw, flags=re.DOTALL).strip().strip('"\'')
+ logger.info(f"[WebChannel] Title generation result: '{title}' (len={len(title)})")
+ if title and len(title) <= 50:
+ return title
+ except Exception as e:
+ logger.warning(f"[WebChannel] Title generation failed: {e}")
+ return fallback
+
+
class WebMessage(ChatMessage):
def __init__(
self,
@@ -519,6 +555,10 @@ class WebChannel(ChatChannel):
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/scheduler', 'SchedulerHandler',
+ '/api/sessions', 'SessionsHandler',
+ '/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
+ '/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
+ '/api/sessions/(.*)', 'SessionDetailHandler',
'/api/history', 'HistoryHandler',
'/api/logs', 'LogsHandler',
'/api/version', 'VersionHandler',
@@ -688,10 +728,15 @@ class StreamHandler:
class ChatHandler:
def GET(self):
- # 正常返回聊天页面
+ web.header('Cache-Control', 'no-cache, no-store, must-revalidate')
+ web.header('Pragma', 'no-cache')
file_path = os.path.join(os.path.dirname(__file__), 'chat.html')
with open(file_path, 'r', encoding='utf-8') as f:
- return f.read()
+ html = f.read()
+ cache_bust = str(int(time.time()))
+ html = html.replace('assets/js/console.js', f'assets/js/console.js?v={cache_bust}')
+ html = html.replace('assets/css/console.css', f'assets/css/console.css?v={cache_bust}')
+ return html
class ConfigHandler:
@@ -1540,6 +1585,135 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)})
+class SessionsHandler:
+ def GET(self):
+ _require_auth()
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ params = web.input(page='1', page_size='50')
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ result = store.list_sessions(
+ channel_type="web",
+ page=int(params.page),
+ page_size=int(params.page_size),
+ )
+ return json.dumps({"status": "success", **result}, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"[WebChannel] Sessions API error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+
+class SessionDetailHandler:
+ def DELETE(self, session_id: str):
+ _require_auth()
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ logger.info(f"[WebChannel] DELETE session request: {session_id}")
+ try:
+ if not session_id:
+ return json.dumps({"status": "error", "message": "session_id required"})
+
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ store.clear_session(session_id)
+
+ # Also remove the Agent instance from AgentBridge if exists
+ try:
+ from bridge.bridge import Bridge
+ ab = Bridge().get_agent_bridge()
+ if session_id in ab.agents:
+ del ab.agents[session_id]
+ logger.info(f"[WebChannel] Removed agent instance for session {session_id}")
+ except Exception:
+ pass
+
+ channel = WebChannel()
+ channel.session_queues.pop(session_id, None)
+
+ logger.info(f"[WebChannel] Session deleted: {session_id}")
+ return json.dumps({"status": "success"})
+ except Exception as e:
+ logger.error(f"[WebChannel] Session delete error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+ def PUT(self, session_id: str):
+ _require_auth()
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ if not session_id:
+ return json.dumps({"status": "error", "message": "session_id required"})
+ body = json.loads(web.data())
+ title = body.get("title", "").strip()
+ if not title:
+ return json.dumps({"status": "error", "message": "title required"})
+
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ found = store.rename_session(session_id, title)
+ if not found:
+ return json.dumps({"status": "error", "message": "session not found"})
+ return json.dumps({"status": "success"})
+ except Exception as e:
+ logger.error(f"[WebChannel] Session rename error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+
+class SessionTitleHandler:
+ def POST(self, session_id: str):
+ _require_auth()
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ if not session_id:
+ return json.dumps({"status": "error", "message": "session_id required"})
+
+ body = json.loads(web.data())
+ user_message = body.get("user_message", "")
+ assistant_reply = body.get("assistant_reply", "")
+ if not user_message:
+ return json.dumps({"status": "error", "message": "user_message required"})
+
+ title = _generate_session_title(user_message, assistant_reply)
+
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ updated = store.rename_session(session_id, title)
+ logger.info(f"[WebChannel] Session title set: sid={session_id}, title='{title}', db_updated={updated}")
+
+ return json.dumps({"status": "success", "title": title}, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"[WebChannel] Title generation error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+
+class SessionClearContextHandler:
+ def POST(self, session_id: str):
+ _require_auth()
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ if not session_id:
+ return json.dumps({"status": "error", "message": "session_id required"})
+
+ from agent.memory import get_conversation_store
+ store = get_conversation_store()
+ new_seq = store.clear_context(session_id)
+
+ # Delete the agent instance so a fresh one is created on the next message
+ try:
+ from bridge.bridge import Bridge
+ bridge = Bridge()
+ ab = bridge.get_agent_bridge()
+ if session_id in ab.agents:
+ del ab.agents[session_id]
+ logger.info(f"[WebChannel] Cleared agent instance for session {session_id}")
+ except Exception:
+ pass
+
+ return json.dumps({"status": "success", "context_start_seq": new_seq})
+ except Exception as e:
+ logger.error(f"[WebChannel] Clear context error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+
class HistoryHandler:
def GET(self):
_require_auth()
diff --git a/docs/channels/web.mdx b/docs/channels/web.mdx
index d8329553..b57b1688 100644
--- a/docs/channels/web.mdx
+++ b/docs/channels/web.mdx
@@ -57,6 +57,16 @@ Web 控制台默认无需密码即可访问。如果部署在公网环境,建

+#### 多会话管理
+
+对话界面支持多会话(Session)管理,所有会话记录持久化存储在数据库中:
+
+- **会话列表**:点击左侧历史会话图标可展开/收起会话列表面板,支持滚动加载全部历史会话
+- **AI 生成标题**:新会话在首轮对话完成后,自动调用模型生成简短的会话摘要标题
+- **新建会话**:点击会话列表顶部的「新对话」按钮或输入区的 `+` 按钮创建新会话
+- **删除会话**:点击会话项的删除按钮,确认后永久删除该会话及其所有消息
+- **清除上下文**:点击输入区的清除按钮,在当前会话中插入一条分隔线,分隔线以上的消息仍然展示但不再作为模型的上下文输入
+
### 模型管理
支持在线管理模型配置,无需手动编辑配置文件:
diff --git a/docs/en/channels/web.mdx b/docs/en/channels/web.mdx
index 845b2196..503fe95b 100644
--- a/docs/en/channels/web.mdx
+++ b/docs/en/channels/web.mdx
@@ -38,6 +38,16 @@ Supports streaming output with real-time display of the Agent's reasoning proces

+#### Multi-Session Management
+
+The chat interface supports multi-session management. All session records are persistently stored in a SQLite database:
+
+- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions
+- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title
+- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session
+- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages
+- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model
+
### Model Management
Manage model configurations online without manually editing config files:
diff --git a/docs/ja/channels/web.mdx b/docs/ja/channels/web.mdx
index 98ac73de..84ab18dd 100644
--- a/docs/ja/channels/web.mdx
+++ b/docs/ja/channels/web.mdx
@@ -38,6 +38,16 @@ Web コンソールは CowAgent のデフォルトチャネルです。起動後

+#### マルチセッション管理
+
+チャット画面はマルチセッション管理に対応しています。すべてのセッション記録は SQLite データベースに永続的に保存されます:
+
+- **セッション一覧**:左側の履歴アイコンをクリックしてセッション一覧パネルを展開/折りたたみでき、スクロールですべての履歴セッションを読み込めます
+- **AI によるタイトル生成**:新しいセッションの最初のやり取りが完了すると、自動的にモデルを呼び出して短い要約タイトルを生成します
+- **新規セッション**:セッション一覧上部の「新しい会話」ボタン、または入力エリアの `+` ボタンをクリックして新しいセッションを作成します
+- **セッション削除**:セッション項目の削除ボタンをクリックし、確認後にそのセッションとすべてのメッセージを完全に削除します
+- **コンテキストクリア**:入力エリアのクリアボタンをクリックすると、現在のセッションに区切り線が挿入されます。区切り線より上のメッセージは表示されたままですが、モデルのコンテキストには含まれなくなります
+
### モデル管理
設定ファイルを手動で編集せずに、オンラインでモデル設定を管理できます: