mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
- 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user