- 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"
This commit is contained in:
PF4YZYNS\admin
2026-06-05 18:51:35 +08:00
parent fde4b6f590
commit c62175c06b
4 changed files with 748 additions and 11 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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, '<br>'); }
}
function _addCodeBlockHeaders(container) {
// Add header with language label and copy button to each <pre> 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 = `
<span class="code-block-lang">${langLabel}</span>
<button class="code-copy-btn" title="Copy code">
<i class="fas fa-copy"></i>
</button>
`;
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 = `
<div class="drag-overlay-content">
<i class="fas fa-cloud-arrow-up"></i>
<p>Drop files here to upload</p>
</div>
`;
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) {
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
${attachHtml}${textHtml}
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
<div class="flex items-center justify-end gap-2 mt-1.5">
<button class="edit-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('edit_message')}">
<i class="fas fa-pen-to-square"></i>
</button>
<button class="delete-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors cursor-pointer" title="${t('delete_message_title')}">
<i class="fas fa-trash"></i>
</button>
<span class="text-xs text-slate-400 dark:text-slate-500">${formatTime(timestamp)}</span>
</div>
</div>
`;
// Store raw content for editing
el.dataset.rawContent = content || '';
return el;
}
@@ -2487,6 +2889,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) {
<button class="copy-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${currentLang === 'zh' ? '复制' : 'Copy'}">
<i class="fas fa-copy"></i>
</button>
<button class="regenerate-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('regenerate_response')}">
<i class="fas fa-rotate-right"></i>
</button>
<button class="delete-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors cursor-pointer" title="${t('delete_message_title')}">
<i class="fas fa-trash"></i>
</button>
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
<i class="fas fa-volume-up"></i>
</button>
@@ -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);
}

View File

@@ -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()