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 = `
+
+ `;
+ 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()