diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index a286efc4..23a81192 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -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: diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 22be33c0..e8424eb5 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -383,7 +383,49 @@ class AgentBridge: """Initialize agent for a specific session""" 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: """ diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 188093b3..e260bbc9 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -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; } diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 70ab97c7..31c8d271 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1180,68 +1180,40 @@ 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) { - 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; + if (!userMsgEl) return; + + showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => { + // Find the next bot reply for this turn (skip non-message nodes). + let botReplyEl = null; + let sibling = userMsgEl.nextElementSibling; + while (sibling) { + if (sibling.classList && sibling.classList.contains('bot-message-group')) { + botReplyEl = sibling; + break; } - 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(); - }); - } + sibling = sibling.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)); + } + }); 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'))) { - messagesToRemove.push(current); + 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 = ` CowAgent
@@ -2285,6 +2267,9 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { +
`; @@ -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) { - - + `; diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index ea95b60e..ddac97dd 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -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 @@ -3940,31 +3961,15 @@ class MessageDeleteHandler: 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 + + # 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: logger.error(f"[WebChannel] Message delete error: {e}")