Skip to content

Commit 3c19614

Browse files
committed
refactor(web-console): polish message actions on bubbles after #2865
1 parent a2e4955 commit 3c19614

5 files changed

Lines changed: 211 additions & 96 deletions

File tree

agent/memory/conversation_store.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,65 @@ def get_context_start_seq(self, session_id: str) -> int:
509509
finally:
510510
conn.close()
511511

512+
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
513+
"""Return the seq numbers of the latest visible user message and the
514+
latest assistant message in a session.
515+
516+
A "visible" user message is one whose content is real user text
517+
(not just a tool_result block), so tool-execution turns do not
518+
shadow the actual user query.
519+
520+
Returns:
521+
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
522+
when no matching message exists.
523+
"""
524+
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
525+
with self._lock:
526+
conn = self._connect()
527+
try:
528+
# Latest assistant message (cheap: single row by seq DESC).
529+
row = conn.execute(
530+
"SELECT seq FROM messages "
531+
"WHERE session_id = ? AND role = 'assistant' "
532+
"ORDER BY seq DESC LIMIT 1",
533+
(session_id,),
534+
).fetchone()
535+
if row:
536+
result["bot_seq"] = int(row[0])
537+
538+
# Latest visible user message: scan recent user rows and
539+
# skip pure tool_result entries.
540+
rows = conn.execute(
541+
"SELECT seq, content FROM messages "
542+
"WHERE session_id = ? AND role = 'user' "
543+
"ORDER BY seq DESC LIMIT 20",
544+
(session_id,),
545+
).fetchall()
546+
for seq, content_raw in rows:
547+
try:
548+
content = json.loads(content_raw)
549+
except Exception:
550+
result["user_seq"] = int(seq)
551+
break
552+
if isinstance(content, list):
553+
has_text = any(
554+
isinstance(b, dict) and b.get("type") == "text"
555+
for b in content
556+
)
557+
has_tool_result = any(
558+
isinstance(b, dict) and b.get("type") == "tool_result"
559+
for b in content
560+
)
561+
if has_text and not has_tool_result:
562+
result["user_seq"] = int(seq)
563+
break
564+
else:
565+
result["user_seq"] = int(seq)
566+
break
567+
finally:
568+
conn.close()
569+
return result
570+
512571
def clear_session(self, session_id: str) -> None:
513572
"""Delete all messages and the session record for a given session_id."""
514573
with self._lock:

bridge/agent_bridge.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,49 @@ def _init_agent_for_session(self, session_id: str):
383383
"""Initialize agent for a specific session"""
384384
agent = self.initializer.initialize_agent(session_id=session_id)
385385
self.agents[session_id] = agent
386-
386+
387+
def sync_session_messages_from_store(self, session_id: str) -> int:
388+
"""Reload an agent's in-memory ``messages`` list from the persistent
389+
conversation store.
390+
391+
Used after an external mutation (e.g. user edits / deletes a message
392+
via the web console) so the agent's next turn sees the same history
393+
as the database. The operation is a no-op when the agent has not been
394+
instantiated yet for the session.
395+
396+
Returns:
397+
Number of messages now held in the agent's memory. Returns -1 if
398+
the agent does not exist or has no compatible ``messages`` attr.
399+
"""
400+
if not session_id or session_id not in self.agents:
401+
return -1
402+
agent = self.agents[session_id]
403+
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
404+
return -1
405+
try:
406+
from agent.memory import get_conversation_store
407+
store = get_conversation_store()
408+
# No turn cap here: we want a faithful mirror of what the store
409+
# has for this session after deletion.
410+
remaining = store.load_messages(session_id, max_turns=10**6)
411+
except Exception as e:
412+
logger.warning(
413+
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
414+
)
415+
return -1
416+
with agent.messages_lock:
417+
agent.messages.clear()
418+
for msg in remaining:
419+
agent.messages.append({
420+
"role": msg["role"],
421+
"content": msg["content"],
422+
})
423+
count = len(agent.messages)
424+
logger.info(
425+
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
426+
)
427+
return count
428+
387429
def agent_reply(self, query: str, context: Context = None,
388430
on_event=None, clear_history: bool = False) -> Reply:
389431
"""

channel/web/static/css/console.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,11 @@
14761476
/* =====================================================================
14771477
Drag and Drop Overlay
14781478
===================================================================== */
1479+
/* Anchor the absolutely-positioned overlay to the chat view. */
1480+
#view-chat {
1481+
position: relative;
1482+
}
1483+
14791484
.drag-overlay {
14801485
position: absolute;
14811486
top: 0;
@@ -1554,8 +1559,7 @@
15541559

15551560
.user-message-group:hover .edit-msg-btn,
15561561
.user-message-group:hover .delete-msg-btn,
1557-
.flex.gap-3:hover .regenerate-msg-btn,
1558-
.flex.gap-3:hover .delete-msg-btn {
1562+
.bot-message-group:hover .regenerate-msg-btn {
15591563
opacity: 1;
15601564
}
15611565

channel/web/static/js/console.js

Lines changed: 75 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,68 +1180,40 @@ messagesDiv.addEventListener('click', (e) => {
11801180
return;
11811181
}
11821182

1183-
// Delete message
1183+
// Delete message (user bubble only; bot bubbles intentionally lack a
1184+
// delete button — removing only the bot reply would leave an orphan
1185+
// user message that breaks LLM context alternation).
11841186
const deleteBtn = e.target.closest('.delete-msg-btn');
11851187
if (deleteBtn) {
11861188
e.preventDefault();
11871189
const userMsgEl = deleteBtn.closest('.user-message-group');
1188-
const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)');
1189-
1190-
if (userMsgEl) {
1191-
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
1192-
let nextSibling = userMsgEl.nextElementSibling;
1193-
let botReplyEl = null;
1194-
while (nextSibling) {
1195-
if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) {
1196-
botReplyEl = nextSibling;
1197-
break;
1198-
}
1199-
nextSibling = nextSibling.nextElementSibling;
1200-
}
1201-
userMsgEl.remove();
1202-
if (botReplyEl) botReplyEl.remove();
1203-
1204-
const userSeq = userMsgEl.dataset.seq;
1205-
if (userSeq) {
1206-
fetch('/api/messages/delete', {
1207-
method: 'POST',
1208-
headers: { 'Content-Type': 'application/json' },
1209-
body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
1210-
}).then(r => r.json()).then(data => {
1211-
if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
1212-
}).catch(err => console.error('Failed to delete:', err));
1213-
}
1214-
});
1215-
} else if (botMsgEl) {
1216-
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
1217-
// Find the preceding user message to get its seq
1218-
let prevUserEl = botMsgEl.previousElementSibling;
1219-
while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) {
1220-
prevUserEl = prevUserEl.previousElementSibling;
1221-
}
1222-
1223-
// Delete from database (keep user message, only delete bot reply)
1224-
if (prevUserEl) {
1225-
const userSeq = prevUserEl.dataset.seq;
1226-
if (userSeq) {
1227-
fetch('/api/messages/delete', {
1228-
method: 'POST',
1229-
headers: { 'Content-Type': 'application/json' },
1230-
body: JSON.stringify({
1231-
session_id: sessionId,
1232-
user_seq: parseInt(userSeq),
1233-
delete_user: false
1234-
})
1235-
}).then(r => r.json()).then(data => {
1236-
if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`);
1237-
}).catch(err => console.error('Failed to delete bot reply:', err));
1238-
}
1190+
if (!userMsgEl) return;
1191+
1192+
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
1193+
// Find the next bot reply for this turn (skip non-message nodes).
1194+
let botReplyEl = null;
1195+
let sibling = userMsgEl.nextElementSibling;
1196+
while (sibling) {
1197+
if (sibling.classList && sibling.classList.contains('bot-message-group')) {
1198+
botReplyEl = sibling;
1199+
break;
12391200
}
1240-
1241-
// Remove from DOM
1242-
botMsgEl.remove();
1243-
});
1244-
}
1201+
sibling = sibling.nextElementSibling;
1202+
}
1203+
userMsgEl.remove();
1204+
if (botReplyEl) botReplyEl.remove();
1205+
1206+
const userSeq = userMsgEl.dataset.seq;
1207+
if (userSeq) {
1208+
fetch('/api/messages/delete', {
1209+
method: 'POST',
1210+
headers: { 'Content-Type': 'application/json' },
1211+
body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
1212+
}).then(r => r.json()).then(data => {
1213+
if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
1214+
}).catch(err => console.error('Failed to delete:', err));
1215+
}
1216+
});
12451217
return;
12461218
}
12471219

@@ -2028,17 +2000,25 @@ async function editUserMessage(msgEl) {
20282000
}
20292001
}
20302002

2031-
// Find all subsequent messages (this message and everything after it)
2003+
// Remove this message bubble and every later bubble that belongs to
2004+
// this or a subsequent turn. We mirror the backend cascade contract:
2005+
// anything with a data-seq >= current seq, plus any live SSE bubble
2006+
// that is still being streamed (no seq yet) after this point.
2007+
const currentSeqNum = userSeq ? parseInt(userSeq) : null;
20322008
const messagesToRemove = [];
20332009
let current = msgEl;
20342010
while (current) {
2035-
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) {
2036-
messagesToRemove.push(current);
2011+
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('bot-message-group'))) {
2012+
const seqAttr = current.dataset.seq;
2013+
if (seqAttr === undefined || seqAttr === '') {
2014+
// Live message without a persisted seq yet — treat as later.
2015+
messagesToRemove.push(current);
2016+
} else if (currentSeqNum === null || parseInt(seqAttr) >= currentSeqNum) {
2017+
messagesToRemove.push(current);
2018+
}
20372019
}
20382020
current = current.nextElementSibling;
20392021
}
2040-
2041-
// Remove all messages from this one onwards
20422022
messagesToRemove.forEach(el => {
20432023
if (el && el.parentNode) el.parentNode.removeChild(el);
20442024
});
@@ -2266,8 +2246,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
22662246
if (botEl) return;
22672247
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
22682248
botEl = document.createElement('div');
2269-
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
2249+
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
22702250
botEl.dataset.requestId = requestId;
2251+
// Regenerate button starts hidden; it's revealed in the "done"
2252+
// event handler once seq metadata arrives from the backend.
22712253
botEl.innerHTML = `
22722254
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
22732255
<div class="min-w-0 flex-1 max-w-[85%]">
@@ -2285,6 +2267,9 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
22852267
<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;">
22862268
<i class="fas fa-volume-up"></i>
22872269
</button>
2270+
<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;">
2271+
<i class="fas fa-rotate-right"></i>
2272+
</button>
22882273
</div>
22892274
</div>
22902275
`;
@@ -2534,6 +2519,29 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
25342519
if (copyBtn && finalText) copyBtn.style.display = '';
25352520
applyHighlighting(botEl);
25362521
}
2522+
2523+
// Backfill seq metadata so edit/regenerate buttons can call
2524+
// the delete API without a page refresh. Backend includes
2525+
// user_seq / bot_seq on the done event after persistence.
2526+
const targetBotEl = botEl || (requestId ? messagesDiv.querySelector(`[data-request-id="${requestId}"]`) : null);
2527+
if (targetBotEl) {
2528+
if (item.bot_seq !== undefined && item.bot_seq !== null) {
2529+
targetBotEl.dataset.seq = item.bot_seq;
2530+
}
2531+
// Reveal regenerate button now that the seq is wired up.
2532+
const regenBtn = targetBotEl.querySelector('.regenerate-msg-btn');
2533+
if (regenBtn) regenBtn.style.display = '';
2534+
if (item.user_seq !== undefined && item.user_seq !== null) {
2535+
// Locate the preceding user bubble for this turn.
2536+
let prev = targetBotEl.previousElementSibling;
2537+
while (prev && !prev.classList.contains('user-message-group')) {
2538+
prev = prev.previousElementSibling;
2539+
}
2540+
if (prev && !prev.dataset.seq) {
2541+
prev.dataset.seq = item.user_seq;
2542+
}
2543+
}
2544+
}
25372545
renderBotSpeakerButton(botEl, finalText);
25382546
scrollChatToBottom();
25392547

@@ -2857,7 +2865,7 @@ function localizeCancelMarker(text) {
28572865

28582866
function createBotMessageEl(content, timestamp, requestId, msg) {
28592867
const el = document.createElement('div');
2860-
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
2868+
el.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
28612869
if (requestId) el.dataset.requestId = requestId;
28622870

28632871
let stepsHtml = '';
@@ -2889,15 +2897,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) {
28892897
<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'}">
28902898
<i class="fas fa-copy"></i>
28912899
</button>
2892-
<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')}">
2893-
<i class="fas fa-rotate-right"></i>
2894-
</button>
2895-
<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')}">
2896-
<i class="fas fa-trash"></i>
2897-
</button>
28982900
<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;">
28992901
<i class="fas fa-volume-up"></i>
29002902
</button>
2903+
<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')}">
2904+
<i class="fas fa-rotate-right"></i>
2905+
</button>
29012906
</div>
29022907
</div>
29032908
`;

0 commit comments

Comments
 (0)