From f1a31a459c4141a780d469438752941fda4f4cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:57:38 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 61 +++++++++++++++++++++++++++++++++++++ web/static/i18n/en-US.json | 3 ++ web/static/i18n/zh-CN.json | 3 ++ web/static/js/chat.js | 62 ++++++++++++++++++++++++++++++++++++++ web/static/js/monitor.js | 46 ++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+) diff --git a/web/static/css/style.css b/web/static/css/style.css index 89597e71..f4af191c 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1573,6 +1573,67 @@ header { letter-spacing: 0.01em; } +/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */ +.message-meta-footer { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + margin-top: 6px; + flex-wrap: wrap; + width: 100%; + min-height: 22px; +} + +.message.user .message-meta-footer { + justify-content: flex-end; +} + +.message.assistant .message-meta-footer { + justify-content: flex-start; +} + +.message-delete-turn-btn { + position: static; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #888); + cursor: pointer; + opacity: 0.5; + flex-shrink: 0; + transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease; +} + +.message:hover .message-meta-footer .message-delete-turn-btn { + opacity: 0.85; +} + +.message-delete-turn-btn:hover { + color: #c62828; + background: rgba(198, 40, 40, 0.07); + border-color: rgba(198, 40, 40, 0.15); + opacity: 1; +} + +.message-delete-turn-btn:focus-visible { + opacity: 1; + outline: 2px solid var(--accent-color, #0066ff); + outline-offset: 1px; +} + +@media (hover: none) { + .message-delete-turn-btn { + opacity: 0.65; + } +} + /* 用户消息中的表格样式 */ .message.user .message-bubble .table-wrapper { scrollbar-color: rgba(255, 255, 255, 0.3) transparent; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index d53ba93b..13701911 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -138,6 +138,9 @@ "expandDetail": "Expand details", "noProcessDetail": "No process details (execution may be too fast or no detailed events)", "copyMessageTitle": "Copy message", + "deleteTurnTitle": "Delete this turn", + "deleteTurnConfirm": "Delete this entire turn (user message and assistant reply)? This cannot be undone. The next reply will use only the remaining messages; saved context snapshots will be cleared.", + "deleteTurnFailed": "Failed to delete turn", "emptyGroupConversations": "This group has no conversations yet.", "noMatchingConversationsInGroup": "No matching conversations found.", "noHistoryConversations": "No conversation history yet", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 137cf1f2..b71a4a44 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -138,6 +138,9 @@ "expandDetail": "展开详情", "noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)", "copyMessageTitle": "复制消息内容", + "deleteTurnTitle": "删除本轮对话", + "deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。", + "deleteTurnFailed": "删除本轮失败", "emptyGroupConversations": "该分组暂无对话", "noMatchingConversationsInGroup": "未找到匹配的对话", "noHistoryConversations": "暂无历史对话", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index aa284032..b6f988f4 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2452,6 +2452,7 @@ async function loadConversation(conversationId) { const messageEl = document.getElementById(messageId); if (messageEl && msg && msg.id) { messageEl.dataset.backendMessageId = String(msg.id); + attachDeleteTurnButton(messageEl); } // 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮) if (msg.role === 'assistant') { @@ -2491,6 +2492,67 @@ async function loadConversation(conversationId) { } } +/** 「删除本轮」:与时间戳同一行(message-meta-footer),风格与复制按钮区区分 */ +function attachDeleteTurnButton(messageEl) { + if (!messageEl || !messageEl.dataset.backendMessageId) return; + if (messageEl.querySelector('.message-delete-turn-btn')) return; + const content = messageEl.querySelector('.message-content'); + if (!content) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'message-delete-turn-btn'; + const title = typeof window.t === 'function' ? window.t('chat.deleteTurnTitle') : '删除本轮对话'; + btn.title = title; + btn.setAttribute('aria-label', title); + btn.innerHTML = ''; + btn.onclick = function (e) { + e.stopPropagation(); + e.preventDefault(); + deleteConversationTurnFromUI(messageEl.dataset.backendMessageId); + }; + const timeDiv = content.querySelector('.message-time'); + let footer = content.querySelector('.message-meta-footer'); + if (!footer && timeDiv && timeDiv.parentNode === content) { + footer = document.createElement('div'); + footer.className = 'message-meta-footer'; + timeDiv.parentNode.insertBefore(footer, timeDiv); + footer.appendChild(timeDiv); + } + if (footer) { + footer.appendChild(btn); + } else { + content.appendChild(btn); + } +} + +/** 删除锚点所在整轮(后端:该轮 user 至下一轮 user 之前),并清空 ReAct 快照 */ +async function deleteConversationTurnFromUI(anchorBackendMessageId) { + if (!currentConversationId || !anchorBackendMessageId) return; + const confirmMsg = typeof window.t === 'function' ? window.t('chat.deleteTurnConfirm') : '确定删除本轮对话?'; + if (!confirm(confirmMsg)) return; + try { + const response = await apiFetch(`/api/conversations/${currentConversationId}/delete-turn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messageId: anchorBackendMessageId }) + }); + let data = {}; + try { + data = await response.json(); + } catch (e) { /* ignore */ } + if (!response.ok) { + throw new Error(data.error || data.message || 'delete failed'); + } + await loadConversation(currentConversationId); + if (typeof loadConversations === 'function') loadConversations(); + if (typeof loadConversationsWithGroups === 'function') loadConversationsWithGroups(); + } catch (error) { + console.error('delete turn failed:', error); + const failed = typeof window.t === 'function' ? window.t('chat.deleteTurnFailed') : '删除本轮失败'; + alert(failed + ': ' + (error && error.message ? error.message : error)); + } +} + // 删除对话 async function deleteConversation(conversationId, skipConfirm = false) { // 确认删除(如果调用者没有跳过确认) diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 576c12a9..769fedd8 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -700,10 +700,45 @@ function convertProgressToDetails(progressId, assistantMessageId) { scrollChatMessagesToBottomIfPinned(insertWasPinned); } +/** 将后端消息 UUID 绑定到助手气泡,供删除本轮 / 过程详情懒加载(domId 为前端 msg-*) */ +function applyBackendMessageIdToAssistantDom(domAssistantId, backendMessageId) { + if (!domAssistantId || !backendMessageId) return; + const el = document.getElementById(domAssistantId); + if (!el) return; + el.dataset.backendMessageId = String(backendMessageId); + if (typeof attachDeleteTurnButton === 'function') { + attachDeleteTurnButton(el); + } +} + +/** 将后端用户消息 ID 绑定到最后一条尚未绑定 backendMessageId 的用户气泡 */ +function applyBackendMessageIdToLastUser(backendMessageId) { + if (!backendMessageId) return; + const users = document.querySelectorAll('#chat-messages .message.user'); + if (!users.length) return; + const lastUser = users[users.length - 1]; + if (lastUser.dataset.backendMessageId) return; + lastUser.dataset.backendMessageId = String(backendMessageId); + if (typeof attachDeleteTurnButton === 'function') { + attachDeleteTurnButton(lastUser); + } +} + // 处理流式事件 function handleStreamEvent(event, progressElement, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds) { const streamScrollWasPinned = isChatMessagesPinnedToBottom(); + + // 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID + if (event.type === 'message_saved') { + const d = event.data || {}; + if (d.userMessageId) { + applyBackendMessageIdToLastUser(d.userMessageId); + } + scrollChatMessagesToBottomIfPinned(streamScrollWasPinned); + return; + } + const timeline = document.getElementById(progressId + '-timeline'); if (!timeline) return; @@ -1173,6 +1208,9 @@ function handleStreamEvent(event, progressElement, progressId, { const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null; const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId); + if (assistantId && preferredMessageId) { + applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId); + } if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { @@ -1306,6 +1344,11 @@ function handleStreamEvent(event, progressElement, progressId, integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds); responseStreamStateByProgressId.delete(progressId); + const respMid = responseData.messageId; + if (respMid) { + applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid); + } + setTimeout(() => { collapseAllProgressDetails(assistantIdFinal, progressId); }, 3000); @@ -1344,6 +1387,9 @@ function handleStreamEvent(event, progressElement, progressId, { const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null; const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId); + if (assistantId && preferredMessageId) { + applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId); + } if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) {