diff --git a/application/single_app/config.py b/application/single_app/config.py
index e4527b682..32e453797 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -96,7 +96,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.250.201"
+VERSION = "0.250.202"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/functions_chat_stream_events.py b/application/single_app/functions_chat_stream_events.py
new file mode 100644
index 000000000..859e3a80c
--- /dev/null
+++ b/application/single_app/functions_chat_stream_events.py
@@ -0,0 +1,37 @@
+# functions_chat_stream_events.py
+
+import json
+from typing import Any, Dict
+
+
+USER_MESSAGE_PERSISTED_EVENT_TYPE = "user_message_persisted"
+
+
+def build_user_message_persisted_stream_payload(
+ conversation_id: str,
+ user_message_id: str,
+) -> Dict[str, Any]:
+ """Build the SSE payload that acknowledges durable user-message storage."""
+ normalized_conversation_id = str(conversation_id or "").strip()
+ normalized_user_message_id = str(user_message_id or "").strip()
+ if not normalized_conversation_id or not normalized_user_message_id:
+ raise ValueError("conversation_id and user_message_id are required")
+
+ return {
+ "type": USER_MESSAGE_PERSISTED_EVENT_TYPE,
+ "conversation_id": normalized_conversation_id,
+ "user_message_id": normalized_user_message_id,
+ "message_persisted": True,
+ }
+
+
+def build_user_message_persisted_stream_event(
+ conversation_id: str,
+ user_message_id: str,
+) -> str:
+ """Serialize a user-message persistence acknowledgement as an SSE event."""
+ payload = build_user_message_persisted_stream_payload(
+ conversation_id,
+ user_message_id,
+ )
+ return f"data: {json.dumps(payload)}\n\n"
diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py
index f89d50ec1..8db8e6b2b 100644
--- a/application/single_app/route_backend_chats.py
+++ b/application/single_app/route_backend_chats.py
@@ -116,6 +116,7 @@
from functions_global_agents import get_global_agents
from functions_group_agents import get_group_agents
from functions_personal_agents import get_personal_agents
+from functions_chat_stream_events import build_user_message_persisted_stream_event
from functions_source_review import (
build_deep_research_ledger,
build_deep_research_ledger_markdown,
@@ -15020,6 +15021,13 @@ def execute_document_action_chat_request(
'metadata': user_metadata,
})
cosmos_messages_container.upsert_item(user_message_doc)
+ if callable(publish_background_event):
+ publish_background_event(
+ build_user_message_persisted_stream_event(
+ conversation_id,
+ user_message_id,
+ )
+ )
try:
document_action_activity_context = {
@@ -15880,6 +15888,11 @@ def generate_image_from_proposal():
@login_required
@user_required
def chat_api():
+ publish_background_event = getattr(
+ g,
+ 'chat_publish_background_event',
+ None,
+ )
try:
request_start_time = time.time()
settings = get_settings()
@@ -16932,6 +16945,13 @@ def result_requires_message_reload(result: Any) -> bool:
# Note: Message-level chat_type will be updated after document search
cosmos_messages_container.upsert_item(user_message_doc)
+ if callable(publish_background_event):
+ publish_background_event(
+ build_user_message_persisted_stream_event(
+ conversation_id,
+ user_message_id,
+ )
+ )
# Log chat activity for real-time tracking
try:
@@ -20099,7 +20119,7 @@ def normalize_legacy_chat_payload(payload):
'blocked': payload.get('blocked', False),
})
- def generate_compatibility_response():
+ def generate_compatibility_response(publish_background_event=None):
"""Bridge legacy JSON chat handling into a terminal SSE event for parity cases."""
try:
g.conversation_id = finalized_conversation_id
@@ -20122,6 +20142,7 @@ def generate_compatibility_response():
}
yield f"data: {json.dumps(image_request_event)}\n\n"
+ g.chat_publish_background_event = publish_background_event
legacy_result = chat_api()
legacy_response = legacy_result
status_code = 200
@@ -21205,6 +21226,10 @@ def build_streaming_capability_usage():
}
cosmos_messages_container.upsert_item(user_message_doc)
+ yield build_user_message_persisted_stream_event(
+ conversation_id,
+ user_message_id,
+ )
debug_print(
f"[STREAMING] Saved user message {user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}"
)
diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py
index 1c52e27d0..bf1f20af3 100644
--- a/application/single_app/route_backend_collaboration.py
+++ b/application/single_app/route_backend_collaboration.py
@@ -45,6 +45,10 @@
update_personal_collaboration_title,
)
from functions_conversation_cache import bump_conversation_cache_version
+from functions_chat_stream_events import (
+ USER_MESSAGE_PERSISTED_EVENT_TYPE,
+ build_user_message_persisted_stream_event,
+)
from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id, require_active_group
from functions_image_messages import decode_image_content, get_complete_image_content, is_blob_backed_image_message, is_external_image_url
from functions_message_masking import (
@@ -1449,6 +1453,10 @@ def stream_collaboration_message_api(conversation_id):
def generate_stream():
try:
+ yield build_user_message_persisted_stream_event(
+ conversation_id,
+ serialized_user_message.get('id'),
+ )
internal_stream_view = current_app.view_functions.get('chat_stream_api')
if not callable(internal_stream_view):
yield _serialize_stream_error(
@@ -1508,6 +1516,9 @@ def transform_event_block(event_block):
conversation_id=conversation_id,
)
+ if stream_payload.get('type') == USER_MESSAGE_PERSISTED_EVENT_TYPE:
+ return None
+
if not stream_payload.get('done'):
return normalized_event_block + '\n\n'
diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js
index 32666f30c..0a9f5a8c6 100644
--- a/application/single_app/static/js/chat/chat-collaboration.js
+++ b/application/single_app/static/js/chat/chat-collaboration.js
@@ -5,6 +5,7 @@ import {
getCollaborativeTagSuggestions,
getGeneratedImageProposalSourceMessageId,
groupGeneratedImageProposalMessages,
+ setUserMessageStreamingActionsDisabled,
updateSendButtonVisibility,
updateUserMessageId,
userInput,
@@ -1074,6 +1075,12 @@ function handleConversationEvent(eventEnvelope = {}) {
const decoratedMessage = decorateReplyMessage(payload.message);
cacheCollaborationMessage(payload.message);
if (reconcilePendingCollaborativeUserMessage(payload.message)) {
+ const messageKind = String(
+ payload.message.message_kind || payload.message.metadata?.message_kind || ''
+ ).trim();
+ if (senderUserId === getCurrentUserId() && messageKind !== 'ai_request') {
+ setUserMessageStreamingActionsDisabled(payload.message.id, false);
+ }
if (shouldClearNotifications) {
void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, {
suppressErrorToast: true,
@@ -1308,6 +1315,7 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) {
if (!reconcilePendingCollaborativeUserMessage(payload.message, tempMessageId)) {
renderCollaborationMessage(decorateReplyMessage(payload.message), { isNewMessage: true });
}
+ setUserMessageStreamingActionsDisabled(payload.message.id, false);
}
setTypingState(false, { force: true });
diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js
index c35488d27..e80fa8fc0 100644
--- a/application/single_app/static/js/chat/chat-messages.js
+++ b/application/single_app/static/js/chat/chat-messages.js
@@ -6489,6 +6489,9 @@ export function appendMessage(
// Add event listeners for user message buttons
if (sender === "You") {
attachUserMessageEventListeners(messageDiv, messageId, messageContent);
+ if (String(messageId || '').startsWith('temp_user_')) {
+ setUserMessageStreamingActionsDisabled(messageId, true);
+ }
// Apply masked state if message has masking
if (fullMessageObject?.metadata) {
@@ -7515,8 +7518,58 @@ if (promptSelect) {
updateDocumentActionControls();
+let userMetadataRequestSequence = 0;
+
+function renderUserMetadataStatus(container, message, className = 'text-muted') {
+ const status = document.createElement('div');
+ status.className = className;
+ status.textContent = message;
+ container.replaceChildren(status);
+}
+
+export function setUserMessageStreamingActionsDisabled(messageId, disabled) {
+ const normalizedMessageId = String(messageId || '').trim();
+ if (!normalizedMessageId) {
+ return false;
+ }
+
+ const messageDiv = document.querySelector(`[data-message-id="${normalizedMessageId}"]`);
+ if (!messageDiv) {
+ return false;
+ }
+
+ messageDiv.dataset.streamingActionsDisabled = disabled ? 'true' : 'false';
+ const mutatingActions = getUserMessageMutatingActions(normalizedMessageId);
+ mutatingActions.forEach(action => {
+ action.dataset.streamingDisabled = disabled ? 'true' : 'false';
+ action.classList.toggle('disabled', disabled);
+ action.setAttribute('aria-disabled', disabled ? 'true' : 'false');
+ if (disabled) {
+ action.setAttribute('tabindex', '-1');
+ } else {
+ action.removeAttribute('tabindex');
+ }
+ if (action instanceof HTMLButtonElement) {
+ action.disabled = disabled;
+ }
+ });
+ return mutatingActions.length > 0;
+}
+
+function getUserMessageMutatingActions(messageId) {
+ const normalizedMessageId = String(messageId || '').trim();
+ return Array.from(document.querySelectorAll(
+ '.dropdown-edit-btn, .dropdown-delete-btn, .dropdown-retry-btn, .mask-add-btn, .mask-remove-btn'
+ )).filter(action => action.getAttribute('data-message-id') === normalizedMessageId);
+}
+
+function isUserMessageStreamingActionDisabled(action) {
+ return action?.dataset.streamingDisabled === 'true';
+}
+
// Helper function to update user message ID after backend response
-export function updateUserMessageId(tempId, realId) {
+export function updateUserMessageId(tempId, realId, options = {}) {
+ const { refreshExpandedMetadata = false } = options;
console.log(`๐ Updating message ID: ${tempId} -> ${realId}`);
// Find the message with the temporary ID
@@ -7527,12 +7580,13 @@ export function updateUserMessageId(tempId, realId) {
console.log(`โ Updated messageDiv data-message-id to: ${realId}`);
// Update ALL elements with the temporary ID to ensure consistency
- const elementsToUpdate = [
+ const elementsToUpdate = new Set([
messageDiv.querySelector('.copy-user-btn'),
messageDiv.querySelector('.metadata-toggle-btn'),
...messageDiv.querySelectorAll(`[data-message-id="${tempId}"]`),
- ...messageDiv.querySelectorAll(`[aria-controls*="${tempId}"]`)
- ];
+ ...messageDiv.querySelectorAll(`[aria-controls*="${tempId}"]`),
+ ...getUserMessageMutatingActions(tempId)
+ ]);
let updateCount = 0;
elementsToUpdate.forEach(element => {
@@ -7565,6 +7619,13 @@ export function updateUserMessageId(tempId, realId) {
updateCount++;
}
+ if (['pending', 'unconfirmed'].includes(metadataContainer?.dataset.metadataState)) {
+ metadataContainer.dataset.metadataState = 'ready';
+ if (metadataContainer.style.display !== 'none') {
+ loadUserMessageMetadata(realId, metadataContainer);
+ }
+ }
+
console.log(`โ Updated ${updateCount} elements with new message ID`);
// Verify the update was successful
@@ -7578,12 +7639,74 @@ export function updateUserMessageId(tempId, realId) {
const existingRealMessageDiv = document.querySelector(`[data-message-id="${realId}"]`);
if (existingRealMessageDiv) {
console.info(`โน๏ธ Message div for temp ID ${tempId} was already reconciled to ${realId}`);
+ if (refreshExpandedMetadata) {
+ refreshUserMessageMetadata(realId);
+ }
} else {
console.warn(`โ ๏ธ Message div with temp ID ${tempId} not found for update`);
}
}
}
+export function refreshUserMessageMetadata(messageId) {
+ const normalizedMessageId = String(messageId || '').trim();
+ const messageDiv = normalizedMessageId
+ ? document.querySelector(`[data-message-id="${normalizedMessageId}"]`)
+ : null;
+ const metadataContainer = messageDiv?.querySelector('.metadata-container');
+ if (!metadataContainer) {
+ return false;
+ }
+
+ if (metadataContainer.style.display !== 'none') {
+ loadUserMessageMetadata(normalizedMessageId, metadataContainer);
+ } else {
+ metadataContainer.dataset.metadataState = 'stale';
+ metadataContainer.dataset.metadataRequestToken = '';
+ }
+ return true;
+}
+
+function markUserMessageMetadataState(messageId, metadataState, statusMessage) {
+ const normalizedMessageId = String(messageId || '').trim();
+ if (!normalizedMessageId) {
+ return false;
+ }
+
+ const messageDiv = document.querySelector(`[data-message-id="${normalizedMessageId}"]`);
+ const metadataContainer = messageDiv?.querySelector('.metadata-container');
+ if (!metadataContainer) {
+ return false;
+ }
+
+ metadataContainer.dataset.metadataState = metadataState;
+ metadataContainer.dataset.metadataRequestToken = '';
+ if (metadataContainer.style.display !== 'none') {
+ renderUserMetadataStatus(
+ metadataContainer,
+ statusMessage,
+ 'text-warning'
+ );
+ }
+ return true;
+}
+
+export function markUserMessageMetadataUnconfirmed(messageId) {
+ return markUserMessageMetadataState(
+ messageId,
+ 'unconfirmed',
+ 'Message metadata persistence could not be confirmed. Refresh the conversation to check.'
+ );
+}
+
+export function markUserMessageMetadataFinalizationUnconfirmed(messageId) {
+ return markUserMessageMetadataState(
+ messageId,
+ 'finalization-unconfirmed',
+ 'Message metadata may still be updating after the stream disconnected. Refresh the conversation to check.'
+ );
+}
+
// Helper function to attach event listeners to user message buttons
function attachUserMessageEventListeners(messageDiv, messageId, messageContent) {
const copyBtn = messageDiv.querySelector(".copy-user-btn");
@@ -7609,7 +7732,8 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent)
if (metadataToggleBtn) {
metadataToggleBtn.addEventListener("click", () => {
- toggleUserMessageMetadata(messageDiv, messageId);
+ const currentMessageId = messageDiv.getAttribute('data-message-id') || messageId;
+ toggleUserMessageMetadata(messageDiv, currentMessageId);
});
}
@@ -7619,6 +7743,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent)
if (dropdownDeleteBtn) {
dropdownDeleteBtn.addEventListener("click", (e) => {
e.preventDefault();
+ if (isUserMessageStreamingActionDisabled(e.currentTarget)) {
+ return;
+ }
// Always read the message ID from the DOM attribute dynamically
// This ensures we use the updated ID after updateUserMessageId is called
const currentMessageId = messageDiv.getAttribute('data-message-id');
@@ -7631,6 +7758,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent)
if (dropdownRetryBtn) {
dropdownRetryBtn.addEventListener("click", (e) => {
e.preventDefault();
+ if (isUserMessageStreamingActionDisabled(e.currentTarget)) {
+ return;
+ }
// Always read the message ID from the DOM attribute dynamically
const currentMessageId = messageDiv.getAttribute('data-message-id');
console.log(`๐ Retry button clicked - using message ID from DOM: ${currentMessageId}`);
@@ -7642,6 +7772,9 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent)
if (dropdownEditBtn) {
dropdownEditBtn.addEventListener("click", (e) => {
e.preventDefault();
+ if (isUserMessageStreamingActionDisabled(e.currentTarget)) {
+ return;
+ }
// Always read the message ID from the DOM attribute dynamically
const currentMessageId = messageDiv.getAttribute('data-message-id');
console.log(`โ๏ธ Edit button clicked - using message ID from DOM: ${currentMessageId}`);
@@ -7754,23 +7887,9 @@ function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject,
// Function to toggle user message metadata drawer
function toggleUserMessageMetadata(messageDiv, messageId) {
+ messageId = messageDiv.getAttribute('data-message-id') || messageId;
console.log(`๐ Toggling metadata for message: ${messageId}`);
- // Validate that we're not using a temporary ID
- if (messageId && messageId.startsWith('temp_user_')) {
- console.error(`โ Metadata toggle called with temporary ID: ${messageId}`);
- console.log(`๐ Checking if real ID is available in DOM...`);
-
- // Try to find the real ID from the message div
- const actualMessageId = messageDiv.getAttribute('data-message-id');
- if (actualMessageId && actualMessageId !== messageId && !actualMessageId.startsWith('temp_user_')) {
- console.log(`โ Found real ID in DOM: ${actualMessageId}, using that instead`);
- messageId = actualMessageId;
- } else {
- console.error(`โ No valid real ID found, metadata toggle may fail`);
- }
- }
-
const toggleBtn = messageDiv.querySelector('.metadata-toggle-btn');
const targetId = toggleBtn.getAttribute('aria-controls');
const metadataContainer = messageDiv.querySelector(`#${targetId}`);
@@ -7800,7 +7919,7 @@ function toggleUserMessageMetadata(messageDiv, messageId) {
toggleBtn.innerHTML = '';
// Load metadata if not already loaded
- if (metadataContainer.innerHTML.includes('Loading metadata...')) {
+ if (metadataContainer.dataset.metadataState !== 'loaded') {
console.log(`๐ Loading metadata content for ${messageId}`);
loadUserMessageMetadata(messageId, metadataContainer);
}
@@ -7821,36 +7940,61 @@ function toggleUserMessageMetadata(messageDiv, messageId) {
// Function to load user message metadata into the drawer
function loadUserMessageMetadata(messageId, container, retryCount = 0) {
+ const currentMessageId = container.closest('[data-message-id]')?.getAttribute('data-message-id');
+ if (
+ messageId?.startsWith('temp_user_')
+ && currentMessageId
+ && !currentMessageId.startsWith('temp_user_')
+ ) {
+ messageId = currentMessageId;
+ }
+
console.log(`๐ Loading metadata for message ID: ${messageId} (attempt ${retryCount + 1})`);
+ if (container.dataset.metadataState === 'unconfirmed') {
+ renderUserMetadataStatus(
+ container,
+ 'Message metadata persistence could not be confirmed. Refresh the conversation to check.',
+ 'text-warning'
+ );
+ return;
+ }
+
+ if (container.dataset.metadataState === 'finalization-unconfirmed') {
+ renderUserMetadataStatus(
+ container,
+ 'Message metadata may still be updating after the stream disconnected. Refresh the conversation to check.',
+ 'text-warning'
+ );
+ return;
+ }
+
// Validate message ID to catch temporary IDs early
if (!messageId || messageId === "null" || messageId === "undefined") {
console.error(`โ Invalid message ID: ${messageId}`);
- container.innerHTML = '
Message metadata not available.
';
+ container.dataset.metadataState = 'error';
+ renderUserMetadataStatus(container, 'Message metadata not available.');
return;
}
- // Check for temporary IDs which indicate a bug
+ // Wait for the persistence event instead of polling with a stale temporary ID.
if (messageId.startsWith('temp_user_')) {
- console.error(`โ Attempting to load metadata with temporary ID: ${messageId}`);
- console.error(`This indicates the updateUserMessageId function didn't work properly`);
-
- if (retryCount < 2) {
- // Short retry for temp IDs in case the real ID update is still in progress
- console.log(`๐ Retrying metadata load for temp ID in 100ms (attempt ${retryCount + 1}/3)`);
- setTimeout(() => {
- loadUserMessageMetadata(messageId, container, retryCount + 1);
- }, 100);
- return;
- } else {
- container.innerHTML = '
Message metadata unavailable (temporary ID not updated).