mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
refactor(web-console): polish message actions on bubbles after #2865
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user