- 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

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