From c62175c06ba98d8a82e78f8582526a960c1b5034 Mon Sep 17 00:00:00 2001 From: "PF4YZYNS\\admin" <2250832148@qq.com> Date: Fri, 5 Jun 2026 18:51:35 +0800 Subject: [PATCH] - Add edit/delete/regenerate for user and bot messages - Add language labels and copy buttons to code blocks - Enhance drag-and-drop to full chat view - Fix data consistency bugs in message operations - Use RLock to prevent deadlock in conversation store" --- agent/memory/conversation_store.py | 106 ++++++- channel/web/static/css/console.css | 168 +++++++++++ channel/web/static/js/console.js | 434 ++++++++++++++++++++++++++++- channel/web/web_channel.py | 51 ++++ 4 files changed, 748 insertions(+), 11 deletions(-) diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index 48148f61..a286efc4 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -291,7 +291,7 @@ class ConversationStore: def __init__(self, db_path: Path): self._db_path = db_path - self._lock = threading.Lock() + self._lock = threading.RLock() # Use RLock to allow reentrant locking self._init_db() # ------------------------------------------------------------------ @@ -524,6 +524,109 @@ class ConversationStore: finally: conn.close() + def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int: + """Delete a user message and/or its corresponding assistant reply. + + The assistant reply is identified as all messages between user_seq + and the next visible user message (or end of session). + + Args: + session_id: Session identifier. + user_seq: The seq number of the user message. + delete_user: If True (default), delete the user message too. + If False, only delete assistant reply (for regenerate scenarios). + cascade: If True, also delete all subsequent turns after this one. + Used by edit-message which removes this turn and everything after. + + Returns: + Number of message rows deleted. + """ + with self._lock: + conn = self._connect() + try: + with conn: + # Verify this is a user message + row = conn.execute( + "SELECT role FROM messages WHERE session_id = ? AND seq = ?", + (session_id, user_seq), + ).fetchone() + if not row or row[0] != "user": + return 0 + + if cascade: + # Delete from this message to end of session + start_seq = user_seq if delete_user else user_seq + 1 + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + else: + # Find the next visible user message seq (exclude tool_result) + # Use batched query to avoid loading too many rows at once + next_user_seq = None + batch_size = 100 + offset = 0 + while True: + batch = conn.execute( + """ + SELECT seq, content FROM messages + WHERE session_id = ? AND seq > ? AND role = 'user' + ORDER BY seq ASC + LIMIT ? OFFSET ? + """, + (session_id, user_seq, batch_size, offset), + ).fetchall() + if not batch: + break + for seq, content in batch: + try: + content_obj = json.loads(content) + except Exception: + content_obj = content + if _is_visible_user_message(content_obj): + next_user_seq = seq + break + if next_user_seq is not None: + break + offset += batch_size + + # Determine the end boundary for deletion + if next_user_seq is not None: + end_seq = next_user_seq + else: + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + + # Determine the start boundary for deletion + start_seq = user_seq if delete_user else user_seq + 1 + + # Delete messages from start_seq to end_seq (exclusive) + cur = conn.execute( + "DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?", + (session_id, start_seq, end_seq), + ) + deleted = cur.rowcount + + # Update session msg_count + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + + return deleted + finally: + conn.close() + def prune_scheduled_messages( self, session_id: str, @@ -1053,3 +1156,4 @@ def get_conversation_store() -> ConversationStore: _store_instance = ConversationStore(db_path) logger.debug(f"[ConversationStore] Using shared DB at: {db_path}") return _store_instance + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 957db3e0..188093b3 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -1399,3 +1399,171 @@ .agent-cancelled-tag { font-style: italic; } + +/* ===================================================================== + Code Block Enhancements + ===================================================================== */ +.code-block-wrapper { + position: relative; + margin: 1em 0; + border-radius: 8px; + overflow: hidden; + background: #f8f9fa; + border: 1px solid #e2e8f0; +} + +.dark .code-block-wrapper { + background: #1e293b; + border-color: #334155; +} + +.code-block-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + background: #e2e8f0; + border-bottom: 1px solid #cbd5e1; + font-size: 0.85em; +} + +.dark .code-block-header { + background: #0f172a; + border-bottom-color: #334155; +} + +.code-block-lang { + color: #64748b; + font-weight: 500; + text-transform: lowercase; +} + +.dark .code-block-lang { + color: #94a3b8; +} + +.code-copy-btn { + background: transparent; + border: none; + color: #64748b; + cursor: pointer; + padding: 0.25em 0.5em; + border-radius: 4px; + transition: all 0.2s; + font-size: 0.9em; +} + +.code-copy-btn:hover { + background: rgba(100, 116, 139, 0.1); + color: #475569; +} + +.dark .code-copy-btn { + color: #94a3b8; +} + +.dark .code-copy-btn:hover { + background: rgba(148, 163, 184, 0.1); + color: #cbd5e1; +} + +.code-block-wrapper pre { + margin: 0; + border-radius: 0; + border: none; +} + +/* ===================================================================== + Drag and Drop Overlay + ===================================================================== */ +.drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(59, 130, 246, 0.1); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; +} + +.drag-overlay.active { + opacity: 1; +} + +.drag-overlay.hidden { + display: none; +} + +.drag-overlay-content { + background: white; + border: 3px dashed #3b82f6; + border-radius: 16px; + padding: 3em 4em; + text-align: center; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + animation: bounce 1s ease infinite; +} + +.dark .drag-overlay-content { + background: #1e293b; + border-color: #60a5fa; +} + +.drag-overlay-content i { + font-size: 4em; + color: #3b82f6; + margin-bottom: 0.5em; +} + +.dark .drag-overlay-content i { + color: #60a5fa; +} + +.drag-overlay-content p { + font-size: 1.5em; + font-weight: 600; + color: #1e293b; + margin: 0; +} + +.dark .drag-overlay-content p { + color: #f1f5f9; +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +/* ===================================================================== + Message Action Buttons + ===================================================================== */ +.edit-msg-btn, +.delete-msg-btn, +.regenerate-msg-btn { + opacity: 0; + transition: opacity 0.2s, color 0.2s; +} + +.user-message-group:hover .edit-msg-btn, +.user-message-group:hover .delete-msg-btn, +.flex.gap-3:hover .regenerate-msg-btn, +.flex.gap-3:hover .delete-msg-btn { + opacity: 1; +} + +.edit-msg-btn:hover, +.regenerate-msg-btn:hover { + color: #3b82f6 !important; +} + +.delete-msg-btn:hover { + color: #ef4444 !important; +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index e4d84387..70ab97c7 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -184,6 +184,8 @@ const I18N = { today: '今天', yesterday: '昨天', earlier: '更早', delete_session_confirm: '确认删除该会话?所有消息将被清除。', delete_session_title: '删除会话', + delete_message_confirm: '确认删除这条消息?', + delete_message_title: '删除消息', untitled_session: '新对话', context_cleared: '— 以上内容已从上下文中移除 —', tip_new_chat: '新建对话', @@ -206,6 +208,10 @@ const I18N = { confirm_cancel: '取消', error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。', thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时', + edit_message: '编辑消息', + regenerate_response: '重新生成', + edit_save: '保存并发送', + edit_cancel: '取消', }, en: { console: 'Console', @@ -380,6 +386,8 @@ const I18N = { today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier', delete_session_confirm: 'Delete this session? All messages will be removed.', delete_session_title: 'Delete Session', + delete_message_confirm: 'Delete this message?', + delete_message_title: 'Delete Message', untitled_session: 'New Chat', context_cleared: '— Context above has been cleared —', tip_new_chat: 'New Chat', @@ -402,6 +410,10 @@ const I18N = { 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', + edit_message: 'Edit message', + regenerate_response: 'Regenerate', + edit_save: 'Save and send', + edit_cancel: 'Cancel', } }; @@ -821,11 +833,45 @@ function renderMarkdown(text) { let html = md.render(text); html = _rewriteLocalImgSrc(html); // Order matters: video first (more specific), then image. - return injectImagePreviews(injectVideoPlayers(html)); + html = injectImagePreviews(injectVideoPlayers(html)); + // Note: Code block headers are added via DOM manipulation after insertion + // See addCodeBlockHeadersToElement() + return html; } catch (e) { return text.replace(/\n/g, '
'); } } +function _addCodeBlockHeaders(container) { + // Add header with language label and copy button to each
 block using DOM manipulation
+    const preBlocks = container.querySelectorAll('pre');
+    preBlocks.forEach(pre => {
+        if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrapper')) return;
+        
+        const codeEl = pre.querySelector('code');
+        if (!codeEl) return;
+        
+        const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-'));
+        const language = langClass ? langClass.replace('language-', '') : 'code';
+        const langLabel = language.charAt(0).toUpperCase() + language.slice(1);
+        
+        const wrapper = document.createElement('div');
+        wrapper.className = 'code-block-wrapper';
+        
+        const header = document.createElement('div');
+        header.className = 'code-block-header';
+        header.innerHTML = `
+            ${langLabel}
+            
+        `;
+        
+        pre.parentNode.insertBefore(wrapper, pre);
+        wrapper.appendChild(header);
+        wrapper.appendChild(pre);
+    });
+}
+
 // =====================================================================
 // Chat Module
 // =====================================================================
@@ -1085,6 +1131,22 @@ messagesDiv.addEventListener('scroll', () => {
 
 // Intercept internal navigation links in chat messages
 messagesDiv.addEventListener('click', (e) => {
+    // Code block copy button
+    const codeCopyBtn = e.target.closest('.code-copy-btn');
+    if (codeCopyBtn) {
+        e.preventDefault();
+        const wrapper = codeCopyBtn.closest('.code-block-wrapper');
+        const codeEl = wrapper && wrapper.querySelector('pre code');
+        if (codeEl) {
+            const codeText = codeEl.textContent;
+            copyToClipboard(codeText).then(() => {
+                const icon = codeCopyBtn.querySelector('i');
+                if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
+            });
+        }
+        return;
+    }
+
     const copyBtn = e.target.closest('.copy-msg-btn');
     if (copyBtn) {
         e.preventDefault();
@@ -1092,13 +1154,97 @@ messagesDiv.addEventListener('click', (e) => {
         const answerEl = msgRoot && msgRoot.querySelector('.answer-content');
         const rawMd = answerEl && answerEl.dataset.rawMd;
         if (rawMd) {
-            navigator.clipboard.writeText(rawMd).then(() => {
+            copyToClipboard(rawMd).then(() => {
                 const icon = copyBtn.querySelector('i');
                 if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
             });
         }
         return;
     }
+
+    // Edit user message
+    const editBtn = e.target.closest('.edit-msg-btn');
+    if (editBtn) {
+        e.preventDefault();
+        const msgRoot = editBtn.closest('.user-message-group');
+        if (msgRoot) editUserMessage(msgRoot);
+        return;
+    }
+
+    // Regenerate bot response
+    const regenerateBtn = e.target.closest('.regenerate-msg-btn');
+    if (regenerateBtn) {
+        e.preventDefault();
+        const botMsgRoot = regenerateBtn.closest('.flex.gap-3');
+        if (botMsgRoot) regenerateResponse(botMsgRoot);
+        return;
+    }
+
+    // Delete message
+    const deleteBtn = e.target.closest('.delete-msg-btn');
+    if (deleteBtn) {
+        e.preventDefault();
+        const userMsgEl = deleteBtn.closest('.user-message-group');
+        const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)');
+        
+        if (userMsgEl) {
+            showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
+                let nextSibling = userMsgEl.nextElementSibling;
+                let botReplyEl = null;
+                while (nextSibling) {
+                    if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) {
+                        botReplyEl = nextSibling;
+                        break;
+                    }
+                    nextSibling = nextSibling.nextElementSibling;
+                }
+                userMsgEl.remove();
+                if (botReplyEl) botReplyEl.remove();
+                
+                const userSeq = userMsgEl.dataset.seq;
+                if (userSeq) {
+                    fetch('/api/messages/delete', {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
+                    }).then(r => r.json()).then(data => {
+                        if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
+                    }).catch(err => console.error('Failed to delete:', err));
+                }
+            });
+        } else if (botMsgEl) {
+            showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
+                // Find the preceding user message to get its seq
+                let prevUserEl = botMsgEl.previousElementSibling;
+                while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) {
+                    prevUserEl = prevUserEl.previousElementSibling;
+                }
+                
+                // Delete from database (keep user message, only delete bot reply)
+                if (prevUserEl) {
+                    const userSeq = prevUserEl.dataset.seq;
+                    if (userSeq) {
+                        fetch('/api/messages/delete', {
+                            method: 'POST',
+                            headers: { 'Content-Type': 'application/json' },
+                            body: JSON.stringify({ 
+                                session_id: sessionId, 
+                                user_seq: parseInt(userSeq),
+                                delete_user: false
+                            })
+                        }).then(r => r.json()).then(data => {
+                            if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`);
+                        }).catch(err => console.error('Failed to delete bot reply:', err));
+                    }
+                }
+                
+                // Remove from DOM
+                botMsgEl.remove();
+            });
+        }
+        return;
+    }
+
     const a = e.target.closest('a');
     if (!a) return;
     const href = a.getAttribute('href') || '';
@@ -1376,14 +1522,83 @@ document.addEventListener('click', (e) => {
     hideAttachMenu();
 });
 
-// Drag-and-drop support on chat input area
+// Drag-and-drop support on entire chat view
+const chatView = document.getElementById('view-chat');
 const chatInputArea = chatInput.closest('.flex-shrink-0');
-chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
-chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); });
-chatInputArea.addEventListener('drop', (e) => {
-    e.preventDefault(); e.stopPropagation();
+
+// Create drag overlay for visual feedback
+let dragOverlay = document.getElementById('drag-overlay');
+if (!dragOverlay) {
+    dragOverlay = document.createElement('div');
+    dragOverlay.id = 'drag-overlay';
+    dragOverlay.className = 'drag-overlay hidden';
+    dragOverlay.innerHTML = `
+        
+ +

Drop files here to upload

+
+ `; + chatView.appendChild(dragOverlay); +} + +let dragCounter = 0; + +function showDragOverlay() { + dragOverlay.classList.remove('hidden'); + dragOverlay.classList.add('active'); +} + +function hideDragOverlay() { + dragOverlay.classList.remove('active'); + dragOverlay.classList.add('hidden'); +} + +chatView.addEventListener('dragenter', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter++; + if (e.dataTransfer.types.includes('Files')) { + showDragOverlay(); + } +}); + +chatView.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + chatInputArea.classList.add('drag-over'); +}); + +chatView.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter--; + if (dragCounter === 0) { + hideDragOverlay(); + chatInputArea.classList.remove('drag-over'); + } +}); + +chatView.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + hideDragOverlay(); chatInputArea.classList.remove('drag-over'); - if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files); + if (e.dataTransfer.files.length) { + handleFileSelect(e.dataTransfer.files); + } +}); + +document.body.addEventListener('dragover', (e) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } +}); + +document.body.addEventListener('drop', (e) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } }); // Paste image support @@ -1761,6 +1976,183 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) { scrollChatToBottom(true); } +// Clipboard helper with fallback for non-HTTPS environments +function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + // Fallback for HTTP environments + return new Promise((resolve, reject) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy') ? resolve() : reject(new Error('Copy failed')); + } catch (err) { + reject(err); + } finally { + textArea.remove(); + } + }); +} + +// Edit user message: extract content, remove this and subsequent messages, fill input +async function editUserMessage(msgEl) { + const rawContent = msgEl.dataset.rawContent; + if (!rawContent) return; + + // Delete this message and ALL subsequent messages from database (cascade) + // Must await to ensure delete completes before user sends a new message + const userSeq = msgEl.dataset.seq; + if (userSeq) { + try { + const resp = await fetch('/api/messages/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + user_seq: parseInt(userSeq), + delete_user: true, + cascade: true + }) + }); + const data = await resp.json(); + if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`); + } catch (err) { + console.error('Failed to delete old messages:', err); + } + } + + // Find all subsequent messages (this message and everything after it) + const messagesToRemove = []; + let current = msgEl; + while (current) { + if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) { + messagesToRemove.push(current); + } + current = current.nextElementSibling; + } + + // Remove all messages from this one onwards + messagesToRemove.forEach(el => { + if (el && el.parentNode) el.parentNode.removeChild(el); + }); + + // Fill input with the original content + chatInput.value = rawContent; + chatInput.style.height = 'auto'; + chatInput.style.height = chatInput.scrollHeight + 'px'; + chatInput.focus(); + scrollChatToBottom(); +} + +// Regenerate bot response: find the preceding user message and resend it +async function regenerateResponse(botMsgEl) { + let prevEl = botMsgEl.previousElementSibling; + while (prevEl && !prevEl.classList.contains('user-message-group')) { + prevEl = prevEl.previousElementSibling; + } + + if (!prevEl) { + console.warn('No preceding user message found'); + return; + } + + const userContent = prevEl.dataset.rawContent; + if (!userContent) { + console.warn('No content in preceding user message'); + return; + } + + // Delete both the old user message AND bot reply from database + // (because /message will create a fresh user message + new bot reply) + // Must await to ensure delete completes before /message is sent + const userSeq = prevEl.dataset.seq; + if (userSeq) { + try { + const resp = await fetch('/api/messages/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + user_seq: parseInt(userSeq), + delete_user: true + }) + }); + const data = await resp.json(); + if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`); + } catch (err) { + console.error('Failed to delete old messages:', err); + } + } + + // Remove both the old user message and bot message from DOM + if (prevEl.parentNode) prevEl.parentNode.removeChild(prevEl); + if (botMsgEl.parentNode) botMsgEl.parentNode.removeChild(botMsgEl); + + // Re-add the user message to DOM (so it appears before the loading indicator) + addUserMessage(userContent, new Date()); + + // Show loading indicator + const loadingEl = addLoadingIndicator(); + + // Resend the message + const timestamp = new Date(); + const body = { session_id: sessionId, message: userContent, stream: true, timestamp: timestamp.toISOString(), lang: currentLang }; + + const MAX_RETRIES = 2; + const RETRY_DELAY_MS = 1000; + + function postWithRetry(attempt) { + fetch('/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + if (data.inline_reply) { + loadingEl.remove(); + addBotMessage(data.inline_reply, new Date()); + } else if (data.stream) { + setSendBtnCancelMode(data.request_id); + startSSE(data.request_id, loadingEl, timestamp, null); + } else { + loadingContainers[data.request_id] = loadingEl; + } + } else { + loadingEl.remove(); + addBotMessage(t('error_send'), new Date()); + resetSendBtnSendMode(); + } + }) + .catch(err => { + if (err.name === 'AbortError') { + loadingEl.remove(); + addBotMessage(t('error_timeout'), new Date()); + resetSendBtnSendMode(); + return; + } + if (attempt < MAX_RETRIES) { + console.warn(`[regenerateResponse] attempt ${attempt + 1} failed, retrying...`, err); + setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1)); + return; + } + loadingEl.remove(); + addBotMessage(t('error_send'), new Date()); + resetSendBtnSendMode(); + }); + } + + postWithRetry(0); +} + function sendMessage() { // Do NOT branch on sendBtnMode here: Enter should always send (so // typing "/cancel" submits normally). Cancel is wired only to the @@ -2252,7 +2644,7 @@ function startPolling() { function createUserMessageEl(content, timestamp, attachments) { const el = document.createElement('div'); - el.className = 'flex justify-end px-4 sm:px-6 py-3'; + el.className = 'flex justify-end px-4 sm:px-6 py-3 user-message-group'; let attachHtml = ''; if (attachments && attachments.length > 0) { @@ -2277,9 +2669,19 @@ function createUserMessageEl(content, timestamp, attachments) {
${attachHtml}${textHtml}
-
${formatTime(timestamp)}
+
+ + + ${formatTime(timestamp)} +
`; + // Store raw content for editing + el.dataset.rawContent = content || ''; return el; } @@ -2487,6 +2889,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) { + + @@ -2709,6 +3117,10 @@ function loadHistory(page) { const el = msg.role === 'user' ? createUserMessageEl(msg.content, ts) : createBotMessageEl(msg.content || '', ts, null, msg); + // Store seq for delete functionality + if (msg._seq !== undefined) { + el.dataset.seq = msg._seq; + } fragment.appendChild(el); }); @@ -3285,6 +3697,8 @@ function applyHighlighting(container) { hljsLib.highlightElement(block); } }); + // Add language labels and copy buttons to code blocks + _addCodeBlockHeaders(root); }, 0); } diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 92cd9fcf..ea95b60e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1096,6 +1096,7 @@ class WebChannel(ChatChannel): '/api/sessions/(.*)/clear_context', 'SessionClearContextHandler', '/api/sessions/(.*)', 'SessionDetailHandler', '/api/history', 'HistoryHandler', + '/api/messages/delete', 'MessageDeleteHandler', '/api/logs', 'LogsHandler', '/api/version', 'VersionHandler', '/assets/(.*)', 'AssetsHandler', @@ -3920,6 +3921,56 @@ class HistoryHandler: return json.dumps({"status": "error", "message": str(e)}) +class MessageDeleteHandler: + def POST(self): + _require_auth() + web.header('Content-Type', 'application/json; charset=utf-8') + web.header('Access-Control-Allow-Origin', '*') + try: + data = json.loads(web.data()) + session_id = data.get('session_id', '').strip() + user_seq = data.get('user_seq') + delete_user = data.get('delete_user', True) + cascade = data.get('cascade', False) + + if not session_id or user_seq is None: + return json.dumps({"status": "error", "message": "session_id and user_seq required"}) + + # 1. Delete from database + from agent.memory import get_conversation_store + store = get_conversation_store() + deleted = store.delete_message_pair(session_id, int(user_seq), delete_user=delete_user, cascade=cascade) + + # 2. Sync agent's in-memory context + try: + from bridge import Bridge + ab = Bridge().get_agent_bridge() + agent = ab.get_agent(session_id) + if agent and hasattr(agent, 'messages') and hasattr(agent, 'messages_lock'): + with agent.messages_lock: + # Rebuild agent.messages from database + # load_messages returns: [{"role": "user", "content": "..."}, ...] + remaining_msgs = store.load_messages(session_id, max_turns=1000) + + agent.messages.clear() + for msg in remaining_msgs: + agent.messages.append({ + "role": msg["role"], + "content": msg["content"] + }) + + logger.info(f"[WebChannel] Synced agent memory for session {session_id}, " + f"remaining messages: {len(agent.messages)}") + except Exception as sync_err: + logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}") + # Don't fail the request if memory sync fails + + return json.dumps({"status": "success", "deleted": deleted}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Message delete error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + class LogsHandler: def GET(self): _require_auth()