refactor(web-console): polish message actions on bubbles after #2865

This commit is contained in:
zhayujie
2026-06-06 16:07:31 +08:00
parent a2e4955116
commit 3c19614c74
5 changed files with 211 additions and 96 deletions

View File

@@ -509,6 +509,65 @@ class ConversationStore:
finally:
conn.close()
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
"""Return the seq numbers of the latest visible user message and the
latest assistant message in a session.
A "visible" user message is one whose content is real user text
(not just a tool_result block), so tool-execution turns do not
shadow the actual user query.
Returns:
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
when no matching message exists.
"""
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
with self._lock:
conn = self._connect()
try:
# Latest assistant message (cheap: single row by seq DESC).
row = conn.execute(
"SELECT seq FROM messages "
"WHERE session_id = ? AND role = 'assistant' "
"ORDER BY seq DESC LIMIT 1",
(session_id,),
).fetchone()
if row:
result["bot_seq"] = int(row[0])
# Latest visible user message: scan recent user rows and
# skip pure tool_result entries.
rows = conn.execute(
"SELECT seq, content FROM messages "
"WHERE session_id = ? AND role = 'user' "
"ORDER BY seq DESC LIMIT 20",
(session_id,),
).fetchall()
for seq, content_raw in rows:
try:
content = json.loads(content_raw)
except Exception:
result["user_seq"] = int(seq)
break
if isinstance(content, list):
has_text = any(
isinstance(b, dict) and b.get("type") == "text"
for b in content
)
has_tool_result = any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in content
)
if has_text and not has_tool_result:
result["user_seq"] = int(seq)
break
else:
result["user_seq"] = int(seq)
break
finally:
conn.close()
return result
def clear_session(self, session_id: str) -> None:
"""Delete all messages and the session record for a given session_id."""
with self._lock:

View File

@@ -384,6 +384,48 @@ class AgentBridge:
agent = self.initializer.initialize_agent(session_id=session_id)
self.agents[session_id] = agent
def sync_session_messages_from_store(self, session_id: str) -> int:
"""Reload an agent's in-memory ``messages`` list from the persistent
conversation store.
Used after an external mutation (e.g. user edits / deletes a message
via the web console) so the agent's next turn sees the same history
as the database. The operation is a no-op when the agent has not been
instantiated yet for the session.
Returns:
Number of messages now held in the agent's memory. Returns -1 if
the agent does not exist or has no compatible ``messages`` attr.
"""
if not session_id or session_id not in self.agents:
return -1
agent = self.agents[session_id]
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
return -1
try:
from agent.memory import get_conversation_store
store = get_conversation_store()
# No turn cap here: we want a faithful mirror of what the store
# has for this session after deletion.
remaining = store.load_messages(session_id, max_turns=10**6)
except Exception as e:
logger.warning(
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
)
return -1
with agent.messages_lock:
agent.messages.clear()
for msg in remaining:
agent.messages.append({
"role": msg["role"],
"content": msg["content"],
})
count = len(agent.messages)
logger.info(
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
)
return count
def agent_reply(self, query: str, context: Context = None,
on_event=None, clear_history: bool = False) -> Reply:
"""

View File

@@ -1476,6 +1476,11 @@
/* =====================================================================
Drag and Drop Overlay
===================================================================== */
/* Anchor the absolutely-positioned overlay to the chat view. */
#view-chat {
position: relative;
}
.drag-overlay {
position: absolute;
top: 0;
@@ -1554,8 +1559,7 @@
.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 {
.bot-message-group:hover .regenerate-msg-btn {
opacity: 1;
}

View File

@@ -1180,23 +1180,25 @@ messagesDiv.addEventListener('click', (e) => {
return;
}
// Delete message
// Delete message (user bubble only; bot bubbles intentionally lack a
// delete button — removing only the bot reply would leave an orphan
// user message that breaks LLM context alternation).
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) return;
if (userMsgEl) {
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
let nextSibling = userMsgEl.nextElementSibling;
// Find the next bot reply for this turn (skip non-message nodes).
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;
let sibling = userMsgEl.nextElementSibling;
while (sibling) {
if (sibling.classList && sibling.classList.contains('bot-message-group')) {
botReplyEl = sibling;
break;
}
nextSibling = nextSibling.nextElementSibling;
sibling = sibling.nextElementSibling;
}
userMsgEl.remove();
if (botReplyEl) botReplyEl.remove();
@@ -1212,36 +1214,6 @@ messagesDiv.addEventListener('click', (e) => {
}).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;
}
@@ -2028,17 +2000,25 @@ async function editUserMessage(msgEl) {
}
}
// Find all subsequent messages (this message and everything after it)
// Remove this message bubble and every later bubble that belongs to
// this or a subsequent turn. We mirror the backend cascade contract:
// anything with a data-seq >= current seq, plus any live SSE bubble
// that is still being streamed (no seq yet) after this point.
const currentSeqNum = userSeq ? parseInt(userSeq) : null;
const messagesToRemove = [];
let current = msgEl;
while (current) {
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) {
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('bot-message-group'))) {
const seqAttr = current.dataset.seq;
if (seqAttr === undefined || seqAttr === '') {
// Live message without a persisted seq yet — treat as later.
messagesToRemove.push(current);
} else if (currentSeqNum === null || parseInt(seqAttr) >= currentSeqNum) {
messagesToRemove.push(current);
}
}
current = current.nextElementSibling;
}
// Remove all messages from this one onwards
messagesToRemove.forEach(el => {
if (el && el.parentNode) el.parentNode.removeChild(el);
});
@@ -2266,8 +2246,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
if (botEl) return;
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
botEl = document.createElement('div');
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
botEl.dataset.requestId = requestId;
// Regenerate button starts hidden; it's revealed in the "done"
// event handler once seq metadata arrives from the backend.
botEl.innerHTML = `
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
<div class="min-w-0 flex-1 max-w-[85%]">
@@ -2285,6 +2267,9 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
<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>
<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')}" style="display:none;">
<i class="fas fa-rotate-right"></i>
</button>
</div>
</div>
`;
@@ -2534,6 +2519,29 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
if (copyBtn && finalText) copyBtn.style.display = '';
applyHighlighting(botEl);
}
// Backfill seq metadata so edit/regenerate buttons can call
// the delete API without a page refresh. Backend includes
// user_seq / bot_seq on the done event after persistence.
const targetBotEl = botEl || (requestId ? messagesDiv.querySelector(`[data-request-id="${requestId}"]`) : null);
if (targetBotEl) {
if (item.bot_seq !== undefined && item.bot_seq !== null) {
targetBotEl.dataset.seq = item.bot_seq;
}
// Reveal regenerate button now that the seq is wired up.
const regenBtn = targetBotEl.querySelector('.regenerate-msg-btn');
if (regenBtn) regenBtn.style.display = '';
if (item.user_seq !== undefined && item.user_seq !== null) {
// Locate the preceding user bubble for this turn.
let prev = targetBotEl.previousElementSibling;
while (prev && !prev.classList.contains('user-message-group')) {
prev = prev.previousElementSibling;
}
if (prev && !prev.dataset.seq) {
prev.dataset.seq = item.user_seq;
}
}
}
renderBotSpeakerButton(botEl, finalText);
scrollChatToBottom();
@@ -2857,7 +2865,7 @@ function localizeCancelMarker(text) {
function createBotMessageEl(content, timestamp, requestId, msg) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
el.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
if (requestId) el.dataset.requestId = requestId;
let stepsHtml = '';
@@ -2889,15 +2897,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>
<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>
</div>
</div>
`;

View File

@@ -251,6 +251,21 @@ class WebChannel(ChatChannel):
"""生成唯一的请求ID"""
return str(uuid.uuid4())
def _fetch_latest_pair_seqs(self, session_id: str):
"""Query the conversation store for the latest user/bot message seqs.
Returned as ``{"user_seq": int|None, "bot_seq": int|None}``; used to
attach seq metadata onto the SSE ``done`` event so the frontend can
wire edit / regenerate buttons for live-streamed bubbles without a
page refresh.
"""
try:
from agent.memory import get_conversation_store
return get_conversation_store().get_latest_pair_seqs(session_id)
except Exception as e:
logger.debug(f"[WebChannel] _fetch_latest_pair_seqs failed: {e}")
return {"user_seq": None, "bot_seq": None}
def send(self, reply: Reply, context: Context):
try:
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
@@ -291,11 +306,14 @@ class WebChannel(ChatChannel):
if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
text_content = getattr(reply, 'text_content', '')
if text_content:
seqs = self._fetch_latest_pair_seqs(session_id)
self.sse_queues[request_id].put({
"type": "done",
"content": text_content,
"request_id": request_id,
"timestamp": time.time()
"timestamp": time.time(),
"user_seq": seqs.get("user_seq"),
"bot_seq": seqs.get("bot_seq"),
})
logger.debug(f"SSE skipped duplicate file for request {request_id}")
return
@@ -307,11 +325,14 @@ class WebChannel(ChatChannel):
logger.debug(f"SSE skipped http media reply for request {request_id}")
return
seqs = self._fetch_latest_pair_seqs(session_id)
self.sse_queues[request_id].put({
"type": "done",
"content": content,
"request_id": request_id,
"timestamp": time.time()
"timestamp": time.time(),
"user_seq": seqs.get("user_seq"),
"bot_seq": seqs.get("bot_seq"),
})
logger.debug(f"SSE done sent for request {request_id}")
# Auto-trigger TTS once the bot finishes its text reply. The
@@ -3941,29 +3962,13 @@ class MessageDeleteHandler:
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
# 2. Sync agent's in-memory context so its next turn sees the
# same history as the DB. Handled by the agent_bridge helper.
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)}")
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
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: