${initials}
-
-
${participant.name || 'Unknown User'}
+
+
${displayName}${statusBadgesHtml}
${participant.email || ''}
+ ${participantActions.length > 0 ? `
${participantActions.join('')}
` : ''}
`;
});
@@ -329,6 +579,67 @@ function renderParticipantsSection(participants) {
return html;
}
+function attachConversationDetailActions(metadata, conversationId) {
+ const addParticipantBtn = document.querySelector('[data-collaboration-action="add-participant"]');
+ const acceptInviteBtn = document.querySelector('[data-collaboration-action="accept-invite"]');
+ const declineInviteBtn = document.querySelector('[data-collaboration-action="decline-invite"]');
+ const removeParticipantButtons = document.querySelectorAll('[data-collaboration-action="remove-participant"]');
+ const roleButtons = document.querySelectorAll('[data-collaboration-action="toggle-participant-role"]');
+ const exportConversationBtn = document.querySelector('[data-conversation-action="export"]');
+ const deleteConversationBtn = document.querySelector('[data-conversation-action="delete"]');
+
+ if (addParticipantBtn) {
+ addParticipantBtn.addEventListener('click', () => {
+ window.chatCollaboration?.openParticipantPicker?.({ conversationId });
+ });
+ }
+
+ if (acceptInviteBtn) {
+ acceptInviteBtn.addEventListener('click', () => {
+ window.chatCollaboration?.respondToInvite?.(conversationId, 'accept');
+ });
+ }
+
+ if (declineInviteBtn) {
+ declineInviteBtn.addEventListener('click', () => {
+ window.chatCollaboration?.respondToInvite?.(conversationId, 'decline');
+ });
+ }
+
+ removeParticipantButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const memberUserId = button.getAttribute('data-member-user-id');
+ if (!memberUserId) {
+ return;
+ }
+ window.chatCollaboration?.removeParticipant?.(conversationId, memberUserId);
+ });
+ });
+
+ roleButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const memberUserId = button.getAttribute('data-member-user-id');
+ const nextRole = button.getAttribute('data-next-role');
+ if (!memberUserId || !nextRole) {
+ return;
+ }
+ window.chatCollaboration?.updateParticipantRole?.(conversationId, memberUserId, nextRole);
+ });
+ });
+
+ if (exportConversationBtn) {
+ exportConversationBtn.addEventListener('click', () => {
+ window.chatExport?.openExportWizard?.([conversationId], true);
+ });
+ }
+
+ if (deleteConversationBtn) {
+ deleteConversationBtn.addEventListener('click', () => {
+ window.chatConversations?.deleteConversation?.(conversationId);
+ });
+ }
+}
+
/**
* Load profile image for a participant
*/
@@ -751,3 +1062,20 @@ document.addEventListener('click', function(e) {
// Export functions for external use
window.showConversationDetails = showConversationDetails;
+window.hideConversationDetails = hideConversationDetails;
+
+function initializeConversationDetailsModal() {
+ const { modal } = getConversationDetailsModalElements();
+ if (!modal || modal.dataset.initialized === 'true') {
+ return;
+ }
+
+ modal.dataset.initialized = 'true';
+ modal.addEventListener('hidden.bs.modal', cleanupConversationDetailsModalState);
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializeConversationDetailsModal);
+} else {
+ initializeConversationDetailsModal();
+}
diff --git a/application/single_app/static/js/chat/chat-conversation-info-button.js b/application/single_app/static/js/chat/chat-conversation-info-button.js
index dfb5e0b7..07aa27a8 100644
--- a/application/single_app/static/js/chat/chat-conversation-info-button.js
+++ b/application/single_app/static/js/chat/chat-conversation-info-button.js
@@ -36,7 +36,7 @@ export function toggleConversationInfoButton(hasActiveConversation) {
const infoButton = document.getElementById('conversation-info-btn');
if (infoButton) {
- infoButton.style.display = hasActiveConversation ? 'inline-block' : 'none';
+ infoButton.classList.toggle('d-none', !hasActiveConversation);
}
}
@@ -53,3 +53,34 @@ export function showConversationInfoButton() {
export function hideConversationInfoButton() {
toggleConversationInfoButton(false);
}
+
+function buildWorkflowActivityUrl(conversationId, workflowId) {
+ if (!conversationId) {
+ return '';
+ }
+
+ const url = new URL('/workflow-activity', window.location.origin);
+ url.searchParams.set('conversationId', conversationId);
+ if (workflowId) {
+ url.searchParams.set('workflowId', workflowId);
+ }
+ return url.toString();
+}
+
+export function updateWorkflowActivityButton(conversationId, metadata = {}) {
+ const activityButton = document.getElementById('workflow-activity-btn');
+ if (!activityButton) {
+ return;
+ }
+
+ const workflowId = String(metadata.workflow_id || '').trim();
+ const isWorkflowConversation = String(metadata.chat_type || '').trim().toLowerCase() === 'workflow' || Boolean(workflowId);
+ const activityUrl = isWorkflowConversation ? buildWorkflowActivityUrl(conversationId, workflowId) : '';
+
+ activityButton.classList.toggle('d-none', !activityUrl);
+ activityButton.href = activityUrl || '#';
+}
+
+export function hideWorkflowActivityButton() {
+ updateWorkflowActivityButton('', {});
+}
diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js
index 560571e9..a566405a 100644
--- a/application/single_app/static/js/chat/chat-conversations.js
+++ b/application/single_app/static/js/chat/chat-conversations.js
@@ -9,7 +9,11 @@ import {
setActiveConversation as setSidebarActiveConversation,
setConversationUnreadState as setSidebarConversationUnreadState,
} from "./chat-sidebar-conversations.js";
-import { toggleConversationInfoButton } from "./chat-conversation-info-button.js";
+import {
+ hideWorkflowActivityButton,
+ toggleConversationInfoButton,
+ updateWorkflowActivityButton,
+} from "./chat-conversation-info-button.js";
import { restoreScopeLockState, resetScopeLock } from "./chat-documents.js";
import { loadUserSettings } from "./chat-layout.js";
import { setUserSetting } from "../agents_common.js";
@@ -23,6 +27,15 @@ const conversationsList = document.getElementById("conversations-list");
const currentConversationTitleEl = document.getElementById("current-conversation-title");
const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications");
const chatbox = document.getElementById("chatbox");
+const deleteConversationModalEl = document.getElementById("delete-conversation-modal");
+const deleteConversationMessageEl = document.getElementById("delete-conversation-message");
+const deleteConversationSharedWarningEl = document.getElementById("delete-conversation-shared-warning");
+const deleteConversationOwnerOptionsEl = document.getElementById("delete-conversation-owner-options");
+const deleteConversationTransferOptionEl = document.getElementById("delete-conversation-transfer-option");
+const deleteConversationOwnerSelectContainerEl = document.getElementById("delete-conversation-owner-select-container");
+const deleteConversationNewOwnerSelectEl = document.getElementById("delete-conversation-new-owner-select");
+const deleteConversationImpactNoteEl = document.getElementById("delete-conversation-impact-note");
+const confirmDeleteConversationBtn = document.getElementById("confirm-delete-conversation-btn");
// Track selected conversations
let selectedConversations = new Set();
@@ -94,6 +107,7 @@ let showQuickSearch = false; // Track if quick search input is visible
let quickSearchTerm = ""; // Current search term
let pendingConversationCreation = null; // Reuse a single in-flight create request
const markConversationReadRequests = new Map();
+let pendingDeleteConversationContext = null;
function createUnreadDotElement() {
const unreadDot = document.createElement("span");
@@ -226,16 +240,49 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) {
convoItem.setAttribute('data-conversation-title', updates.title);
const titleElement = convoItem.querySelector('.conversation-title');
if (titleElement) {
- const pinIcon = titleElement.querySelector('.bi-pin-angle');
+ const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true));
titleElement.innerHTML = '';
- if (pinIcon) {
- titleElement.appendChild(pinIcon);
- }
+ existingIcons.forEach(icon => titleElement.appendChild(icon));
titleElement.appendChild(document.createTextNode(updates.title));
titleElement.title = updates.title;
}
}
+ if (updates.conversation_kind) {
+ convoItem.dataset.conversationKind = updates.conversation_kind;
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) {
+ convoItem.dataset.membershipStatus = updates.membership_status;
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) {
+ convoItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) {
+ convoItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) {
+ convoItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) {
+ convoItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) {
+ convoItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) {
+ convoItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) {
+ convoItem.dataset.currentUserRole = updates.current_user_role || '';
+ }
+ if (Object.prototype.hasOwnProperty.call(updates, 'workflow_id')) {
+ if (updates.workflow_id) {
+ convoItem.dataset.workflowId = updates.workflow_id;
+ } else {
+ delete convoItem.dataset.workflowId;
+ }
+ }
+
if (Array.isArray(updates.classification)) {
convoItem.dataset.classifications = JSON.stringify(updates.classification);
}
@@ -252,6 +299,16 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) {
classification: updates.classification,
context: updates.context,
chat_type: updates.chat_type,
+ conversation_kind: updates.conversation_kind,
+ membership_status: updates.membership_status,
+ can_manage_members: updates.can_manage_members,
+ can_manage_roles: updates.can_manage_roles,
+ can_accept_invite: updates.can_accept_invite,
+ can_post_messages: updates.can_post_messages,
+ can_delete_conversation: updates.can_delete_conversation,
+ can_leave_conversation: updates.can_leave_conversation,
+ current_user_role: updates.current_user_role,
+ workflow_id: updates.workflow_id,
});
applySidebarConversationMetadataUpdate(conversationId, updates);
@@ -269,6 +326,10 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) {
}
renderConversationHeaderBadges(convoItem);
+ updateWorkflowActivityButton(conversationId, {
+ chat_type: updates.chat_type || convoItem.getAttribute('data-chat-type') || '',
+ workflow_id: updates.workflow_id || convoItem.dataset.workflowId || '',
+ });
if (hasContextUpdate) {
void refreshAgentsAndModelsForActiveConversation();
@@ -377,6 +438,21 @@ document.addEventListener('DOMContentLoaded', () => {
if (deleteSelectedBtn) {
deleteSelectedBtn.style.display = "none";
}
+
+ if (confirmDeleteConversationBtn) {
+ confirmDeleteConversationBtn.addEventListener('click', () => {
+ void executeDeleteConversationAction();
+ });
+ }
+
+ if (deleteConversationModalEl) {
+ deleteConversationModalEl.addEventListener('hidden.bs.modal', () => {
+ resetDeleteConversationModalState();
+ });
+ deleteConversationModalEl.querySelectorAll('input[name="delete-conversation-action"]').forEach(input => {
+ input.addEventListener('change', toggleDeleteConversationTransferInputs);
+ });
+ }
// Set up quick search event listeners
const searchBtn = document.getElementById('sidebar-search-btn');
@@ -582,7 +658,9 @@ function clearQuickSearch() {
loadConversations();
}
-export function loadConversations() {
+export function loadConversations(options = {}) {
+ const { syncSidebar = true } = options;
+
if (!conversationsList) return;
// Prevent concurrent loads
@@ -594,11 +672,24 @@ export function loadConversations() {
isLoadingConversations = true;
conversationsList.innerHTML = '
Loading conversations...
'; // Loading state
- return fetch("/api/get_conversations")
- .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err)))
- .then(data => {
+ const legacyConversationsRequest = fetch("/api/get_conversations")
+ .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err)));
+ const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList
+ ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => {
+ console.warn('Failed to load collaborative conversations:', error);
+ return [];
+ })
+ : Promise.resolve([]);
+
+ return Promise.all([legacyConversationsRequest, collaborationConversationsRequest])
+ .then(([data, collaborationConversations]) => {
conversationsList.innerHTML = ""; // Clear loading state
- if (!data.conversations || data.conversations.length === 0) {
+ const mergedConversations = [
+ ...(Array.isArray(data.conversations) ? data.conversations : []),
+ ...(Array.isArray(collaborationConversations) ? collaborationConversations : []),
+ ];
+
+ if (mergedConversations.length === 0) {
conversationsList.innerHTML = '
No conversations yet.
';
allConversations = [];
updateHiddenToggleButton();
@@ -606,7 +697,7 @@ export function loadConversations() {
}
// Store all conversations for client-side operations
- allConversations = data.conversations;
+ allConversations = mergedConversations;
// Sort conversations: pinned first (by last_updated), then unpinned (by last_updated)
const sortedConversations = [...allConversations].sort((a, b) => {
@@ -646,8 +737,8 @@ export function loadConversations() {
updateHiddenToggleButton();
// Also load sidebar conversations if the sidebar exists
- if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) {
- window.chatSidebarConversations.loadSidebarConversations();
+ if (syncSidebar && window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) {
+ window.chatSidebarConversations.loadSidebarConversations({ conversations: mergedConversations });
}
// Reset loading flag
@@ -671,18 +762,27 @@ export async function ensureConversationPresent(conversationId) {
if (existing) return existing;
// Fetch metadata to validate ownership and get details
+ let metadata = null;
const res = await fetch(`/api/conversations/${conversationId}/metadata`);
- if (!res.ok) {
+ if (res.ok) {
+ metadata = await res.json();
+ } else if (window.chatCollaboration?.fetchConversationMetadata) {
+ try {
+ metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId);
+ } catch (error) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(error.message || err.error || `Failed to load conversation ${conversationId}`);
+ }
+ } else {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to load conversation ${conversationId}`);
}
- const metadata = await res.json();
// Build a conversation object compatible with createConversationItem
const convo = {
id: conversationId,
title: metadata.title || 'Conversation',
- last_updated: metadata.last_updated || new Date().toISOString(),
+ last_updated: metadata.last_updated || metadata.updated_at || new Date().toISOString(),
classification: metadata.classification || [],
context: metadata.context || [],
chat_type: metadata.chat_type || null,
@@ -691,6 +791,15 @@ export async function ensureConversationPresent(conversationId) {
has_unread_assistant_response: metadata.has_unread_assistant_response || false,
last_unread_assistant_message_id: metadata.last_unread_assistant_message_id || null,
last_unread_assistant_at: metadata.last_unread_assistant_at || null,
+ conversation_kind: metadata.conversation_kind || null,
+ membership_status: metadata.membership_status || null,
+ can_manage_members: metadata.can_manage_members || false,
+ can_manage_roles: metadata.can_manage_roles || false,
+ can_accept_invite: metadata.can_accept_invite || false,
+ can_post_messages: metadata.can_post_messages !== false,
+ can_delete_conversation: metadata.can_delete_conversation || false,
+ can_leave_conversation: metadata.can_leave_conversation || false,
+ current_user_role: metadata.current_user_role || '',
};
// Keep allConversations in sync
@@ -712,7 +821,36 @@ export function createConversationItem(convo) {
convoItem.classList.add("list-group-item", "list-group-item-action", "conversation-item", "d-flex", "align-items-center"); // Use action class
convoItem.setAttribute("data-conversation-id", convo.id);
convoItem.setAttribute("data-conversation-title", convo.title); // Store title too
+ if (convo.workflow_id) {
+ convoItem.dataset.workflowId = convo.workflow_id;
+ }
convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false";
+ const isCollaborativeConversation = convo.conversation_kind === 'collaborative';
+ const conversationChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type;
+ const canManageMembers = isCollaborativeConversation
+ ? Boolean(convo.can_manage_members)
+ : ['personal_single_user', 'group-single-user'].includes(conversationChatType || '');
+ const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false;
+ const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles;
+ const canShowAddParticipants = ['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(conversationChatType || '')
+ && canManageMembers;
+ const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation);
+ const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation);
+ const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave';
+
+ if (isCollaborativeConversation) {
+ convoItem.dataset.conversationKind = 'collaborative';
+ }
+ if (convo.membership_status) {
+ convoItem.dataset.membershipStatus = convo.membership_status;
+ }
+ convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false';
+ convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false';
+ convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false';
+ convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true';
+ convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false';
+ convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false';
+ convoItem.dataset.currentUserRole = convo.current_user_role || '';
// *** Store classification data as stringified JSON ***
convoItem.dataset.classifications = JSON.stringify(convo.classification || []);
@@ -826,6 +964,13 @@ export function createConversationItem(convo) {
pinIcon.classList.add("bi", "bi-pin-angle", "me-1");
titleSpan.appendChild(pinIcon);
}
+
+ if (isCollaborativeConversation) {
+ const collaborationIcon = document.createElement("i");
+ collaborationIcon.classList.add("bi", "bi-people", "me-1");
+ collaborationIcon.title = "Collaborative conversation";
+ titleSpan.appendChild(collaborationIcon);
+ }
titleSpan.appendChild(document.createTextNode(convo.title));
titleSpan.title = convo.title; // Tooltip for full title
@@ -868,6 +1013,17 @@ export function createConversationItem(convo) {
detailsA.innerHTML = '
Details';
detailsLi.appendChild(detailsA);
+ let addParticipantsA = null;
+ let addParticipantsLi = null;
+ if (canShowAddParticipants) {
+ addParticipantsLi = document.createElement("li");
+ addParticipantsA = document.createElement("a");
+ addParticipantsA.classList.add("dropdown-item", "add-participants-btn");
+ addParticipantsA.href = "#";
+ addParticipantsA.innerHTML = '
Add participants';
+ addParticipantsLi.appendChild(addParticipantsA);
+ }
+
// Add Pin option
const pinLi = document.createElement("li");
const pinA = document.createElement("a");
@@ -915,17 +1071,32 @@ export function createConversationItem(convo) {
const deleteA = document.createElement("a");
deleteA.classList.add("dropdown-item", "delete-btn", "text-danger");
deleteA.href = "#";
- deleteA.innerHTML = '
Delete';
+ deleteA.innerHTML = `
${isCollaborativeConversation ? collaborativeDeleteLabel : 'Delete'}`;
deleteLi.appendChild(deleteA);
dropdownMenu.appendChild(detailsLi);
- dropdownMenu.appendChild(pinLi);
- dropdownMenu.appendChild(hideLi);
- dropdownMenu.appendChild(selectLi);
- dropdownMenu.appendChild(exportLi);
-
- dropdownMenu.appendChild(editLi);
- dropdownMenu.appendChild(deleteLi);
+ if (addParticipantsLi) {
+ dropdownMenu.appendChild(addParticipantsLi);
+ }
+ if (isCollaborativeConversation) {
+ dropdownMenu.appendChild(pinLi);
+ dropdownMenu.appendChild(hideLi);
+ dropdownMenu.appendChild(selectLi);
+ dropdownMenu.appendChild(exportLi);
+ if (canEditCollaborativeTitle) {
+ dropdownMenu.appendChild(editLi);
+ }
+ if (canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) {
+ dropdownMenu.appendChild(deleteLi);
+ }
+ } else {
+ dropdownMenu.appendChild(pinLi);
+ dropdownMenu.appendChild(hideLi);
+ dropdownMenu.appendChild(selectLi);
+ dropdownMenu.appendChild(exportLi);
+ dropdownMenu.appendChild(editLi);
+ dropdownMenu.appendChild(deleteLi);
+ }
rightDiv.appendChild(dropdownBtn);
rightDiv.appendChild(dropdownMenu);
@@ -951,6 +1122,15 @@ export function createConversationItem(convo) {
selectConversation(convo.id);
});
+ if (addParticipantsA) {
+ addParticipantsA.addEventListener("click", event => {
+ event.preventDefault();
+ event.stopPropagation();
+ closeDropdownMenu(dropdownBtn);
+ window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id });
+ });
+ }
+
editA.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
@@ -1124,7 +1304,13 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan,
const newSpan = document.createElement("span");
newSpan.classList.add("conversation-title", "text-truncate");
- newSpan.textContent = convo.title;
+ if (convoItem.dataset.conversationKind === 'collaborative') {
+ const collaborationIcon = document.createElement("i");
+ collaborationIcon.classList.add("bi", "bi-people", "me-1");
+ collaborationIcon.title = "Collaborative conversation";
+ newSpan.appendChild(collaborationIcon);
+ }
+ newSpan.appendChild(document.createTextNode(convo.title));
newSpan.title = convo.title; // Add tooltip back
input.replaceWith(newSpan); // Replace input with updated span
@@ -1137,7 +1323,10 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan,
}
export async function updateConversationTitle(conversationId, newTitle) {
- const response = await fetch(`/api/conversations/${conversationId}`, {
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)
+ || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`);
+ const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative';
+ const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle }),
@@ -1204,16 +1393,26 @@ export async function selectConversation(conversationId) {
if (chatbox) chatbox.innerHTML = '
Conversation not found.
';
highlightSelectedConversation(null); // Deselect all visually
toggleConversationInfoButton(false); // Hide the info button
+ hideWorkflowActivityButton();
return;
}
const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title
+ const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative';
+ let metadata = null;
// Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status
try {
- const response = await fetch(`/api/conversations/${conversationId}/metadata`);
- if (response.ok) {
- const metadata = await response.json();
+ if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) {
+ metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId);
+ } else {
+ const response = await fetch(`/api/conversations/${conversationId}/metadata`);
+ if (response.ok) {
+ metadata = await response.json();
+ }
+ }
+
+ if (metadata) {
// Update Header Title with pin icon and hidden status
if (currentConversationTitleEl) {
@@ -1324,6 +1523,21 @@ export async function selectConversation(conversationId) {
const metaScopeLocked = metadata.scope_locked !== undefined ? metadata.scope_locked : null;
const metaLockedContexts = metadata.locked_contexts || [];
restoreScopeLockState(metaScopeLocked, metaLockedContexts);
+
+ convoItem.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false';
+ convoItem.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false';
+ convoItem.dataset.canPostMessages = metadata.can_post_messages === false ? 'false' : 'true';
+ if (metadata.membership_status) {
+ convoItem.dataset.membershipStatus = metadata.membership_status;
+ }
+ if (metadata.conversation_kind) {
+ convoItem.dataset.conversationKind = metadata.conversation_kind;
+ }
+ if (metadata.workflow_id) {
+ convoItem.dataset.workflowId = metadata.workflow_id;
+ } else {
+ delete convoItem.dataset.workflowId;
+ }
}
} catch (error) {
console.warn('Failed to fetch conversation metadata:', error);
@@ -1335,20 +1549,29 @@ export async function selectConversation(conversationId) {
renderConversationHeaderBadges(convoItem);
}
- await loadMessages(conversationId);
- try {
- const streamingModule = await import('./chat-streaming.js');
- await streamingModule.reattachStreamingConversation(conversationId);
- } catch (error) {
- console.warn('Failed to reattach active stream for conversation:', error);
+ if (isCollaborativeConversation && window.chatCollaboration?.activateConversation) {
+ await window.chatCollaboration.activateConversation(conversationId, metadata);
+ } else {
+ window.chatCollaboration?.deactivateConversation?.();
+ await loadMessages(conversationId);
+ try {
+ const streamingModule = await import('./chat-streaming.js');
+ await streamingModule.reattachStreamingConversation(conversationId);
+ } catch (error) {
+ console.warn('Failed to reattach active stream for conversation:', error);
+ }
+ markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => {
+ console.warn('Failed to clear unread state for conversation:', error);
+ });
}
- markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => {
- console.warn('Failed to clear unread state for conversation:', error);
- });
highlightSelectedConversation(conversationId);
// Show the conversation info button since we have an active conversation
toggleConversationInfoButton(true);
+ updateWorkflowActivityButton(conversationId, {
+ chat_type: metadata?.chat_type || convoItem.getAttribute('data-chat-type') || '',
+ workflow_id: metadata?.workflow_id || convoItem.dataset.workflowId || '',
+ });
// Update sidebar active conversation if sidebar exists
if (setSidebarActiveConversation) {
@@ -1388,45 +1611,261 @@ export function highlightSelectedConversation(conversationId) {
});
}
-// Delete a conversation
-export function deleteConversation(conversationId) {
- if (!confirm("Are you sure you want to delete this conversation? This action cannot be undone.")) {
+function getConversationFromCache(conversationId) {
+ return allConversations.find(conversation => conversation.id === conversationId) || null;
+}
+
+function toggleDeleteConversationTransferInputs() {
+ if (!deleteConversationOwnerSelectContainerEl || !deleteConversationModalEl || !confirmDeleteConversationBtn) {
return;
}
- // Optionally show loading state on the item being deleted
+ const selectedAction = deleteConversationModalEl.querySelector('input[name="delete-conversation-action"]:checked')?.value;
+ deleteConversationOwnerSelectContainerEl.classList.toggle('d-none', selectedAction !== 'leave');
- fetch(`/api/conversations/${conversationId}`, { method: "DELETE" })
- .then(response => {
- if (response.ok) {
- const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`);
- if (convoItem) convoItem.remove();
-
- // If the deleted conversation was the current one, reset the chat view
- if (currentConversationId === conversationId) {
- currentConversationId = null;
- if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation";
- if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; // Clear classifications
- if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
'; // Reset chatbox
- highlightSelectedConversation(null); // Deselect all
- toggleConversationInfoButton(false); // Hide the info button
- }
-
- // Also reload sidebar conversations if the sidebar exists
- if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) {
- window.chatSidebarConversations.loadSidebarConversations();
+ if (selectedAction === 'leave') {
+ confirmDeleteConversationBtn.classList.remove('btn-danger');
+ confirmDeleteConversationBtn.classList.add('btn-warning');
+ confirmDeleteConversationBtn.innerHTML = '
Assign Owner & Leave';
+ return;
+ }
+
+ confirmDeleteConversationBtn.classList.remove('btn-warning');
+ confirmDeleteConversationBtn.classList.add('btn-danger');
+ confirmDeleteConversationBtn.innerHTML = '
Delete Conversation';
+}
+
+function resetDeleteConversationModalState() {
+ pendingDeleteConversationContext = null;
+
+ if (deleteConversationMessageEl) {
+ deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?';
+ }
+ if (deleteConversationSharedWarningEl) {
+ deleteConversationSharedWarningEl.classList.add('d-none');
+ }
+ if (deleteConversationOwnerOptionsEl) {
+ deleteConversationOwnerOptionsEl.classList.add('d-none');
+ }
+ if (deleteConversationTransferOptionEl) {
+ deleteConversationTransferOptionEl.classList.add('d-none');
+ }
+ if (deleteConversationOwnerSelectContainerEl) {
+ deleteConversationOwnerSelectContainerEl.classList.add('d-none');
+ }
+ if (deleteConversationNewOwnerSelectEl) {
+ deleteConversationNewOwnerSelectEl.innerHTML = '';
+ }
+ if (deleteConversationImpactNoteEl) {
+ deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.';
+ }
+ if (confirmDeleteConversationBtn) {
+ confirmDeleteConversationBtn.disabled = false;
+ confirmDeleteConversationBtn.innerHTML = '
Delete Conversation';
+ confirmDeleteConversationBtn.classList.remove('btn-warning');
+ confirmDeleteConversationBtn.classList.add('btn-danger');
+ }
+
+ const deleteRadio = document.getElementById('delete-conversation-action-delete');
+ if (deleteRadio) {
+ deleteRadio.checked = true;
+ }
+}
+
+async function buildDeleteConversationContext(conversationId) {
+ const cachedConversation = getConversationFromCache(conversationId) || {};
+ const isCollaborativeConversation = cachedConversation.conversation_kind === 'collaborative'
+ || document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative'
+ || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative';
+
+ if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) {
+ return window.chatCollaboration.fetchConversationMetadata(conversationId);
+ }
+
+ const response = await fetch(`/api/conversations/${conversationId}/metadata`);
+ if (!response.ok) {
+ const errorPayload = await response.json().catch(() => ({}));
+ throw new Error(errorPayload.error || 'Failed to load conversation metadata');
+ }
+ return response.json();
+}
+
+function configureDeleteConversationModal(conversationId, metadata = {}) {
+ resetDeleteConversationModalState();
+
+ const isCollaborativeConversation = metadata.conversation_kind === 'collaborative';
+ const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || '').trim();
+ const activeParticipants = Array.isArray(metadata.participants)
+ ? metadata.participants.filter(participant => participant?.status === 'accepted')
+ : [];
+ const transferableParticipants = activeParticipants.filter(participant => participant?.user_id && participant.user_id !== currentUserId);
+
+ pendingDeleteConversationContext = {
+ conversationId,
+ isCollaborativeConversation,
+ metadata,
+ transferableParticipants,
+ };
+
+ if (!isCollaborativeConversation) {
+ if (deleteConversationMessageEl) {
+ deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?';
+ }
+ if (deleteConversationImpactNoteEl) {
+ deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.';
+ }
+ if (confirmDeleteConversationBtn) {
+ confirmDeleteConversationBtn.innerHTML = '
Delete Conversation';
+ }
+ return;
+ }
+
+ if (deleteConversationSharedWarningEl) {
+ deleteConversationSharedWarningEl.classList.remove('d-none');
+ }
+
+ if (metadata.can_delete_conversation) {
+ if (deleteConversationMessageEl) {
+ deleteConversationMessageEl.textContent = 'This is a multi-user conversation. Do you want to delete it for everyone, or assign another owner and leave the conversation?';
+ }
+ if (deleteConversationOwnerOptionsEl) {
+ deleteConversationOwnerOptionsEl.classList.remove('d-none');
+ }
+ if (deleteConversationImpactNoteEl) {
+ deleteConversationImpactNoteEl.textContent = 'Deleting removes the shared conversation for every participant.';
+ }
+ if (deleteConversationTransferOptionEl && transferableParticipants.length > 0) {
+ deleteConversationTransferOptionEl.classList.remove('d-none');
+ }
+ if (deleteConversationNewOwnerSelectEl) {
+ deleteConversationNewOwnerSelectEl.innerHTML = transferableParticipants
+ .map(participant => `
${participant.display_name || participant.email || participant.user_id} `)
+ .join('');
+ }
+ return;
+ }
+
+ if (deleteConversationMessageEl) {
+ deleteConversationMessageEl.textContent = 'Are you sure you want to leave this shared conversation?';
+ }
+ if (deleteConversationImpactNoteEl) {
+ deleteConversationImpactNoteEl.textContent = 'Leaving removes this conversation from your list, but other participants will keep it.';
+ }
+ if (confirmDeleteConversationBtn) {
+ confirmDeleteConversationBtn.classList.remove('btn-danger');
+ confirmDeleteConversationBtn.classList.add('btn-warning');
+ confirmDeleteConversationBtn.innerHTML = '
Leave Conversation';
+ }
+}
+
+export function removeConversationFromUi(conversationId, options = {}) {
+ if (!conversationId) {
+ return;
+ }
+
+ allConversations = allConversations.filter(conversation => conversation.id !== conversationId);
+ document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.remove();
+ document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.remove();
+
+ if (currentConversationId === conversationId || window.currentConversationId === conversationId) {
+ currentConversationId = null;
+ window.currentConversationId = null;
+ if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation";
+ if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = "";
+ if (chatbox) {
+ chatbox.innerHTML = '
Select a conversation to view messages.
';
+ }
+ highlightSelectedConversation(null);
+ toggleConversationInfoButton(false);
+ hideWorkflowActivityButton();
+ window.chatCollaboration?.deactivateConversation?.();
+ setSidebarActiveConversation(null);
+ }
+
+ window.hideConversationDetails?.();
+
+ if (!options.skipToast) {
+ showToast(options.toastMessage || 'Conversation removed.', 'success');
+ }
+
+ if (options.refreshList !== false) {
+ loadConversations();
+ }
+}
+
+async function executeDeleteConversationAction() {
+ if (!pendingDeleteConversationContext || !confirmDeleteConversationBtn) {
+ return;
+ }
+
+ const { conversationId, isCollaborativeConversation, metadata } = pendingDeleteConversationContext;
+ confirmDeleteConversationBtn.disabled = true;
+
+ try {
+ if (!isCollaborativeConversation) {
+ const response = await fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' });
+ if (!response.ok) {
+ const errorPayload = await response.json().catch(() => ({}));
+ throw new Error(errorPayload.error || 'Failed to delete conversation');
+ }
+
+ bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide();
+ removeConversationFromUi(conversationId, { toastMessage: 'Conversation deleted.' });
+ return;
+ }
+
+ let action = metadata.can_delete_conversation ? 'delete' : 'leave';
+ let newOwnerUserId = null;
+ if (metadata.can_delete_conversation) {
+ action = deleteConversationModalEl?.querySelector('input[name="delete-conversation-action"]:checked')?.value || 'delete';
+ if (action === 'leave') {
+ newOwnerUserId = deleteConversationNewOwnerSelectEl?.value || null;
+ if (!newOwnerUserId) {
+ throw new Error('Choose a new owner before leaving the shared conversation.');
}
-
- showToast("Conversation deleted.", "success");
- } else {
- return response.json().then(err => Promise.reject(err)); // Pass error details
}
- })
- .catch(error => {
- console.error("Error deleting conversation:", error);
- showToast(`Error deleting conversation: ${error.error || 'Unknown error'}`, "danger");
- // Re-enable button if loading state was shown
+ }
+
+ const response = await fetch(`/api/collaboration/conversations/${conversationId}/delete-action`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ action,
+ new_owner_user_id: newOwnerUserId,
+ }),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(payload.error || 'Failed to update the shared conversation');
+ }
+
+ bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide();
+ removeConversationFromUi(conversationId, {
+ toastMessage: action === 'delete' ? 'Shared conversation deleted for all participants.' : 'You left the shared conversation.',
});
+ } catch (error) {
+ showToast(error.message || 'Failed to update the conversation.', 'danger');
+ } finally {
+ confirmDeleteConversationBtn.disabled = false;
+ }
+}
+
+// Delete a conversation
+export async function deleteConversation(conversationId) {
+ if (!conversationId || !deleteConversationModalEl) {
+ return;
+ }
+
+ try {
+ const metadata = await buildDeleteConversationContext(conversationId);
+ configureDeleteConversationModal(conversationId, metadata);
+ bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl).show();
+ } catch (error) {
+ showToast(error.message || 'Failed to prepare the delete dialog.', 'danger');
+ }
}
// Create a new conversation via API
@@ -1686,6 +2125,7 @@ async function deleteSelectedConversations() {
if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
';
highlightSelectedConversation(null);
toggleConversationInfoButton(false); // Hide the info button
+ hideWorkflowActivityButton();
}
});
@@ -1711,7 +2151,10 @@ async function deleteSelectedConversations() {
// Toggle conversation pin status
async function toggleConversationPin(conversationId) {
try {
- const response = await fetch(`/api/conversations/${conversationId}/pin`, {
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)
+ || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`);
+ const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative';
+ const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/pin` : `/api/conversations/${conversationId}/pin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1743,7 +2186,10 @@ async function toggleConversationPin(conversationId) {
// Toggle conversation hide status
async function toggleConversationHide(conversationId) {
try {
- const response = await fetch(`/api/conversations/${conversationId}/hide`, {
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)
+ || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`);
+ const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative';
+ const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/hide` : `/api/conversations/${conversationId}/hide`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1882,7 +2328,7 @@ export function setShowHiddenConversations(value) {
updateHiddenToggleButton();
- if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) {
+ if (syncSidebar && window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) {
window.chatSidebarConversations.loadSidebarConversations();
}
} else {
@@ -1900,6 +2346,7 @@ window.chatConversations = {
markConversationRead,
setConversationUnreadState,
deleteConversation,
+ removeConversationFromUi,
toggleConversationSelection,
deleteSelectedConversations,
bulkPinConversations,
@@ -2016,6 +2463,13 @@ function addChatTypeBadges(convoItem, classificationsEl) {
// Don't show badges for Model-only conversations
if (chatType === 'personal' || chatType === 'personal_single_user') {
return;
+ } else if (chatType === 'personal_multi_user') {
+ const sharedBadge = document.createElement("span");
+ sharedBadge.classList.add("badge", "bg-primary-subtle", "text-primary-emphasis");
+ sharedBadge.textContent = 'shared';
+
+ appendBadgeSpacer();
+ classificationsEl.appendChild(sharedBadge);
} else if (chatType && chatType.startsWith('group')) {
// Group workspace was used
const groupBadge = document.createElement("span");
diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js
index d0792db7..f4b78aa3 100644
--- a/application/single_app/static/js/chat/chat-documents.js
+++ b/application/single_app/static/js/chat/chat-documents.js
@@ -160,8 +160,10 @@ export async function toggleScopeLock(conversationId, newState) {
updateHeaderLockIcon();
- // Reload docs for the new scope
- loadAllDocs().then(() => { loadTagsForScope(); });
+ // Reload scope-dependent UI and notify listeners like the agent picker.
+ runScopeRefreshPipeline('scope-lock').catch(error => {
+ console.error('Failed to refresh scope-dependent UI after toggling scope lock:', error);
+ });
}
/**
@@ -1703,6 +1705,11 @@ export function handleDocumentSelectChange() {
// Sync button text from current hidden select state
syncDropdownButtonText();
+ window.dispatchEvent(new CustomEvent('chat:document-selection-changed', {
+ detail: {
+ documentIds: Array.from(docSelectEl.selectedOptions).map(option => option.value).filter(Boolean),
+ },
+ }));
}
diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js
index f42e0123..03d5f20a 100644
--- a/application/single_app/static/js/chat/chat-export.js
+++ b/application/single_app/static/js/chat/chat-export.js
@@ -114,10 +114,25 @@ function prevStep() {
async function _loadConversationTitles() {
try {
- const response = await fetch('/api/get_conversations');
- if (!response.ok) throw new Error('Failed to fetch conversations');
- const data = await response.json();
- const conversations = data.conversations || [];
+ const legacyConversationsRequest = fetch('/api/get_conversations')
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to fetch conversations');
+ }
+ return response.json();
+ });
+ const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList
+ ? window.chatCollaboration.fetchCollaborationConversationList().catch(() => [])
+ : Promise.resolve([]);
+
+ const [legacyData, collaborationConversations] = await Promise.all([
+ legacyConversationsRequest,
+ collaborationConversationsRequest,
+ ]);
+ const conversations = [
+ ...(legacyData?.conversations || []),
+ ...(Array.isArray(collaborationConversations) ? collaborationConversations : []),
+ ];
exportConversationTitles = {};
conversations.forEach(c => {
if (exportConversationIds.includes(c.id)) {
diff --git a/application/single_app/static/js/chat/chat-inline-charts.js b/application/single_app/static/js/chat/chat-inline-charts.js
new file mode 100644
index 00000000..2bd050eb
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-inline-charts.js
@@ -0,0 +1,441 @@
+// chat-inline-charts.js
+
+const INLINE_CHART_LANGUAGE = 'simplechart';
+const INLINE_CHART_REGEX = new RegExp(`\`\`\`${INLINE_CHART_LANGUAGE}\\s*([\\s\\S]*?)\`\`\``, 'gi');
+const ALLOWED_KINDS = new Set(['line', 'bar', 'pie', 'doughnut', 'scatter', 'area', 'bubble', 'radar', 'stacked_bar', 'stacked_line', 'polar_area']);
+const DEFAULT_PALETTE = [
+ { background: 'rgba(28, 110, 164, 0.18)', border: '#1c6ea4' },
+ { background: 'rgba(215, 91, 53, 0.18)', border: '#d75b35' },
+ { background: 'rgba(39, 123, 84, 0.18)', border: '#277b54' },
+ { background: 'rgba(153, 92, 32, 0.18)', border: '#995c20' },
+ { background: 'rgba(126, 77, 140, 0.18)', border: '#7e4d8c' },
+ { background: 'rgba(191, 66, 112, 0.18)', border: '#bf4270' },
+ { background: 'rgba(58, 141, 121, 0.18)', border: '#3a8d79' },
+ { background: 'rgba(101, 120, 48, 0.18)', border: '#657830' }
+];
+
+function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>"']/g, character => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]
+ ));
+}
+
+function getPalette(index) {
+ return DEFAULT_PALETTE[index % DEFAULT_PALETTE.length];
+}
+
+function sanitizeText(value, maxLength = 240) {
+ return String(value ?? '').trim().slice(0, maxLength);
+}
+
+function sanitizeNumber(value) {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function sanitizeColor(value, fallback) {
+ if (typeof value !== 'string') {
+ return fallback;
+ }
+
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > 40) {
+ return fallback;
+ }
+
+ if (trimmed.startsWith('#') || trimmed.startsWith('rgb(') || trimmed.startsWith('rgba(') || trimmed.startsWith('hsl(') || trimmed.startsWith('hsla(')) {
+ return trimmed;
+ }
+
+ return fallback;
+}
+
+function getBaseChartType(kind) {
+ if (kind === 'area' || kind === 'stacked_line') {
+ return 'line';
+ }
+ if (kind === 'stacked_bar') {
+ return 'bar';
+ }
+ if (kind === 'polar_area') {
+ return 'polarArea';
+ }
+ return kind;
+}
+
+function normalizePoint(point, kind) {
+ if (!point || typeof point !== 'object') {
+ return null;
+ }
+
+ const normalized = {
+ x: sanitizeNumber(point.x),
+ y: sanitizeNumber(point.y)
+ };
+
+ if (normalized.x === null || normalized.y === null) {
+ return null;
+ }
+
+ if (kind === 'bubble') {
+ normalized.r = sanitizeNumber(point.r);
+ if (normalized.r === null) {
+ return null;
+ }
+ }
+
+ return normalized;
+}
+
+function normalizeDatasets(kind, rawDatasets, labels) {
+ if (!Array.isArray(rawDatasets) || rawDatasets.length === 0) {
+ return [];
+ }
+
+ return rawDatasets.slice(0, 20).map((dataset, datasetIndex) => {
+ const palette = getPalette(datasetIndex);
+ const normalized = {
+ label: sanitizeText(dataset?.label || `Series ${datasetIndex + 1}`, 80),
+ borderColor: sanitizeColor(dataset?.borderColor, palette.border),
+ backgroundColor: sanitizeColor(dataset?.backgroundColor, palette.background),
+ borderWidth: 2
+ };
+
+ if (kind === 'scatter' || kind === 'bubble') {
+ normalized.data = Array.isArray(dataset?.data)
+ ? dataset.data.map(point => normalizePoint(point, kind)).filter(Boolean)
+ : [];
+ } else {
+ normalized.data = Array.isArray(dataset?.data)
+ ? dataset.data.slice(0, 200).map(value => sanitizeNumber(value))
+ : [];
+ }
+
+ if (kind === 'line' || kind === 'area' || kind === 'stacked_line') {
+ normalized.fill = dataset?.fill === true || kind === 'area';
+ normalized.tension = dataset?.tension === 0 ? 0 : 0.35;
+ }
+
+ if (kind === 'radar') {
+ normalized.fill = dataset?.fill === true;
+ }
+
+ if ((kind === 'pie' || kind === 'doughnut' || kind === 'polar_area') && Array.isArray(labels) && labels.length) {
+ normalized.backgroundColor = labels.map((_, colorIndex) => getPalette(colorIndex).background);
+ normalized.borderColor = labels.map((_, colorIndex) => getPalette(colorIndex).border);
+ }
+
+ if (dataset?.type === 'line' || dataset?.type === 'bar') {
+ normalized.type = dataset.type;
+ }
+
+ return normalized;
+ }).filter(dataset => Array.isArray(dataset.data) && dataset.data.length > 0);
+}
+
+function normalizeTable(rawTable) {
+ if (!rawTable || typeof rawTable !== 'object') {
+ return null;
+ }
+
+ const columns = Array.isArray(rawTable.columns)
+ ? rawTable.columns.slice(0, 12).map(column => sanitizeText(column, 80)).filter(Boolean)
+ : [];
+ const rows = Array.isArray(rawTable.rows)
+ ? rawTable.rows.slice(0, 500).map(row => Array.isArray(row) ? row.slice(0, columns.length || 12) : []).filter(row => row.length > 0)
+ : [];
+
+ if (!columns.length || !rows.length) {
+ return null;
+ }
+
+ return { columns, rows };
+}
+
+function normalizeChartSpec(rawSpec) {
+ if (!rawSpec || typeof rawSpec !== 'object' || Array.isArray(rawSpec)) {
+ return null;
+ }
+
+ const kind = sanitizeText(rawSpec.kind || rawSpec.chartType, 40).toLowerCase();
+ if (!ALLOWED_KINDS.has(kind)) {
+ return null;
+ }
+
+ const rawData = rawSpec.data;
+ if (!rawData || typeof rawData !== 'object' || Array.isArray(rawData)) {
+ return null;
+ }
+
+ const labels = Array.isArray(rawData.labels)
+ ? rawData.labels.slice(0, 200).map(label => sanitizeText(label, 80))
+ : [];
+ const datasets = normalizeDatasets(kind, rawData.datasets, labels);
+ if (!datasets.length) {
+ return null;
+ }
+
+ const rawOptions = rawSpec.options && typeof rawSpec.options === 'object' && !Array.isArray(rawSpec.options)
+ ? rawSpec.options
+ : {};
+
+ const legendPosition = sanitizeText(rawOptions.legendPosition || 'top', 10).toLowerCase();
+ const normalizedOptions = {
+ legendPosition: ['top', 'bottom', 'left', 'right'].includes(legendPosition) ? legendPosition : 'top',
+ showLegend: rawOptions.showLegend !== false,
+ showDataTable: rawOptions.showDataTable !== false,
+ beginAtZero: rawOptions.beginAtZero !== false,
+ horizontal: Boolean(rawOptions.horizontal) && (kind === 'bar' || kind === 'stacked_bar'),
+ fill: Boolean(rawOptions.fill) || kind === 'area',
+ smooth: rawOptions.smooth !== false,
+ stacked: Boolean(rawOptions.stacked) || kind === 'stacked_bar' || kind === 'stacked_line',
+ xAxisLabel: sanitizeText(rawOptions.xAxisLabel, 80),
+ yAxisLabel: sanitizeText(rawOptions.yAxisLabel, 80),
+ cutout: sanitizeText(rawOptions.cutout || '60%', 20)
+ };
+
+ return {
+ version: Number(rawSpec.version) || 1,
+ chartId: sanitizeText(rawSpec.chartId || '', 40),
+ kind,
+ chartType: getBaseChartType(kind),
+ title: sanitizeText(rawSpec.title, 160),
+ subtitle: sanitizeText(rawSpec.subtitle, 160),
+ description: sanitizeText(rawSpec.description, 320),
+ summary: sanitizeText(rawSpec.summary, 220),
+ data: {
+ labels,
+ datasets
+ },
+ options: normalizedOptions,
+ table: normalizeTable(rawSpec.table)
+ };
+}
+
+function buildTableHtml(spec) {
+ if (!spec.table || spec.options.showDataTable === false) {
+ return '';
+ }
+
+ const tableId = `chart-table-${spec.chartId || Math.random().toString(36).slice(2, 10)}`;
+ const headHtml = spec.table.columns.map(column => `
${escapeHtml(column)} `).join('');
+ const bodyHtml = spec.table.rows.map(row => `
+
${row.map(cell => `${escapeHtml(cell ?? '')} `).join('')}
+ `).join('');
+
+ return `
+
+
+ Show data table
+
+
+
+ ${headHtml}
+ ${bodyHtml}
+
+
+
+ `;
+}
+
+function buildPlaceholderHtml(block, index) {
+ const encodedSpec = encodeURIComponent(JSON.stringify(block.spec));
+ const captionParts = [block.spec.description, block.spec.summary].filter(Boolean);
+ const captionHtml = captionParts.length
+ ? `
${escapeHtml(captionParts.join(' '))}
`
+ : '';
+
+ return `
+
+
+
+ ${block.spec.title ? `
${escapeHtml(block.spec.title)}
` : ''}
+ ${block.spec.subtitle ? `
${escapeHtml(block.spec.subtitle)}
` : ''}
+
+
+
+
+ ${captionHtml}
+ ${buildTableHtml(block.spec)}
+
+
+ `;
+}
+
+function replaceAllOccurrences(source, target, replacement) {
+ return source.split(target).join(replacement);
+}
+
+function buildChartJsConfig(spec) {
+ const baseType = getBaseChartType(spec.kind);
+ const config = {
+ type: baseType,
+ data: {
+ datasets: spec.data.datasets.map(dataset => ({ ...dataset }))
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'nearest',
+ intersect: false
+ },
+ plugins: {
+ legend: {
+ display: spec.options.showLegend,
+ position: spec.options.legendPosition
+ },
+ title: {
+ display: Boolean(spec.title),
+ text: spec.title
+ },
+ subtitle: {
+ display: Boolean(spec.subtitle),
+ text: spec.subtitle
+ }
+ }
+ }
+ };
+
+ if (spec.data.labels.length) {
+ config.data.labels = [...spec.data.labels];
+ }
+
+ if (['bar', 'line', 'scatter', 'bubble'].includes(baseType)) {
+ config.options.scales = {
+ x: {
+ stacked: spec.options.stacked,
+ title: {
+ display: Boolean(spec.options.xAxisLabel),
+ text: spec.options.xAxisLabel
+ }
+ },
+ y: {
+ stacked: spec.options.stacked,
+ beginAtZero: spec.options.beginAtZero,
+ title: {
+ display: Boolean(spec.options.yAxisLabel),
+ text: spec.options.yAxisLabel
+ }
+ }
+ };
+
+ if (spec.options.horizontal && baseType === 'bar') {
+ config.options.indexAxis = 'y';
+ }
+ }
+
+ if (baseType === 'doughnut') {
+ config.options.cutout = spec.options.cutout || '60%';
+ }
+
+ if (baseType === 'radar') {
+ config.options.scales = {
+ r: {
+ beginAtZero: spec.options.beginAtZero
+ }
+ };
+ }
+
+ return config;
+}
+
+export function extractInlineChartBlocks(markdownText = '') {
+ const blocks = [];
+ const markdown = String(markdownText ?? '').replace(INLINE_CHART_REGEX, (match, payload) => {
+ try {
+ const parsed = JSON.parse(String(payload || '').trim());
+ const spec = normalizeChartSpec(parsed);
+ if (!spec) {
+ return match;
+ }
+
+ const token = `SIMPLECHAT_INLINE_CHART_TOKEN_${blocks.length}__`;
+ blocks.push({ token, spec, originalBlock: match });
+ return `\n\n${token}\n\n`;
+ } catch (error) {
+ console.warn('Failed to parse inline chart block:', error);
+ return match;
+ }
+ });
+
+ return { markdown, blocks };
+}
+
+export function restoreInlineChartTokens(markdownText = '', blocks = []) {
+ let restored = String(markdownText ?? '');
+ blocks.forEach(block => {
+ restored = replaceAllOccurrences(restored, block.token, block.originalBlock || '');
+ });
+ return restored;
+}
+
+export function injectInlineChartHtml(html = '', blocks = []) {
+ let renderedHtml = String(html ?? '');
+
+ blocks.forEach((block, index) => {
+ const placeholderHtml = buildPlaceholderHtml(block, index);
+ renderedHtml = replaceAllOccurrences(renderedHtml, `
${block.token}
`, placeholderHtml);
+ renderedHtml = replaceAllOccurrences(renderedHtml, block.token, placeholderHtml);
+ });
+
+ return renderedHtml;
+}
+
+export function hydrateInlineCharts(root = document) {
+ const chartContainers = root.querySelectorAll('.sc-inline-chart[data-chart-hydrated="false"]');
+ chartContainers.forEach(container => {
+ const specText = container.getAttribute('data-chart-spec');
+ const stage = container.querySelector('.sc-inline-chart-stage');
+ const canvas = container.querySelector('canvas');
+ if (!specText || !stage || !canvas) {
+ return;
+ }
+
+ stage.style.height = '320px';
+
+ if (typeof window.Chart === 'undefined') {
+ stage.innerHTML = '
Chart library is unavailable for this message.
';
+ container.setAttribute('data-chart-hydrated', 'true');
+ return;
+ }
+
+ try {
+ const spec = normalizeChartSpec(JSON.parse(decodeURIComponent(specText)));
+ if (!spec) {
+ throw new Error('Invalid inline chart specification.');
+ }
+
+ const chartConfig = buildChartJsConfig(spec);
+ if (container._chartInstance) {
+ container._chartInstance.destroy();
+ }
+ container._chartInstance = new window.Chart(canvas.getContext('2d'), chartConfig);
+ container.setAttribute('data-chart-hydrated', 'true');
+
+ const toggleButton = container.querySelector('.sc-inline-chart-table-toggle');
+ if (toggleButton && !toggleButton.dataset.bound) {
+ toggleButton.dataset.bound = 'true';
+ toggleButton.addEventListener('click', () => {
+ const targetId = toggleButton.getAttribute('data-target-id');
+ const target = targetId ? container.querySelector(`#${targetId}`) : null;
+ if (!target) {
+ return;
+ }
+ const isHidden = target.classList.contains('d-none');
+ target.classList.toggle('d-none', !isHidden);
+ toggleButton.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
+ toggleButton.textContent = isHidden ? 'Hide data table' : 'Show data table';
+ });
+ }
+ } catch (error) {
+ console.warn('Failed to hydrate inline chart:', error);
+ stage.innerHTML = `
Unable to render chart: ${escapeHtml(error.message || 'invalid data')}
`;
+ container.setAttribute('data-chart-hydrated', 'true');
+ }
+ });
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-inline-images.js b/application/single_app/static/js/chat/chat-inline-images.js
new file mode 100644
index 00000000..8937ce01
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-inline-images.js
@@ -0,0 +1,730 @@
+// chat-inline-images.js
+import {
+ fetchAgentCitationArtifact,
+ parseDocIdAndPage,
+ showImagePopup,
+} from "./chat-citations.js";
+import { escapeHtml } from "./chat-utils.js";
+
+const INLINE_IMAGE_GALLERY_RENDER_TYPE = "inline_image_gallery";
+const MAX_INLINE_IMAGE_ITEMS = 5;
+const IMAGE_FILE_NAME_PATTERN = /\.(?:avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)(?:$|[?#])/i;
+
+function toNonEmptyString(value) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function parseJsonValue(value) {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+
+ if (typeof value === "object") {
+ return value;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ return null;
+ }
+}
+
+function getCitationResult(candidate) {
+ if (!candidate || typeof candidate !== "object") {
+ return null;
+ }
+
+ if (candidate.render_type && (candidate.image_gallery || candidate.image_url || candidate.images)) {
+ return candidate;
+ }
+
+ const parsedResult = parseJsonValue(candidate.function_result);
+ if (parsedResult && typeof parsedResult === "object") {
+ return parsedResult;
+ }
+
+ return null;
+}
+
+function resolveImageUrlValue(candidate) {
+ if (typeof candidate === "string") {
+ return candidate.trim();
+ }
+
+ if (!candidate || typeof candidate !== "object") {
+ return "";
+ }
+
+ return toNonEmptyString(candidate.url || candidate.image_url || candidate.src);
+}
+
+function buildWorkspaceImageUrl(docId) {
+ const normalizedDocId = toNonEmptyString(docId);
+ if (!normalizedDocId) {
+ return "";
+ }
+
+ return `/api/enhanced_citations/image?doc_id=${encodeURIComponent(normalizedDocId)}`;
+}
+
+function getUrlHostname(urlValue) {
+ const normalizedValue = toNonEmptyString(urlValue);
+ if (!normalizedValue) {
+ return "";
+ }
+
+ try {
+ return new URL(normalizedValue, window.location.origin).hostname;
+ } catch (error) {
+ return "";
+ }
+}
+
+function deriveSourceLabel(docId, imageUrl, explicitLabel = "") {
+ const normalizedLabel = toNonEmptyString(explicitLabel);
+ if (normalizedLabel) {
+ return normalizedLabel;
+ }
+
+ if (toNonEmptyString(docId)) {
+ return "Workspace image";
+ }
+
+ if (toNonEmptyString(imageUrl).startsWith("data:image/")) {
+ return "Embedded image";
+ }
+
+ const hostname = getUrlHostname(imageUrl);
+ if (hostname) {
+ return `External image (${hostname})`;
+ }
+
+ return "Image";
+}
+
+function isLikelyImageFileName(fileName) {
+ return IMAGE_FILE_NAME_PATTERN.test(toNonEmptyString(fileName));
+}
+
+function isLikelyImageUrl(urlValue) {
+ const normalizedUrl = toNonEmptyString(urlValue);
+ if (!normalizedUrl) {
+ return false;
+ }
+
+ if (normalizedUrl.startsWith("data:image/")) {
+ return true;
+ }
+
+ return IMAGE_FILE_NAME_PATTERN.test(normalizedUrl);
+}
+
+function getItemIdentityKey(item) {
+ if (!item || typeof item !== "object") {
+ return "";
+ }
+
+ return toNonEmptyString(
+ item.doc_id
+ || item.source_url
+ || item.full_image_url
+ || item.preview_image_url
+ || item.title
+ );
+}
+
+function pushUniqueImageItem(targetItems, seenKeys, item) {
+ if (!item) {
+ return;
+ }
+
+ const identityKey = getItemIdentityKey(item);
+ if (!identityKey || seenKeys.has(identityKey)) {
+ return;
+ }
+
+ seenKeys.add(identityKey);
+ targetItems.push(item);
+}
+
+function normalizeImageItem(rawItem, index) {
+ const item = typeof rawItem === "string"
+ ? { image_url: rawItem }
+ : rawItem;
+
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
+ return null;
+ }
+
+ const docId = toNonEmptyString(item.doc_id || item.docId || item.document_id || item.documentId);
+ const imageUrl = resolveImageUrlValue(item.image_url || item.url || item.src || item.full_url || item.fullUrl);
+ const thumbnailUrl = resolveImageUrlValue(
+ item.thumbnail_url || item.thumbnailUrl || item.preview_url || item.previewUrl
+ ) || imageUrl;
+
+ if (!docId && !imageUrl) {
+ return null;
+ }
+
+ const fallbackTitle = docId
+ ? toNonEmptyString(item.file_name || item.fileName) || `Workspace image ${index + 1}`
+ : `Image ${index + 1}`;
+ const title = toNonEmptyString(item.title || item.label || item.name || item.caption) || fallbackTitle;
+ const description = toNonEmptyString(item.description || item.summary || item.caption || item.details);
+ const fileName = toNonEmptyString(item.file_name || item.fileName);
+ const previewImageUrl = docId ? buildWorkspaceImageUrl(docId) : thumbnailUrl;
+ const fullImageUrl = docId ? buildWorkspaceImageUrl(docId) : imageUrl;
+ const sourceUrl = toNonEmptyString(item.source_url || item.sourceUrl || item.link || item.href);
+
+ return {
+ id: toNonEmptyString(item.id) || `inline-image-${index + 1}`,
+ title,
+ description,
+ file_name: fileName,
+ doc_id: docId,
+ preview_image_url: previewImageUrl,
+ full_image_url: fullImageUrl,
+ source_label: deriveSourceLabel(docId, imageUrl || previewImageUrl, item.source_label || item.sourceLabel),
+ source_url: sourceUrl,
+ alt_text: toNonEmptyString(item.alt_text || item.altText) || title,
+ };
+}
+
+function normalizeWorkspaceCitationImageItem(rawCitation, index) {
+ if (!rawCitation || typeof rawCitation !== "object" || rawCitation.metadata_type) {
+ return null;
+ }
+
+ const fileName = toNonEmptyString(rawCitation.file_name || rawCitation.fileName || rawCitation.title);
+ if (!isLikelyImageFileName(fileName)) {
+ return null;
+ }
+
+ const citationId = toNonEmptyString(rawCitation.citation_id || rawCitation.chunk_id);
+ const { docId } = citationId ? parseDocIdAndPage(citationId) : { docId: "" };
+ const normalizedDocId = toNonEmptyString(docId || rawCitation.doc_id || rawCitation.docId);
+ if (!normalizedDocId) {
+ return null;
+ }
+
+ const locationLabel = toNonEmptyString(rawCitation.location_label || (rawCitation.sheet_name ? "Sheet" : "Page"));
+ const locationValue = toNonEmptyString(rawCitation.location_value || rawCitation.sheet_name || rawCitation.page_number);
+ const description = locationValue && locationValue !== "N/A"
+ ? `${locationLabel || "Location"}: ${locationValue}`
+ : "Workspace image cited in this response.";
+
+ return {
+ id: `workspace-image-${normalizedDocId}-${index + 1}`,
+ title: fileName || `Workspace image ${index + 1}`,
+ description,
+ file_name: fileName,
+ doc_id: normalizedDocId,
+ preview_image_url: buildWorkspaceImageUrl(normalizedDocId),
+ full_image_url: buildWorkspaceImageUrl(normalizedDocId),
+ source_label: "Workspace image",
+ source_url: "",
+ alt_text: fileName || `Workspace image ${index + 1}`,
+ };
+}
+
+function extractWorkspaceCitationImageItems(hybridCitations = [], seenKeys = new Set()) {
+ const items = [];
+ if (!Array.isArray(hybridCitations) || hybridCitations.length === 0) {
+ return items;
+ }
+
+ hybridCitations.forEach((citation, index) => {
+ pushUniqueImageItem(items, seenKeys, normalizeWorkspaceCitationImageItem(citation, index));
+ });
+
+ return items;
+}
+
+function normalizeWebCitationImageItem(rawCitation, index) {
+ if (!rawCitation || typeof rawCitation !== "object") {
+ return null;
+ }
+
+ const imageUrl = resolveImageUrlValue(rawCitation.image_url || rawCitation.url || rawCitation.src);
+ if (!isLikelyImageUrl(imageUrl)) {
+ return null;
+ }
+
+ const title = toNonEmptyString(rawCitation.title || rawCitation.label || rawCitation.name)
+ || `Linked image ${index + 1}`;
+ const description = toNonEmptyString(rawCitation.description || rawCitation.summary);
+
+ return {
+ id: `linked-image-${index + 1}`,
+ title,
+ description,
+ file_name: toNonEmptyString(rawCitation.file_name || rawCitation.fileName),
+ doc_id: "",
+ preview_image_url: imageUrl,
+ full_image_url: imageUrl,
+ source_label: deriveSourceLabel("", imageUrl, rawCitation.source_label || rawCitation.sourceLabel),
+ source_url: toNonEmptyString(rawCitation.source_url || rawCitation.sourceUrl || rawCitation.url),
+ alt_text: toNonEmptyString(rawCitation.alt_text || rawCitation.altText) || title,
+ };
+}
+
+function extractLinkedImageItems(webCitations = [], seenKeys = new Set()) {
+ const items = [];
+ if (!Array.isArray(webCitations) || webCitations.length === 0) {
+ return items;
+ }
+
+ webCitations.forEach((citation, index) => {
+ pushUniqueImageItem(items, seenKeys, normalizeWebCitationImageItem(citation, index));
+ });
+
+ return items;
+}
+
+function buildImageGalleryResult(title, summary, items, sourceActionName, totalCount = items.length) {
+ const renderedItems = Array.isArray(items) ? items.slice(0, MAX_INLINE_IMAGE_ITEMS) : [];
+ if (renderedItems.length === 0) {
+ return null;
+ }
+
+ return {
+ success: true,
+ render_type: INLINE_IMAGE_GALLERY_RENDER_TYPE,
+ image_gallery: {
+ title,
+ summary,
+ items: renderedItems,
+ total_count: Number(totalCount) || renderedItems.length,
+ rendered_count: renderedItems.length,
+ source_action_name: sourceActionName,
+ },
+ };
+}
+
+function extractRawImageItems(candidate) {
+ if (!candidate || typeof candidate !== "object") {
+ return [];
+ }
+
+ if (candidate.image_gallery && typeof candidate.image_gallery === "object") {
+ const galleryItems = candidate.image_gallery.items;
+ return Array.isArray(galleryItems) ? galleryItems : [];
+ }
+
+ if (Array.isArray(candidate.items)) {
+ return candidate.items;
+ }
+
+ if (Array.isArray(candidate.images)) {
+ return candidate.images;
+ }
+
+ if (Array.isArray(candidate.image_urls)) {
+ return candidate.image_urls;
+ }
+
+ const directImageUrl = resolveImageUrlValue(candidate.image_url);
+ const directMime = toNonEmptyString(candidate.mime);
+ const directType = toNonEmptyString(candidate.type).toLowerCase();
+ if (directImageUrl || directType === "image_url" || directMime.startsWith("image/")) {
+ return [{
+ image_url: directImageUrl,
+ title: candidate.title,
+ description: candidate.description || candidate.summary,
+ file_name: candidate.file_name || candidate.fileName,
+ source_label: candidate.source_label || candidate.sourceLabel,
+ source_url: candidate.source_url || candidate.sourceUrl,
+ }];
+ }
+
+ return [];
+}
+
+function normalizeImageGalleryResult(result, maxItems = MAX_INLINE_IMAGE_ITEMS) {
+ if (!result || typeof result !== "object" || result.success === false || maxItems <= 0) {
+ return null;
+ }
+
+ const galleryCandidate = result.image_gallery && typeof result.image_gallery === "object"
+ ? result.image_gallery
+ : result;
+ const rawItems = extractRawImageItems(result);
+ if (!Array.isArray(rawItems) || rawItems.length === 0) {
+ return null;
+ }
+
+ const normalizedItems = rawItems
+ .map((item, index) => normalizeImageItem(item, index))
+ .filter(Boolean);
+ if (normalizedItems.length === 0) {
+ return null;
+ }
+
+ const renderedItems = normalizedItems.slice(0, Math.max(0, maxItems));
+ if (renderedItems.length === 0) {
+ return null;
+ }
+ const totalCount = Number.isFinite(Number(galleryCandidate.total_count || galleryCandidate.totalCount))
+ ? Number(galleryCandidate.total_count || galleryCandidate.totalCount)
+ : normalizedItems.length;
+ const galleryTitle = toNonEmptyString(galleryCandidate.title || galleryCandidate.label || result.title)
+ || (totalCount === 1 ? "Image result" : "Image results");
+ const gallerySummary = toNonEmptyString(galleryCandidate.summary || galleryCandidate.description || result.summary)
+ || (renderedItems.length === 1
+ ? "Relevant image returned for this result."
+ : "Relevant images returned for this result.");
+
+ return {
+ ...result,
+ render_type: result.render_type || INLINE_IMAGE_GALLERY_RENDER_TYPE,
+ image_gallery: {
+ title: galleryTitle,
+ summary: gallerySummary,
+ items: renderedItems,
+ total_count: totalCount,
+ rendered_count: renderedItems.length,
+ source_action_name: toNonEmptyString(galleryCandidate.source_action_name || galleryCandidate.sourceActionName || result.source_action_name),
+ },
+ };
+}
+
+async function hydrateInlineImageGalleryCitation(conversationId, artifactId) {
+ try {
+ const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId);
+ return normalizeImageGalleryResult(getCitationResult(hydratedCitation));
+ } catch (error) {
+ console.warn("Failed to hydrate inline image gallery citation artifact", error);
+ return null;
+ }
+}
+
+async function resolveInlineImageGallery(citation, conversationId, maxItems = MAX_INLINE_IMAGE_ITEMS) {
+ const shouldPreferArtifact = Boolean(
+ citation?.raw_payload_externalized
+ && citation?.artifact_id
+ && conversationId
+ );
+
+ if (shouldPreferArtifact) {
+ const hydratedResult = await hydrateInlineImageGalleryCitation(conversationId, citation.artifact_id);
+ const normalizedHydratedResult = normalizeImageGalleryResult(hydratedResult, maxItems);
+ if (normalizedHydratedResult) {
+ return normalizedHydratedResult;
+ }
+
+ if (hydratedResult) {
+ return hydratedResult;
+ }
+ }
+
+ const localResult = normalizeImageGalleryResult(getCitationResult(citation), maxItems);
+ if (localResult) {
+ return localResult;
+ }
+
+ if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) {
+ return null;
+ }
+
+ const fallbackHydratedResult = await hydrateInlineImageGalleryCitation(conversationId, citation.artifact_id);
+ return normalizeImageGalleryResult(fallbackHydratedResult, maxItems);
+}
+
+function buildImageDetailsRows(item) {
+ const rows = [];
+
+ if (item.source_label) {
+ rows.push(`
+
+ Source
+ ${escapeHtml(item.source_label)}
+
+ `);
+ }
+
+ if (item.file_name) {
+ rows.push(`
+
+ File
+ ${escapeHtml(item.file_name)}
+
+ `);
+ }
+
+ if (item.doc_id) {
+ rows.push(`
+
+ Document ID
+ ${escapeHtml(item.doc_id)}
+
+ `);
+ }
+
+ if (item.source_url) {
+ rows.push(`
+
+ `);
+ }
+
+ return rows.join("");
+}
+
+function getInlineImageDetailsModal() {
+ let modalContainer = document.getElementById("inline-image-details-modal");
+ if (modalContainer) {
+ return modalContainer;
+ }
+
+ modalContainer = document.createElement("div");
+ modalContainer.id = "inline-image-details-modal";
+ modalContainer.className = "modal fade";
+ modalContainer.tabIndex = -1;
+ modalContainer.setAttribute("aria-hidden", "true");
+ modalContainer.innerHTML = `
+
+ `;
+ document.body.appendChild(modalContainer);
+ return modalContainer;
+}
+
+function showInlineImageDetailsModal(item) {
+ const modalContainer = getInlineImageDetailsModal();
+ const titleEl = modalContainer.querySelector("#inline-image-details-title");
+ const previewEl = modalContainer.querySelector("#inline-image-details-preview");
+ const descriptionEl = modalContainer.querySelector("#inline-image-details-description");
+ const metaEl = modalContainer.querySelector("#inline-image-details-meta");
+
+ if (titleEl) {
+ titleEl.textContent = item.title || "Image details";
+ }
+
+ if (previewEl) {
+ previewEl.src = item.full_image_url || item.preview_image_url;
+ previewEl.alt = item.alt_text || item.title || "Inline image";
+ }
+
+ if (descriptionEl) {
+ const hasDescription = Boolean(item.description);
+ descriptionEl.classList.toggle("d-none", !hasDescription);
+ descriptionEl.textContent = item.description || "";
+ }
+
+ if (metaEl) {
+ metaEl.innerHTML = buildImageDetailsRows(item);
+ }
+
+ const modal = new bootstrap.Modal(modalContainer);
+ modal.show();
+}
+
+function createBadge(label, value) {
+ const badge = document.createElement("span");
+ badge.className = "inline-image-gallery-badge";
+ badge.textContent = `${label}: ${value}`;
+ return badge;
+}
+
+function createImageTile(item) {
+ const tile = document.createElement("article");
+ tile.className = "inline-image-gallery-item";
+ tile.innerHTML = `
+
+
+
+
+
+
+
+
${escapeHtml(item.source_label)}
+
${escapeHtml(item.title || "Image")}
+ ${item.description ? `
${escapeHtml(item.description)}
` : ""}
+
+ `;
+
+ const imageEl = tile.querySelector(".inline-image-gallery-item-image");
+ const infoButton = tile.querySelector(".inline-image-gallery-info-btn");
+ if (imageEl) {
+ imageEl.src = item.preview_image_url || item.full_image_url;
+ imageEl.addEventListener("click", () => {
+ showImagePopup(item.full_image_url || item.preview_image_url);
+ });
+ imageEl.addEventListener("error", () => {
+ imageEl.src = "/static/images/image-error.png";
+ imageEl.alt = "Failed to load image";
+ });
+ }
+
+ if (infoButton) {
+ infoButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ showInlineImageDetailsModal(item);
+ });
+ }
+
+ return tile;
+}
+
+function createImageGalleryCard(result, messageId, index) {
+ const payload = result.image_gallery || {};
+ const card = document.createElement("section");
+ card.className = "inline-image-gallery-card";
+
+ const renderedCount = Number(payload.rendered_count || (payload.items || []).length || 0);
+ const totalCount = Number(payload.total_count || renderedCount || 0);
+ const summaryText = payload.summary || result.summary || "Relevant image results.";
+
+ card.innerHTML = `
+
+
+
+ `;
+
+ const badgesContainer = card.querySelector(".inline-image-gallery-badges");
+ if (badgesContainer) {
+ badgesContainer.appendChild(createBadge("Images", renderedCount));
+ if (totalCount > renderedCount) {
+ badgesContainer.appendChild(createBadge("Showing", `${renderedCount} of ${totalCount}`));
+ }
+ }
+
+ const grid = card.querySelector(".inline-image-gallery-grid");
+ if (grid) {
+ (payload.items || []).forEach((item) => {
+ grid.appendChild(createImageTile(item));
+ });
+ }
+
+ return { card };
+}
+
+export async function renderInlineImageGalleries(
+ messageElement,
+ hybridCitations = [],
+ webCitations = [],
+ agentCitations = [],
+ messageId = "",
+ conversationId = ""
+) {
+ if (!messageElement) {
+ return;
+ }
+
+ const container = messageElement.querySelector(".inline-visualizations-container");
+ if (!container) {
+ return;
+ }
+
+ container.querySelectorAll(".inline-image-gallery-card").forEach((card) => card.remove());
+
+ const hasHybridCitations = Array.isArray(hybridCitations) && hybridCitations.length > 0;
+ const hasWebCitations = Array.isArray(webCitations) && webCitations.length > 0;
+ const hasAgentCitations = Array.isArray(agentCitations) && agentCitations.length > 0;
+ if (!hasHybridCitations && !hasWebCitations && !hasAgentCitations) {
+ container.classList.toggle("d-none", container.children.length === 0);
+ return;
+ }
+
+ let remainingSlots = MAX_INLINE_IMAGE_ITEMS;
+ let galleryIndex = 0;
+ const seenImageKeys = new Set();
+
+ const workspaceItems = extractWorkspaceCitationImageItems(hybridCitations, seenImageKeys);
+ if (workspaceItems.length > 0 && remainingSlots > 0) {
+ const workspaceGallery = buildImageGalleryResult(
+ "Workspace images",
+ "Image sources cited from workspace content.",
+ workspaceItems.slice(0, remainingSlots),
+ "Workspace citations",
+ workspaceItems.length
+ );
+ if (workspaceGallery) {
+ const { card } = createImageGalleryCard(workspaceGallery, messageId, galleryIndex);
+ container.appendChild(card);
+ remainingSlots -= workspaceGallery.image_gallery.rendered_count || 0;
+ galleryIndex += 1;
+ }
+ }
+
+ const linkedItems = extractLinkedImageItems(webCitations, seenImageKeys);
+ if (linkedItems.length > 0 && remainingSlots > 0) {
+ const linkedGallery = buildImageGalleryResult(
+ "Linked images",
+ "Direct image links returned with this response.",
+ linkedItems.slice(0, remainingSlots),
+ "Linked sources",
+ linkedItems.length
+ );
+ if (linkedGallery) {
+ const { card } = createImageGalleryCard(linkedGallery, messageId, galleryIndex);
+ container.appendChild(card);
+ remainingSlots -= linkedGallery.image_gallery.rendered_count || 0;
+ galleryIndex += 1;
+ }
+ }
+
+ for (let index = 0; index < agentCitations.length && remainingSlots > 0; index += 1) {
+ const citation = agentCitations[index];
+ const result = await resolveInlineImageGallery(citation, conversationId, remainingSlots);
+ if (!result) {
+ continue;
+ }
+
+ const normalizedItems = Array.isArray(result?.image_gallery?.items)
+ ? result.image_gallery.items.filter((item) => {
+ const identityKey = getItemIdentityKey(item);
+ if (!identityKey || seenImageKeys.has(identityKey)) {
+ return false;
+ }
+
+ seenImageKeys.add(identityKey);
+ return true;
+ })
+ : [];
+ if (normalizedItems.length === 0) {
+ continue;
+ }
+
+ result.image_gallery.items = normalizedItems;
+ result.image_gallery.rendered_count = normalizedItems.length;
+
+ const { card } = createImageGalleryCard(result, messageId, galleryIndex);
+ container.appendChild(card);
+ remainingSlots -= normalizedItems.length;
+ galleryIndex += 1;
+ }
+
+ container.classList.toggle("d-none", container.children.length === 0);
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-inline-maps.js b/application/single_app/static/js/chat/chat-inline-maps.js
new file mode 100644
index 00000000..87ec16d7
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-inline-maps.js
@@ -0,0 +1,578 @@
+// chat-inline-maps.js
+import { fetchAgentCitationArtifact } from "./chat-citations.js";
+import { escapeHtml } from "./chat-utils.js";
+
+const AZURE_MAPS_RENDER_TYPE = "azure_maps_openlayers";
+
+function toFiniteNumber(value) {
+ const numericValue = Number(value);
+ return Number.isFinite(numericValue) ? numericValue : null;
+}
+
+function normalizeCoordinatePair(rawCoordinate) {
+ if (!Array.isArray(rawCoordinate) || rawCoordinate.length < 2) {
+ return null;
+ }
+
+ const longitude = toFiniteNumber(rawCoordinate[0]);
+ const latitude = toFiniteNumber(rawCoordinate[1]);
+ if (longitude === null || latitude === null) {
+ return null;
+ }
+
+ return [longitude, latitude];
+}
+
+function parseJsonValue(value) {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+
+ if (typeof value === "object") {
+ return value;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ return null;
+ }
+}
+
+function getCitationResult(candidate) {
+ if (!candidate || typeof candidate !== "object") {
+ return null;
+ }
+
+ if (candidate.render_type && candidate.map_payload) {
+ return candidate;
+ }
+
+ const parsedResult = parseJsonValue(candidate.function_result);
+ if (parsedResult && typeof parsedResult === "object") {
+ return parsedResult;
+ }
+
+ return null;
+}
+
+function isAzureMapsVisualization(result) {
+ return Boolean(
+ result
+ && result.success !== false
+ && result.render_type === AZURE_MAPS_RENDER_TYPE
+ && result.map_payload
+ && typeof result.map_payload === "object"
+ && result.map_payload.tile_url_template
+ );
+}
+
+function normalizeMarkers(rawMarkers = []) {
+ if (!Array.isArray(rawMarkers)) {
+ return [];
+ }
+
+ return rawMarkers
+ .map((marker) => {
+ if (!marker || typeof marker !== "object" || Array.isArray(marker)) {
+ return null;
+ }
+
+ const longitude = toFiniteNumber(marker.longitude ?? marker.lon ?? marker.lng);
+ const latitude = toFiniteNumber(marker.latitude ?? marker.lat);
+ if (longitude === null || latitude === null) {
+ return null;
+ }
+
+ return {
+ ...marker,
+ longitude,
+ latitude,
+ label: typeof marker.label === "string" && marker.label.trim() ? marker.label.trim() : "Location",
+ description: typeof marker.description === "string" ? marker.description : "",
+ };
+ })
+ .filter(Boolean);
+}
+
+function normalizeAreas(rawAreas = []) {
+ if (!Array.isArray(rawAreas)) {
+ return [];
+ }
+
+ return rawAreas
+ .map((area) => {
+ if (!area || typeof area !== "object" || Array.isArray(area)) {
+ return null;
+ }
+
+ let rawCoordinates = area.coordinates;
+ if (
+ Array.isArray(rawCoordinates)
+ && Array.isArray(rawCoordinates[0])
+ && Array.isArray(rawCoordinates[0][0])
+ ) {
+ rawCoordinates = rawCoordinates[0];
+ }
+
+ const coordinates = Array.isArray(rawCoordinates)
+ ? rawCoordinates.map((coordinate) => normalizeCoordinatePair(coordinate)).filter(Boolean)
+ : [];
+
+ if (coordinates.length < 3) {
+ return null;
+ }
+
+ const firstCoordinate = coordinates[0];
+ const lastCoordinate = coordinates[coordinates.length - 1];
+ if (!lastCoordinate || firstCoordinate[0] !== lastCoordinate[0] || firstCoordinate[1] !== lastCoordinate[1]) {
+ coordinates.push([...firstCoordinate]);
+ }
+
+ return {
+ ...area,
+ coordinates,
+ label: typeof area.label === "string" && area.label.trim() ? area.label.trim() : "Area",
+ description: typeof area.description === "string" ? area.description : "",
+ };
+ })
+ .filter(Boolean);
+}
+
+function normalizePaths(rawPaths = []) {
+ if (!Array.isArray(rawPaths)) {
+ return [];
+ }
+
+ return rawPaths
+ .map((path) => {
+ if (!path || typeof path !== "object" || Array.isArray(path)) {
+ return null;
+ }
+
+ let rawCoordinates = path.coordinates;
+ if (
+ Array.isArray(rawCoordinates)
+ && Array.isArray(rawCoordinates[0])
+ && Array.isArray(rawCoordinates[0][0])
+ ) {
+ rawCoordinates = rawCoordinates[0];
+ }
+
+ const coordinates = Array.isArray(rawCoordinates)
+ ? rawCoordinates.map((coordinate) => normalizeCoordinatePair(coordinate)).filter(Boolean)
+ : [];
+
+ if (coordinates.length < 2) {
+ return null;
+ }
+
+ const lineWidth = toFiniteNumber(path.line_width ?? path.lineWidth ?? path.width);
+ return {
+ ...path,
+ coordinates,
+ label: typeof path.label === "string" && path.label.trim() ? path.label.trim() : "Path",
+ description: typeof path.description === "string" ? path.description : "",
+ line_width: lineWidth === null ? 4 : lineWidth,
+ };
+ })
+ .filter(Boolean);
+}
+
+function buildFallbackCenter(markers, areas, paths) {
+ if (markers.length > 0) {
+ return [markers[0].longitude, markers[0].latitude];
+ }
+
+ if (areas.length > 0 && Array.isArray(areas[0].coordinates) && areas[0].coordinates.length > 0) {
+ return [...areas[0].coordinates[0]];
+ }
+
+ if (paths.length > 0 && Array.isArray(paths[0].coordinates) && paths[0].coordinates.length > 0) {
+ return [...paths[0].coordinates[0]];
+ }
+
+ return [0, 20];
+}
+
+function normalizeView(rawView = {}, markers = [], areas = [], paths = []) {
+ const fallbackCenter = buildFallbackCenter(markers, areas, paths);
+ const parsedCenter = normalizeCoordinatePair(rawView?.center);
+ const zoom = toFiniteNumber(rawView?.zoom);
+ const maxZoom = toFiniteNumber(rawView?.max_zoom);
+
+ return {
+ center: parsedCenter || fallbackCenter,
+ zoom: zoom === null ? (markers.length === 1 && areas.length === 0 && paths.length === 0 ? 14 : 10) : zoom,
+ max_zoom: maxZoom === null ? 15 : maxZoom,
+ fit_to_features: rawView?.fit_to_features !== false,
+ };
+}
+
+function normalizeAzureMapsResult(result) {
+ if (!isAzureMapsVisualization(result)) {
+ return null;
+ }
+
+ const payload = result.map_payload || {};
+ const markers = normalizeMarkers(payload.markers);
+ const areas = normalizeAreas(payload.areas);
+ const paths = normalizePaths(payload.paths);
+ if (markers.length === 0 && areas.length === 0 && paths.length === 0) {
+ return null;
+ }
+
+ return {
+ ...result,
+ map_payload: {
+ ...payload,
+ markers,
+ paths,
+ areas,
+ view: normalizeView(payload.view || {}, markers, areas, paths),
+ },
+ };
+}
+
+async function hydrateAzureMapsCitation(conversationId, artifactId) {
+ try {
+ const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId);
+ return normalizeAzureMapsResult(getCitationResult(hydratedCitation));
+ } catch (error) {
+ console.warn("Failed to hydrate Azure Maps citation artifact", error);
+ return null;
+ }
+}
+
+async function resolveAzureMapsVisualization(citation, conversationId) {
+ const shouldPreferArtifact = Boolean(citation?.artifact_id && conversationId);
+
+ if (shouldPreferArtifact) {
+ const hydratedResult = await hydrateAzureMapsCitation(conversationId, citation.artifact_id);
+ if (hydratedResult) {
+ return hydratedResult;
+ }
+ }
+
+ const localResult = normalizeAzureMapsResult(getCitationResult(citation));
+ if (localResult) {
+ return localResult;
+ }
+
+ if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) {
+ return null;
+ }
+
+ return hydrateAzureMapsCitation(conversationId, citation.artifact_id);
+}
+
+function createBadge(label, value) {
+ const badge = document.createElement("span");
+ badge.className = "inline-map-badge";
+ badge.textContent = `${label}: ${value}`;
+ return badge;
+}
+
+function buildPopupHtml(properties) {
+ const label = escapeHtml(properties?.label || "Map item");
+ const description = properties?.description
+ ? ``
+ : "";
+
+ return `
+
+ ${description}
+ `;
+}
+
+function createMarkerFeature(olRef, marker) {
+ const feature = new olRef.Feature({
+ geometry: new olRef.geom.Point(olRef.proj.fromLonLat([
+ Number(marker.longitude),
+ Number(marker.latitude),
+ ])),
+ featureType: "marker",
+ label: marker.label || "Location",
+ description: marker.description || "",
+ });
+
+ feature.setStyle(new olRef.style.Style({
+ image: new olRef.style.Circle({
+ radius: 8,
+ fill: new olRef.style.Fill({
+ color: marker.color || "#0d6efd",
+ }),
+ stroke: new olRef.style.Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ }),
+ }));
+
+ return feature;
+}
+
+function createAreaFeature(olRef, area) {
+ const ring = (area.coordinates || []).map((coordinate) => olRef.proj.fromLonLat([
+ Number(coordinate[0]),
+ Number(coordinate[1]),
+ ]));
+
+ const feature = new olRef.Feature({
+ geometry: new olRef.geom.Polygon([ring]),
+ featureType: "area",
+ label: area.label || "Area",
+ description: area.description || "",
+ });
+
+ feature.setStyle(new olRef.style.Style({
+ stroke: new olRef.style.Stroke({
+ color: area.stroke_color || "#b02a37",
+ width: 2,
+ }),
+ fill: new olRef.style.Fill({
+ color: area.fill_color || "rgba(176, 42, 55, 0.20)",
+ }),
+ }));
+
+ return feature;
+}
+
+function createPathFeature(olRef, path) {
+ const line = (path.coordinates || []).map((coordinate) => olRef.proj.fromLonLat([
+ Number(coordinate[0]),
+ Number(coordinate[1]),
+ ]));
+
+ const feature = new olRef.Feature({
+ geometry: new olRef.geom.LineString(line),
+ featureType: "path",
+ label: path.label || "Path",
+ description: path.description || "",
+ });
+
+ feature.setStyle(new olRef.style.Style({
+ stroke: new olRef.style.Stroke({
+ color: path.stroke_color || "#0b5ed7",
+ width: Number(path.line_width || 4),
+ }),
+ }));
+
+ return feature;
+}
+
+function fitViewToFeatures(olRef, vectorSource, view, payload, featureCount) {
+ if (!payload.view?.fit_to_features || featureCount === 0) {
+ return;
+ }
+
+ const extent = vectorSource.getExtent();
+ const extentIsEmpty = typeof olRef.extent?.isEmpty === "function"
+ ? olRef.extent.isEmpty(extent)
+ : false;
+ if (extentIsEmpty) {
+ return;
+ }
+
+ view.fit(extent, {
+ padding: [48, 48, 48, 48],
+ maxZoom: payload.view?.max_zoom || 15,
+ duration: 0,
+ });
+}
+
+function initializeOpenLayersMap(mapElement, popupElement, payload) {
+ const olRef = window.ol;
+ if (!olRef?.Map || !olRef?.layer?.Tile || !olRef?.source?.XYZ) {
+ throw new Error("OpenLayers is not available.");
+ }
+
+ const tileLayer = new olRef.layer.Tile({
+ source: new olRef.source.XYZ({
+ url: payload.tile_url_template,
+ attributions: payload.tile_attribution || "",
+ crossOrigin: "anonymous",
+ maxZoom: payload.view?.max_zoom || 15,
+ }),
+ });
+
+ const vectorSource = new olRef.source.Vector();
+ const features = [];
+
+ (payload.markers || []).forEach((marker) => {
+ features.push(createMarkerFeature(olRef, marker));
+ });
+
+ (payload.paths || []).forEach((path) => {
+ features.push(createPathFeature(olRef, path));
+ });
+
+ (payload.areas || []).forEach((area) => {
+ features.push(createAreaFeature(olRef, area));
+ });
+
+ vectorSource.addFeatures(features);
+
+ const vectorLayer = new olRef.layer.Vector({
+ source: vectorSource,
+ });
+
+ const overlay = new olRef.Overlay({
+ element: popupElement,
+ positioning: "bottom-center",
+ stopEvent: false,
+ offset: [0, -14],
+ });
+
+ const view = new olRef.View({
+ center: olRef.proj.fromLonLat(payload.view?.center || [0, 20]),
+ zoom: payload.view?.zoom || 10,
+ maxZoom: payload.view?.max_zoom || 15,
+ });
+
+ const map = new olRef.Map({
+ target: mapElement,
+ layers: [tileLayer, vectorLayer],
+ overlays: [overlay],
+ view,
+ controls: typeof olRef.control?.defaults === "function"
+ ? olRef.control.defaults({ attributionOptions: { collapsible: true } })
+ : undefined,
+ });
+
+ map.on("click", (event) => {
+ const feature = map.forEachFeatureAtPixel(event.pixel, (selectedFeature) => selectedFeature);
+ if (!feature) {
+ popupElement.classList.remove("is-visible");
+ overlay.setPosition(undefined);
+ return;
+ }
+
+ popupElement.innerHTML = buildPopupHtml(feature.getProperties());
+ popupElement.classList.add("is-visible");
+ overlay.setPosition(event.coordinate);
+ });
+
+ map.on("pointermove", (event) => {
+ map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel) ? "pointer" : "";
+ });
+
+ requestAnimationFrame(() => {
+ map.updateSize();
+ fitViewToFeatures(olRef, vectorSource, view, payload, features.length);
+ requestAnimationFrame(() => {
+ map.updateSize();
+ fitViewToFeatures(olRef, vectorSource, view, payload, features.length);
+ });
+ });
+}
+
+function createFallbackNotice(message) {
+ const fallback = document.createElement("div");
+ fallback.className = "inline-map-fallback";
+ fallback.textContent = message;
+ return fallback;
+}
+
+function createMapCard(result, messageId, index) {
+ const payload = result.map_payload || {};
+ const card = document.createElement("section");
+ card.className = "inline-map-card";
+
+ const safeMessageId = String(messageId || "map").replace(/[^a-zA-Z0-9_-]/g, "-");
+ const mapId = `inline-map-${safeMessageId}-${index}`;
+ const markerCount = Array.isArray(payload.markers) ? payload.markers.length : 0;
+ const pathCount = Array.isArray(payload.paths) ? payload.paths.length : 0;
+ const areaCount = Array.isArray(payload.areas) ? payload.areas.length : 0;
+ const summaryText = payload.summary || result.summary || "Interactive Azure Maps visualization.";
+
+ card.innerHTML = `
+
+
+
+ `;
+
+ const badgesContainer = card.querySelector(".inline-map-badges");
+ if (badgesContainer) {
+ badgesContainer.appendChild(createBadge("Markers", markerCount));
+ if (pathCount > 0) {
+ badgesContainer.appendChild(createBadge("Paths", pathCount));
+ }
+ badgesContainer.appendChild(createBadge("Areas", areaCount));
+ if (payload.tileset_id) {
+ badgesContainer.appendChild(createBadge("Tiles", payload.tileset_id));
+ }
+ }
+
+ return {
+ card,
+ mapElement: card.querySelector(".inline-map-canvas"),
+ popupElement: card.querySelector(".inline-map-popup"),
+ payload,
+ };
+}
+
+export async function renderInlineAzureMaps(messageElement, agentCitations = [], messageId = "", conversationId = "") {
+ if (!messageElement) {
+ return;
+ }
+
+ const container = messageElement.querySelector(".inline-visualizations-container");
+ if (!container) {
+ return;
+ }
+
+ container.querySelectorAll(".inline-map-card").forEach((card) => card.remove());
+
+ if (!Array.isArray(agentCitations) || agentCitations.length === 0) {
+ container.classList.toggle("d-none", container.children.length === 0);
+ return;
+ }
+
+ const pendingMaps = [];
+ let renderedCount = 0;
+ for (let index = 0; index < agentCitations.length; index += 1) {
+ const citation = agentCitations[index];
+ const result = await resolveAzureMapsVisualization(citation, conversationId);
+ if (!result) {
+ continue;
+ }
+
+ const { card, mapElement, popupElement, payload } = createMapCard(result, messageId, index);
+ container.appendChild(card);
+ pendingMaps.push({ card, mapElement, popupElement, payload });
+
+ renderedCount += 1;
+ }
+
+ container.classList.toggle("d-none", container.children.length === 0);
+
+ pendingMaps.forEach(({ card, mapElement, popupElement, payload }) => {
+ try {
+ initializeOpenLayersMap(mapElement, popupElement, payload);
+ } catch (error) {
+ console.warn("Failed to initialize inline Azure Maps visualization", error);
+ const mapShell = card.querySelector(".inline-map-shell");
+ if (mapShell) {
+ mapShell.innerHTML = "";
+ mapShell.appendChild(createFallbackNotice("The map data is available, but OpenLayers could not be initialized in this browser session."));
+ }
+ }
+ });
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-inline-videos.js b/application/single_app/static/js/chat/chat-inline-videos.js
new file mode 100644
index 00000000..0e9a931b
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-inline-videos.js
@@ -0,0 +1,796 @@
+// chat-inline-videos.js
+import {
+ fetchAgentCitationArtifact,
+ parseDocIdAndPage,
+} from "./chat-citations.js";
+import { escapeHtml } from "./chat-utils.js";
+
+const INLINE_VIDEO_GALLERY_RENDER_TYPE = "inline_video_gallery";
+const MAX_INLINE_VIDEO_ITEMS = 5;
+const VIDEO_FILE_NAME_PATTERN = /\.(?:3gp|avi|flv|m4v|mkv|mov|mp4|mpe?g|ogv|webm|wmv)(?:$|[?#])/i;
+
+function toNonEmptyString(value) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function parseJsonValue(value) {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+
+ if (typeof value === "object") {
+ return value;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ return null;
+ }
+}
+
+function getCitationResult(candidate) {
+ if (!candidate || typeof candidate !== "object") {
+ return null;
+ }
+
+ if (candidate.render_type && (candidate.video_gallery || candidate.video_url || candidate.videos)) {
+ return candidate;
+ }
+
+ const parsedResult = parseJsonValue(candidate.function_result);
+ if (parsedResult && typeof parsedResult === "object") {
+ return parsedResult;
+ }
+
+ return null;
+}
+
+function resolveVideoUrlValue(candidate) {
+ if (typeof candidate === "string") {
+ return candidate.trim();
+ }
+
+ if (!candidate || typeof candidate !== "object") {
+ return "";
+ }
+
+ return toNonEmptyString(candidate.url || candidate.video_url || candidate.src);
+}
+
+function buildWorkspaceVideoUrl(docId) {
+ const normalizedDocId = toNonEmptyString(docId);
+ if (!normalizedDocId) {
+ return "";
+ }
+
+ return `/api/enhanced_citations/video?doc_id=${encodeURIComponent(normalizedDocId)}`;
+}
+
+function getUrlHostname(urlValue) {
+ const normalizedValue = toNonEmptyString(urlValue);
+ if (!normalizedValue) {
+ return "";
+ }
+
+ try {
+ return new URL(normalizedValue, window.location.origin).hostname;
+ } catch (error) {
+ return "";
+ }
+}
+
+function deriveSourceLabel(docId, videoUrl, explicitLabel = "") {
+ const normalizedLabel = toNonEmptyString(explicitLabel);
+ if (normalizedLabel) {
+ return normalizedLabel;
+ }
+
+ if (toNonEmptyString(docId)) {
+ return "Workspace video";
+ }
+
+ if (toNonEmptyString(videoUrl).startsWith("data:video/")) {
+ return "Embedded video";
+ }
+
+ const hostname = getUrlHostname(videoUrl);
+ if (hostname) {
+ return `External video (${hostname})`;
+ }
+
+ return "Video";
+}
+
+function isLikelyVideoFileName(fileName) {
+ return VIDEO_FILE_NAME_PATTERN.test(toNonEmptyString(fileName));
+}
+
+function isLikelyVideoUrl(urlValue) {
+ const normalizedUrl = toNonEmptyString(urlValue);
+ if (!normalizedUrl) {
+ return false;
+ }
+
+ if (normalizedUrl.startsWith("data:video/")) {
+ return true;
+ }
+
+ return VIDEO_FILE_NAME_PATTERN.test(normalizedUrl);
+}
+
+function getItemIdentityKey(item) {
+ if (!item || typeof item !== "object") {
+ return "";
+ }
+
+ return toNonEmptyString(
+ item.doc_id
+ || item.source_url
+ || item.full_video_url
+ || item.preview_video_url
+ || item.title
+ );
+}
+
+function pushUniqueVideoItem(targetItems, seenKeys, item) {
+ if (!item) {
+ return;
+ }
+
+ const identityKey = getItemIdentityKey(item);
+ if (!identityKey || seenKeys.has(identityKey)) {
+ return;
+ }
+
+ seenKeys.add(identityKey);
+ targetItems.push(item);
+}
+
+function normalizeVideoItem(rawItem, index = 0) {
+ const normalizedRawItem = typeof rawItem === "string" ? { video_url: rawItem } : rawItem;
+ if (!normalizedRawItem || typeof normalizedRawItem !== "object") {
+ return null;
+ }
+
+ const citationReference = parseDocIdAndPage(
+ toNonEmptyString(normalizedRawItem.citation_id || normalizedRawItem.citationId)
+ );
+ const docId = toNonEmptyString(normalizedRawItem.doc_id || normalizedRawItem.docId || citationReference.docId);
+ const fileName = toNonEmptyString(normalizedRawItem.file_name || normalizedRawItem.fileName);
+ const previewVideoUrl = resolveVideoUrlValue(normalizedRawItem.preview_video_url || normalizedRawItem.previewVideoUrl);
+ const fullVideoUrl = resolveVideoUrlValue(
+ normalizedRawItem.full_video_url
+ || normalizedRawItem.fullVideoUrl
+ || normalizedRawItem.video_url
+ || normalizedRawItem.videoUrl
+ || normalizedRawItem.url
+ );
+ const workspaceVideoUrl = buildWorkspaceVideoUrl(docId);
+ const resolvedPreviewVideoUrl = previewVideoUrl || fullVideoUrl || workspaceVideoUrl;
+ const resolvedFullVideoUrl = fullVideoUrl || previewVideoUrl || workspaceVideoUrl;
+ const mimeType = toNonEmptyString(normalizedRawItem.mime).toLowerCase();
+ const resultType = toNonEmptyString(normalizedRawItem.type).toLowerCase();
+ const hasVideoHint = Boolean(
+ isLikelyVideoUrl(resolvedPreviewVideoUrl)
+ || isLikelyVideoUrl(resolvedFullVideoUrl)
+ || isLikelyVideoFileName(fileName)
+ || mimeType.startsWith("video/")
+ || resultType === "video_url"
+ );
+
+ if (!hasVideoHint || (!docId && !resolvedPreviewVideoUrl && !resolvedFullVideoUrl)) {
+ return null;
+ }
+
+ const title = toNonEmptyString(normalizedRawItem.title || normalizedRawItem.name || fileName)
+ || `Video ${index + 1}`;
+
+ return {
+ id: toNonEmptyString(
+ normalizedRawItem.id
+ || normalizedRawItem.citation_id
+ || normalizedRawItem.citationId
+ || docId
+ || resolvedFullVideoUrl
+ || title
+ ) || `video-${index + 1}`,
+ title,
+ description: toNonEmptyString(
+ normalizedRawItem.description
+ || normalizedRawItem.summary
+ || normalizedRawItem.caption
+ ),
+ file_name: fileName,
+ doc_id: docId,
+ poster_url: resolveVideoUrlValue(
+ normalizedRawItem.poster_url
+ || normalizedRawItem.posterUrl
+ || normalizedRawItem.thumbnail_url
+ || normalizedRawItem.thumbnailUrl
+ ),
+ preview_video_url: resolvedPreviewVideoUrl,
+ full_video_url: resolvedFullVideoUrl,
+ source_label: deriveSourceLabel(
+ docId,
+ resolvedFullVideoUrl || resolvedPreviewVideoUrl,
+ normalizedRawItem.source_label || normalizedRawItem.sourceLabel
+ ),
+ source_url: toNonEmptyString(
+ normalizedRawItem.source_url
+ || normalizedRawItem.sourceUrl
+ || normalizedRawItem.url
+ ),
+ };
+}
+
+function normalizeWorkspaceCitationVideoItem(rawCitation, index = 0) {
+ if (!rawCitation || typeof rawCitation !== "object") {
+ return null;
+ }
+
+ const fileName = toNonEmptyString(rawCitation.file_name || rawCitation.fileName);
+ if (!isLikelyVideoFileName(fileName)) {
+ return null;
+ }
+
+ const citationReference = parseDocIdAndPage(
+ toNonEmptyString(rawCitation.citation_id || rawCitation.citationId)
+ );
+ const docId = toNonEmptyString(rawCitation.doc_id || rawCitation.docId || citationReference.docId);
+ if (!docId) {
+ return null;
+ }
+
+ const workspaceVideoUrl = buildWorkspaceVideoUrl(docId);
+
+ return {
+ id: docId || `workspace-video-${index + 1}`,
+ title: fileName || `Workspace video ${index + 1}`,
+ description: "",
+ file_name: fileName,
+ doc_id: docId,
+ poster_url: "",
+ preview_video_url: workspaceVideoUrl,
+ full_video_url: workspaceVideoUrl,
+ source_label: deriveSourceLabel(
+ docId,
+ workspaceVideoUrl,
+ rawCitation.source_label || rawCitation.sourceLabel
+ ),
+ source_url: "",
+ };
+}
+
+function extractWorkspaceCitationVideoItems(hybridCitations = [], seenKeys = new Set()) {
+ const items = [];
+ if (!Array.isArray(hybridCitations) || hybridCitations.length === 0) {
+ return items;
+ }
+
+ hybridCitations.forEach((citation, index) => {
+ pushUniqueVideoItem(items, seenKeys, normalizeWorkspaceCitationVideoItem(citation, index));
+ });
+
+ return items;
+}
+
+function normalizeWebCitationVideoItem(rawCitation, index = 0) {
+ const normalizedCitation = typeof rawCitation === "string" ? { url: rawCitation } : rawCitation;
+ if (!normalizedCitation || typeof normalizedCitation !== "object") {
+ return null;
+ }
+
+ const videoUrl = resolveVideoUrlValue(
+ normalizedCitation.video_url
+ || normalizedCitation.videoUrl
+ || normalizedCitation.url
+ );
+ const fileName = toNonEmptyString(normalizedCitation.file_name || normalizedCitation.fileName);
+ if (!isLikelyVideoUrl(videoUrl) && !isLikelyVideoFileName(fileName)) {
+ return null;
+ }
+
+ const title = toNonEmptyString(normalizedCitation.title || normalizedCitation.name || fileName)
+ || `Video ${index + 1}`;
+
+ return {
+ id: toNonEmptyString(normalizedCitation.id || videoUrl || title) || `linked-video-${index + 1}`,
+ title,
+ description: toNonEmptyString(
+ normalizedCitation.description
+ || normalizedCitation.summary
+ || normalizedCitation.snippet
+ ),
+ file_name: fileName,
+ doc_id: "",
+ poster_url: resolveVideoUrlValue(
+ normalizedCitation.poster_url
+ || normalizedCitation.posterUrl
+ || normalizedCitation.thumbnail_url
+ || normalizedCitation.thumbnailUrl
+ ),
+ preview_video_url: videoUrl,
+ full_video_url: videoUrl,
+ source_label: deriveSourceLabel(
+ "",
+ videoUrl,
+ normalizedCitation.source_label || normalizedCitation.sourceLabel
+ ),
+ source_url: toNonEmptyString(
+ normalizedCitation.source_url
+ || normalizedCitation.sourceUrl
+ || normalizedCitation.url
+ ),
+ };
+}
+
+function extractLinkedVideoItems(webCitations = [], seenKeys = new Set()) {
+ const items = [];
+ if (!Array.isArray(webCitations) || webCitations.length === 0) {
+ return items;
+ }
+
+ webCitations.forEach((citation, index) => {
+ pushUniqueVideoItem(items, seenKeys, normalizeWebCitationVideoItem(citation, index));
+ });
+
+ return items;
+}
+
+function buildVideoGalleryResult(title, summary, items, sourceActionName, totalCount = items.length) {
+ const renderedItems = Array.isArray(items) ? items.slice(0, MAX_INLINE_VIDEO_ITEMS) : [];
+ if (renderedItems.length === 0) {
+ return null;
+ }
+
+ return {
+ success: true,
+ render_type: INLINE_VIDEO_GALLERY_RENDER_TYPE,
+ video_gallery: {
+ title,
+ summary,
+ items: renderedItems,
+ total_count: Number(totalCount) || renderedItems.length,
+ rendered_count: renderedItems.length,
+ source_action_name: sourceActionName,
+ },
+ };
+}
+
+function extractRawVideoItems(candidate) {
+ if (!candidate || typeof candidate !== "object") {
+ return [];
+ }
+
+ if (candidate.video_gallery && typeof candidate.video_gallery === "object") {
+ const galleryItems = candidate.video_gallery.items;
+ return Array.isArray(galleryItems) ? galleryItems : [];
+ }
+
+ if (Array.isArray(candidate.items)) {
+ return candidate.items;
+ }
+
+ if (Array.isArray(candidate.videos)) {
+ return candidate.videos;
+ }
+
+ if (Array.isArray(candidate.video_urls)) {
+ return candidate.video_urls;
+ }
+
+ const directVideoUrl = resolveVideoUrlValue(candidate.video_url || candidate.url);
+ const directMime = toNonEmptyString(candidate.mime).toLowerCase();
+ const directType = toNonEmptyString(candidate.type).toLowerCase();
+ if (directVideoUrl || directType === "video_url" || directMime.startsWith("video/")) {
+ return [{
+ video_url: directVideoUrl,
+ title: candidate.title,
+ description: candidate.description || candidate.summary,
+ file_name: candidate.file_name || candidate.fileName,
+ source_label: candidate.source_label || candidate.sourceLabel,
+ source_url: candidate.source_url || candidate.sourceUrl || candidate.url,
+ mime: candidate.mime,
+ type: candidate.type,
+ poster_url: candidate.poster_url || candidate.posterUrl || candidate.thumbnail_url || candidate.thumbnailUrl,
+ }];
+ }
+
+ return [];
+}
+
+function normalizeVideoGalleryResult(result, maxItems = MAX_INLINE_VIDEO_ITEMS) {
+ if (!result || typeof result !== "object" || result.success === false || maxItems <= 0) {
+ return null;
+ }
+
+ const galleryCandidate = result.video_gallery && typeof result.video_gallery === "object"
+ ? result.video_gallery
+ : result;
+ const rawItems = extractRawVideoItems(result);
+ if (!Array.isArray(rawItems) || rawItems.length === 0) {
+ return null;
+ }
+
+ const normalizedItems = rawItems
+ .map((item, index) => normalizeVideoItem(item, index))
+ .filter(Boolean);
+ if (normalizedItems.length === 0) {
+ return null;
+ }
+
+ const renderedItems = normalizedItems.slice(0, Math.max(0, maxItems));
+ if (renderedItems.length === 0) {
+ return null;
+ }
+
+ const totalCount = Number.isFinite(Number(galleryCandidate.total_count || galleryCandidate.totalCount))
+ ? Number(galleryCandidate.total_count || galleryCandidate.totalCount)
+ : normalizedItems.length;
+ const galleryTitle = toNonEmptyString(galleryCandidate.title || galleryCandidate.label || result.title)
+ || (totalCount === 1 ? "Video result" : "Video results");
+ const gallerySummary = toNonEmptyString(galleryCandidate.summary || galleryCandidate.description || result.summary)
+ || (renderedItems.length === 1
+ ? "Relevant video returned for this result."
+ : "Relevant videos returned for this result.");
+
+ return {
+ ...result,
+ render_type: result.render_type || INLINE_VIDEO_GALLERY_RENDER_TYPE,
+ video_gallery: {
+ title: galleryTitle,
+ summary: gallerySummary,
+ items: renderedItems,
+ total_count: totalCount,
+ rendered_count: renderedItems.length,
+ source_action_name: toNonEmptyString(
+ galleryCandidate.source_action_name || galleryCandidate.sourceActionName || result.source_action_name
+ ),
+ },
+ };
+}
+
+async function hydrateInlineVideoGalleryCitation(conversationId, artifactId) {
+ try {
+ const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId);
+ return normalizeVideoGalleryResult(getCitationResult(hydratedCitation));
+ } catch (error) {
+ console.warn("Failed to hydrate inline video gallery citation artifact", error);
+ return null;
+ }
+}
+
+async function resolveInlineVideoGallery(citation, conversationId, maxItems = MAX_INLINE_VIDEO_ITEMS) {
+ const shouldPreferArtifact = Boolean(
+ citation?.raw_payload_externalized
+ && citation?.artifact_id
+ && conversationId
+ );
+
+ if (shouldPreferArtifact) {
+ const hydratedResult = await hydrateInlineVideoGalleryCitation(conversationId, citation.artifact_id);
+ const normalizedHydratedResult = normalizeVideoGalleryResult(hydratedResult, maxItems);
+ if (normalizedHydratedResult) {
+ return normalizedHydratedResult;
+ }
+
+ if (hydratedResult) {
+ return hydratedResult;
+ }
+ }
+
+ const localResult = normalizeVideoGalleryResult(getCitationResult(citation), maxItems);
+ if (localResult) {
+ return localResult;
+ }
+
+ if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) {
+ return null;
+ }
+
+ const fallbackHydratedResult = await hydrateInlineVideoGalleryCitation(conversationId, citation.artifact_id);
+ return normalizeVideoGalleryResult(fallbackHydratedResult, maxItems);
+}
+
+function buildVideoDetailsRows(item) {
+ const rows = [];
+
+ if (item.source_label) {
+ rows.push(`
+
+ Source
+ ${escapeHtml(item.source_label)}
+
+ `);
+ }
+
+ if (item.file_name) {
+ rows.push(`
+
+ File
+ ${escapeHtml(item.file_name)}
+
+ `);
+ }
+
+ if (item.doc_id) {
+ rows.push(`
+
+ Document ID
+ ${escapeHtml(item.doc_id)}
+
+ `);
+ }
+
+ if (item.source_url) {
+ rows.push(`
+
+ `);
+ }
+
+ return rows.join("");
+}
+
+function getInlineVideoDetailsModal() {
+ let modalContainer = document.getElementById("inline-video-details-modal");
+ if (modalContainer) {
+ return modalContainer;
+ }
+
+ modalContainer = document.createElement("div");
+ modalContainer.id = "inline-video-details-modal";
+ modalContainer.className = "modal fade";
+ modalContainer.tabIndex = -1;
+ modalContainer.setAttribute("aria-hidden", "true");
+ modalContainer.innerHTML = `
+
+ `;
+ document.body.appendChild(modalContainer);
+ return modalContainer;
+}
+
+function showInlineVideoDetailsModal(item) {
+ const modalContainer = getInlineVideoDetailsModal();
+ const titleEl = modalContainer.querySelector("#inline-video-details-title");
+ const previewEl = modalContainer.querySelector("#inline-video-details-preview");
+ const descriptionEl = modalContainer.querySelector("#inline-video-details-description");
+ const metaEl = modalContainer.querySelector("#inline-video-details-meta");
+
+ if (titleEl) {
+ titleEl.textContent = item.title || "Video details";
+ }
+
+ if (previewEl) {
+ previewEl.pause();
+ previewEl.src = item.full_video_url || item.preview_video_url;
+ if (item.poster_url) {
+ previewEl.setAttribute("poster", item.poster_url);
+ } else {
+ previewEl.removeAttribute("poster");
+ }
+ previewEl.load();
+ }
+
+ if (descriptionEl) {
+ const hasDescription = Boolean(item.description);
+ descriptionEl.classList.toggle("d-none", !hasDescription);
+ descriptionEl.textContent = item.description || "";
+ }
+
+ if (metaEl) {
+ metaEl.innerHTML = buildVideoDetailsRows(item);
+ }
+
+ modalContainer.addEventListener("hidden.bs.modal", () => {
+ if (previewEl) {
+ previewEl.pause();
+ previewEl.currentTime = 0;
+ }
+ }, { once: true });
+
+ const modal = new bootstrap.Modal(modalContainer);
+ modal.show();
+}
+
+function createBadge(label, value) {
+ const badge = document.createElement("span");
+ badge.className = "inline-video-gallery-badge";
+ badge.textContent = `${label}: ${value}`;
+ return badge;
+}
+
+function createVideoTile(item) {
+ const tile = document.createElement("article");
+ tile.className = "inline-video-gallery-item";
+ tile.innerHTML = `
+
+
+
+
+
+
+
+
${escapeHtml(item.source_label)}
+
${escapeHtml(item.title || "Video")}
+ ${item.description ? `
${escapeHtml(item.description)}
` : ""}
+
+ `;
+
+ const videoEl = tile.querySelector(".inline-video-gallery-item-video");
+ const infoButton = tile.querySelector(".inline-video-gallery-info-btn");
+ if (videoEl) {
+ videoEl.src = item.preview_video_url || item.full_video_url;
+ if (item.poster_url) {
+ videoEl.setAttribute("poster", item.poster_url);
+ }
+ }
+
+ if (infoButton) {
+ infoButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ showInlineVideoDetailsModal(item);
+ });
+ }
+
+ return tile;
+}
+
+function createVideoGalleryCard(result) {
+ const payload = result.video_gallery || {};
+ const card = document.createElement("section");
+ card.className = "inline-video-gallery-card";
+
+ const renderedCount = Number(payload.rendered_count || (payload.items || []).length || 0);
+ const totalCount = Number(payload.total_count || renderedCount || 0);
+ const summaryText = payload.summary || result.summary || "Relevant video results.";
+
+ card.innerHTML = `
+
+
+
+ `;
+
+ const badgesContainer = card.querySelector(".inline-video-gallery-badges");
+ if (badgesContainer) {
+ badgesContainer.appendChild(createBadge("Videos", renderedCount));
+ if (totalCount > renderedCount) {
+ badgesContainer.appendChild(createBadge("Showing", `${renderedCount} of ${totalCount}`));
+ }
+ }
+
+ const grid = card.querySelector(".inline-video-gallery-grid");
+ if (grid) {
+ (payload.items || []).forEach((item) => {
+ grid.appendChild(createVideoTile(item));
+ });
+ }
+
+ return { card };
+}
+
+export async function renderInlineVideoGalleries(
+ messageElement,
+ hybridCitations = [],
+ webCitations = [],
+ agentCitations = [],
+ conversationId = ""
+) {
+ if (!messageElement) {
+ return;
+ }
+
+ const container = messageElement.querySelector(".inline-visualizations-container");
+ if (!container) {
+ return;
+ }
+
+ container.querySelectorAll(".inline-video-gallery-card").forEach((card) => card.remove());
+
+ const hasHybridCitations = Array.isArray(hybridCitations) && hybridCitations.length > 0;
+ const hasWebCitations = Array.isArray(webCitations) && webCitations.length > 0;
+ const hasAgentCitations = Array.isArray(agentCitations) && agentCitations.length > 0;
+ if (!hasHybridCitations && !hasWebCitations && !hasAgentCitations) {
+ container.classList.toggle("d-none", container.children.length === 0);
+ return;
+ }
+
+ let remainingSlots = MAX_INLINE_VIDEO_ITEMS;
+ const seenVideoKeys = new Set();
+
+ const workspaceItems = extractWorkspaceCitationVideoItems(hybridCitations, seenVideoKeys);
+ if (workspaceItems.length > 0 && remainingSlots > 0) {
+ const workspaceGallery = buildVideoGalleryResult(
+ "Workspace videos",
+ "Video sources cited from workspace content.",
+ workspaceItems.slice(0, remainingSlots),
+ "Workspace citations",
+ workspaceItems.length
+ );
+ if (workspaceGallery) {
+ const { card } = createVideoGalleryCard(workspaceGallery);
+ container.appendChild(card);
+ remainingSlots -= workspaceGallery.video_gallery.rendered_count || 0;
+ }
+ }
+
+ const linkedItems = extractLinkedVideoItems(webCitations, seenVideoKeys);
+ if (linkedItems.length > 0 && remainingSlots > 0) {
+ const linkedGallery = buildVideoGalleryResult(
+ "Linked videos",
+ "Direct video links returned with this response.",
+ linkedItems.slice(0, remainingSlots),
+ "Linked sources",
+ linkedItems.length
+ );
+ if (linkedGallery) {
+ const { card } = createVideoGalleryCard(linkedGallery);
+ container.appendChild(card);
+ remainingSlots -= linkedGallery.video_gallery.rendered_count || 0;
+ }
+ }
+
+ for (let index = 0; index < agentCitations.length && remainingSlots > 0; index += 1) {
+ const citation = agentCitations[index];
+ const result = await resolveInlineVideoGallery(citation, conversationId, remainingSlots);
+ if (!result) {
+ continue;
+ }
+
+ const normalizedItems = Array.isArray(result?.video_gallery?.items)
+ ? result.video_gallery.items.filter((item) => {
+ const identityKey = getItemIdentityKey(item);
+ if (!identityKey || seenVideoKeys.has(identityKey)) {
+ return false;
+ }
+
+ seenVideoKeys.add(identityKey);
+ return true;
+ })
+ : [];
+ if (normalizedItems.length === 0) {
+ continue;
+ }
+
+ result.video_gallery.items = normalizedItems;
+ result.video_gallery.rendered_count = normalizedItems.length;
+
+ const { card } = createVideoGalleryCard(result);
+ container.appendChild(card);
+ remainingSlots -= normalizedItems.length;
+ }
+
+ container.classList.toggle("d-none", container.children.length === 0);
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js
index d350eb3a..a13b78a3 100644
--- a/application/single_app/static/js/chat/chat-messages.js
+++ b/application/single_app/static/js/chat/chat-messages.js
@@ -21,6 +21,10 @@ import { sendMessageWithStreaming } from "./chat-streaming.js";
import { getCurrentReasoningEffort, isReasoningEffortEnabled } from './chat-reasoning.js';
import { areAgentsEnabled } from './chat-agents.js';
import { createThoughtsToggleHtml, attachThoughtsToggleListener } from './chat-thoughts.js';
+import { extractInlineChartBlocks, hydrateInlineCharts, injectInlineChartHtml, restoreInlineChartTokens } from './chat-inline-charts.js';
+import { renderInlineVideoGalleries } from './chat-inline-videos.js';
+import { renderInlineImageGalleries } from './chat-inline-images.js';
+import { renderInlineAzureMaps } from './chat-inline-maps.js';
// Conditionally import TTS if enabled
let ttsModule = null;
@@ -34,6 +38,68 @@ if (typeof window.appSettings !== 'undefined' && window.appSettings.enable_text_
});
}
+const documentActionSelect = document.getElementById('document-action-select');
+const documentComparisonLeftContainer = document.getElementById('document-comparison-left-container');
+const documentComparisonLeftSelect = document.getElementById('document-comparison-left-select');
+const DOCUMENT_ACTION_NONE = 'none';
+const DOCUMENT_ACTION_EXHAUSTIVE_REVIEW = 'exhaustive_review';
+const DOCUMENT_ACTION_COMPARISON = 'comparison';
+const CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 3;
+const WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 10;
+
+function getSelectedDocumentIds() {
+ const docSel = document.getElementById('document-select');
+ if (!docSel) {
+ return [];
+ }
+
+ return Array.from(docSel.selectedOptions)
+ .map(option => option.value)
+ .filter(value => value);
+}
+
+function getDocumentActionType() {
+ return String(documentActionSelect?.value || DOCUMENT_ACTION_NONE).trim() || DOCUMENT_ACTION_NONE;
+}
+
+function syncComparisonLeftOptions() {
+ if (!documentComparisonLeftSelect) {
+ return;
+ }
+
+ const selectedDocumentIds = getSelectedDocumentIds();
+ const previousSelection = String(documentComparisonLeftSelect.value || '').trim();
+ documentComparisonLeftSelect.innerHTML = '';
+
+ selectedDocumentIds.forEach((documentId, index) => {
+ const metadata = getDocumentMetadata(documentId) || {};
+ const option = document.createElement('option');
+ option.value = documentId;
+ option.textContent = metadata.name || metadata.file_name || metadata.filename || documentId;
+ if ((previousSelection && previousSelection === documentId) || (!previousSelection && index === 0)) {
+ option.selected = true;
+ }
+ documentComparisonLeftSelect.appendChild(option);
+ });
+}
+
+function updateDocumentActionControls() {
+ const actionType = getDocumentActionType();
+ const selectedDocumentIds = getSelectedDocumentIds();
+ const showComparisonLeftSelector = actionType === DOCUMENT_ACTION_COMPARISON && selectedDocumentIds.length > 0;
+
+ if (documentComparisonLeftContainer) {
+ documentComparisonLeftContainer.classList.toggle('d-none', !showComparisonLeftSelector);
+ }
+
+ if (showComparisonLeftSelector) {
+ syncComparisonLeftOptions();
+ }
+}
+
+documentActionSelect?.addEventListener('change', updateDocumentActionControls);
+window.addEventListener('chat:document-selection-changed', updateDocumentActionControls);
+
/**
* Unwraps markdown tables that are mistakenly wrapped in code blocks.
* This fixes the issue where AI responses contain tables in code blocks,
@@ -359,11 +425,31 @@ export function updateSendButtonVisibility() {
// Make function available globally for inline oninput handler
window.handleInputChange = updateSendButtonVisibility;
+function resolveMessageConversationId(fullMessageObject = null) {
+ const conversationCandidates = [
+ fullMessageObject?.conversation_id,
+ fullMessageObject?.metadata?.source_conversation_id,
+ fullMessageObject?.source_conversation_id,
+ window.chatConversations?.getCurrentConversationId?.(),
+ window.currentConversationId,
+ ];
+
+ for (const candidate of conversationCandidates) {
+ const normalizedConversationId = String(candidate || '').trim();
+ if (normalizedConversationId) {
+ return normalizedConversationId;
+ }
+ }
+
+ return '';
+}
+
function createCitationsHtml(
hybridCitations = [],
webCitations = [],
agentCitations = [],
- messageId
+ messageId,
+ messageConversationId = ""
) {
let citationsHtml = "";
let hasCitations = false;
@@ -449,7 +535,7 @@ function createCitationsHtml(
data-tool-args="${escapeHtml(toolArgs)}"
data-tool-result="${escapeHtml(toolResult)}"
data-artifact-id="${escapeHtml(cite.artifact_id || '')}"
- data-conversation-id="${escapeHtml(window.currentConversationId || '')}"
+ data-conversation-id="${escapeHtml(messageConversationId)}"
title="Agent tool: ${escapeHtml(displayText)} - Click to view details">
${escapeHtml(displayText)}
`;
@@ -470,7 +556,12 @@ export function loadMessages(conversationId) {
// Clear search highlights when loading a different conversation
clearSearchHighlight();
- return fetch(`/conversation/${conversationId}/messages`)
+ return fetch(`/conversation/${conversationId}/messages?ts=${Date.now()}`, {
+ cache: "no-store",
+ headers: {
+ "Cache-Control": "no-cache",
+ },
+ })
.then((response) => response.json())
.then((data) => {
const chatbox = document.getElementById("chatbox");
@@ -554,6 +645,382 @@ export function loadMessages(conversationId) {
});
}
+const collaboratorProfileImageCache = new Map();
+
+function stripHtmlTags(value) {
+ const tempElement = document.createElement("div");
+ tempElement.innerHTML = String(value ?? "");
+ return tempElement.textContent || tempElement.innerText || "";
+}
+
+function buildPlainTextPreview(value, maxLength = 160) {
+ const normalizedValue = String(value ?? "").replace(/\s+/g, " ").trim();
+ if (!normalizedValue) {
+ return "No message content";
+ }
+ if (normalizedValue.length <= maxLength) {
+ return normalizedValue;
+ }
+ return `${normalizedValue.slice(0, maxLength - 3)}...`;
+}
+
+function getMessageSenderUserId(fullMessageObject = null) {
+ const senderUserId = String(
+ fullMessageObject?.sender?.user_id || fullMessageObject?.metadata?.sender?.user_id || ""
+ ).trim();
+ return senderUserId || null;
+}
+
+function getMessageSenderDisplayName(fullMessageObject = null, fallbackLabel = "Participant") {
+ const senderDisplayName = String(
+ fullMessageObject?.sender?.display_name
+ || fullMessageObject?.metadata?.sender?.display_name
+ || fallbackLabel
+ ).trim();
+ return senderDisplayName || fallbackLabel;
+}
+
+function getInitials(name) {
+ const words = String(name ?? "")
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (words.length === 0) {
+ return "?";
+ }
+
+ return words
+ .slice(0, 2)
+ .map(word => word.charAt(0).toUpperCase())
+ .join("");
+}
+
+function createCollaboratorAvatarHtml(fullMessageObject, senderLabel) {
+ const senderUserId = getMessageSenderUserId(fullMessageObject);
+ const cachedProfileImage = senderUserId ? collaboratorProfileImageCache.get(senderUserId) : null;
+ const altText = `${senderLabel} Avatar`;
+
+ if (cachedProfileImage) {
+ return `
`;
+ }
+
+ return `
+
+ ${escapeHtml(getInitials(senderLabel))}
+
`;
+}
+
+function hydrateCollaboratorAvatar(messageDiv, senderUserId, senderLabel) {
+ if (!messageDiv || !senderUserId) {
+ return;
+ }
+
+ const avatarElement = messageDiv.querySelector(".collaborator-avatar");
+ if (!avatarElement) {
+ return;
+ }
+
+ const cachedProfileImage = collaboratorProfileImageCache.get(senderUserId);
+ if (cachedProfileImage) {
+ if (avatarElement.tagName === "IMG") {
+ avatarElement.src = cachedProfileImage;
+ avatarElement.alt = `${senderLabel} Avatar`;
+ } else {
+ const imageElement = document.createElement("img");
+ imageElement.src = cachedProfileImage;
+ imageElement.alt = `${senderLabel} Avatar`;
+ imageElement.className = "avatar collaborator-avatar";
+ imageElement.dataset.avatarUserId = senderUserId;
+ avatarElement.replaceWith(imageElement);
+ }
+ return;
+ }
+
+ fetch(`/api/user/profile-image/${encodeURIComponent(senderUserId)}`, {
+ credentials: "same-origin",
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error("Failed to load user profile image");
+ }
+ return response.json();
+ })
+ .then(userData => {
+ const profileImage = String(userData?.profile_image || "").trim();
+ if (!profileImage) {
+ return;
+ }
+
+ collaboratorProfileImageCache.set(senderUserId, profileImage);
+ if (avatarElement.tagName === "IMG") {
+ avatarElement.src = profileImage;
+ avatarElement.alt = `${senderLabel} Avatar`;
+ } else {
+ const imageElement = document.createElement("img");
+ imageElement.src = profileImage;
+ imageElement.alt = `${senderLabel} Avatar`;
+ imageElement.className = "avatar collaborator-avatar";
+ imageElement.dataset.avatarUserId = senderUserId;
+ avatarElement.replaceWith(imageElement);
+ }
+ })
+ .catch(() => {
+ console.debug("Could not load profile image for collaborator:", senderUserId);
+ });
+}
+
+function buildReplyContextFromMessage(message = null) {
+ if (!message) {
+ return null;
+ }
+
+ const messageId = String(message.id || "").trim();
+ if (!messageId) {
+ return null;
+ }
+
+ return {
+ message_id: messageId,
+ sender_display_name: getMessageSenderDisplayName(
+ message,
+ message.role === "assistant" ? "AI" : "Participant"
+ ),
+ content_preview: buildPlainTextPreview(
+ message.content || message.metadata?.last_message_preview || ""
+ ),
+ };
+}
+
+function resolveReplyContextFromDom(messageId) {
+ if (!messageId) {
+ return null;
+ }
+
+ const replyElement = document.querySelector(`[data-message-id="${messageId}"]`);
+ if (!replyElement) {
+ return null;
+ }
+
+ const senderDisplayName = String(
+ replyElement.dataset.replySenderName
+ || replyElement.querySelector(".message-sender")?.textContent
+ || "Participant"
+ )
+ .replace(/\s+/g, " ")
+ .trim();
+ const contentPreview = String(
+ replyElement.dataset.replyPreviewText
+ || buildPlainTextPreview(replyElement.querySelector(".message-text")?.textContent || "")
+ ).trim();
+
+ return {
+ message_id: messageId,
+ sender_display_name: senderDisplayName || "Participant",
+ content_preview: contentPreview || "No message content",
+ };
+}
+
+function resolveReplyContext(fullMessageObject = null) {
+ const replyMessageContext = buildReplyContextFromMessage(fullMessageObject?.reply_message);
+ if (replyMessageContext) {
+ return replyMessageContext;
+ }
+
+ const metadataReplyContext = fullMessageObject?.metadata?.reply_context;
+ if (metadataReplyContext) {
+ return {
+ message_id: String(metadataReplyContext.message_id || "").trim(),
+ sender_display_name: String(metadataReplyContext.sender_display_name || "Participant").trim() || "Participant",
+ content_preview: buildPlainTextPreview(metadataReplyContext.content_preview || ""),
+ };
+ }
+
+ const replyToMessageId = String(fullMessageObject?.reply_to_message_id || "").trim();
+ if (!replyToMessageId) {
+ return null;
+ }
+
+ return resolveReplyContextFromDom(replyToMessageId);
+}
+
+function renderReplyQuoteHtml(fullMessageObject = null) {
+ const replyContext = resolveReplyContext(fullMessageObject);
+ if (!replyContext) {
+ return "";
+ }
+
+ return `
+
+
Replying to ${escapeHtml(replyContext.sender_display_name || "Participant")}
+
${escapeHtml(replyContext.content_preview || "No message content")}
+
`;
+}
+
+ function escapeMentionPattern(value) {
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ function buildAtMentionPattern(displayName) {
+ return new RegExp(
+ `(^|\\s)@${escapeMentionPattern(displayName)}(?=$|\\s|[.,!?;:])`,
+ "gi"
+ );
+ }
+
+ function normalizeStructuredMessageContent(messageContent) {
+ return String(messageContent ?? "")
+ .replace(/[ \t]{2,}/g, " ")
+ .replace(/\s+\n/g, "\n")
+ .replace(/\n\s+/g, "\n")
+ .replace(/\s+([.,!?;:])/g, "$1")
+ .trim();
+ }
+
+ function stripInlineAzureMapsBlocks(messageContent) {
+ const normalizedContent = String(messageContent ?? "");
+ if (!normalizedContent.includes("{{map:")) {
+ return normalizedContent;
+ }
+
+ return normalizeStructuredMessageContent(
+ normalizedContent
+ .replace(/\n?\{\{map:[\s\S]*?\}\}\n?/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ );
+ }
+
+ function getMentionedParticipants(fullMessageObject = null) {
+ const rawMentions = Array.isArray(fullMessageObject?.metadata?.mentioned_participants)
+ ? fullMessageObject.metadata.mentioned_participants
+ : [];
+
+ return rawMentions
+ .map(participant => ({
+ user_id: String(participant?.user_id || "").trim(),
+ display_name: String(participant?.display_name || participant?.name || participant?.email || "").trim(),
+ email: String(participant?.email || "").trim(),
+ }))
+ .filter(participant => participant.user_id && participant.display_name);
+ }
+
+ function stripMentionTextFromMessageContent(messageContent, fullMessageObject = null) {
+ let normalizedMessageContent = String(messageContent ?? "");
+ if (!normalizedMessageContent.trim()) {
+ return normalizedMessageContent;
+ }
+
+ const mentions = getMentionedParticipants(fullMessageObject)
+ .slice()
+ .sort((left, right) => right.display_name.length - left.display_name.length);
+ if (mentions.length === 0) {
+ return normalizedMessageContent;
+ }
+
+ mentions.forEach(participant => {
+ const displayName = String(participant.display_name || "").trim();
+ if (!displayName) {
+ return;
+ }
+
+ const mentionPattern = buildAtMentionPattern(displayName);
+ normalizedMessageContent = normalizedMessageContent.replace(
+ mentionPattern,
+ (match, leadingWhitespace) => leadingWhitespace || ""
+ );
+ });
+
+ const invocationTarget = getInvocationTarget(fullMessageObject);
+ if (invocationTarget?.display_name) {
+ normalizedMessageContent = normalizedMessageContent.replace(
+ buildAtMentionPattern(invocationTarget.display_name),
+ (match, leadingWhitespace) => leadingWhitespace || ""
+ );
+ }
+
+ return normalizeStructuredMessageContent(normalizedMessageContent);
+ }
+
+ function renderMentionTagsHtml(fullMessageObject = null) {
+ const mentions = getMentionedParticipants(fullMessageObject);
+ if (mentions.length === 0) {
+ return "";
+ }
+
+ const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || "").trim();
+ const mentionChipsHtml = mentions.map(participant => {
+ const isCurrentUser = currentUserId && participant.user_id === currentUserId;
+ const currentUserClass = isCurrentUser ? " collaboration-mention-chip-current-user" : "";
+ return `
@${escapeHtml(participant.display_name)} `;
+ }).join("");
+
+ return `
+
+
Tagged
+
${mentionChipsHtml}
+
`;
+ }
+
+ function getInvocationTarget(fullMessageObject = null) {
+ const target = fullMessageObject?.metadata?.ai_invocation_target;
+ if (!target || typeof target !== "object") {
+ return null;
+ }
+
+ const displayName = String(target.display_name || target.label || "").trim();
+ if (!displayName) {
+ return null;
+ }
+
+ const targetType = String(target.target_type || target.type || "model").trim() || "model";
+ const sourceMode = String(target.source_mode || target.mode || "").trim() || null;
+ return {
+ target_type: targetType,
+ display_name: displayName,
+ mention_text: String(target.mention_text || `@${displayName}`).trim() || `@${displayName}`,
+ source_mode: sourceMode,
+ };
+ }
+
+ function renderInvocationTargetHtml(fullMessageObject = null) {
+ const invocationTarget = getInvocationTarget(fullMessageObject);
+ if (!invocationTarget) {
+ return "";
+ }
+
+ const targetTypeClass = ` collaboration-mention-chip-target-${String(invocationTarget.target_type || "model")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]/g, "") || "model"}`;
+
+ return `
+
+
+ ${escapeHtml(invocationTarget.mention_text)}
+
+
`;
+ }
+
+ export function renderAiMessageContent(messageContent) {
+ let cleaned = stripInlineAzureMapsBlocks(messageContent).trim().replace(/\n{3,}/g, "\n\n");
+ cleaned = cleaned.replace(/(\bhttps?:\/\/\S+)(%5D|\])+/gi, (_, url) => url);
+
+ const chartExtraction = extractInlineChartBlocks(cleaned);
+ const withInlineCitations = parseCitations(chartExtraction.markdown);
+ const withUnwrappedTables = unwrapTablesFromCodeBlocks(withInlineCitations);
+ const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables);
+ const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables);
+ const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables);
+ const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables));
+ const htmlWithCharts = injectInlineChartHtml(sanitizedHtml, chartExtraction.blocks);
+
+ return {
+ htmlContent: addTargetBlankToExternalLinks(htmlWithCharts),
+ copyMarkdown: restoreInlineChartTokens(withInlineCitations, chartExtraction.blocks),
+ previewMarkdown: chartExtraction.markdown,
+ };
+ }
+
export function appendMessage(
sender,
messageContent,
@@ -576,6 +1043,7 @@ export function appendMessage(
let avatarImg = "";
let avatarAltText = "";
+ let avatarHtml = "";
let messageClass = ""; // <<< ENSURE THIS IS DECLARED HERE
let senderLabel = "";
let messageContentHtml = "";
@@ -615,16 +1083,10 @@ export function appendMessage(
senderLabel = "AI";
}
- // Parse content with comprehensive table processing
- let cleaned = messageContent.trim().replace(/\n{3,}/g, "\n\n");
- cleaned = cleaned.replace(/(\bhttps?:\/\/\S+)(%5D|\])+/gi, (_, url) => url);
- const withInlineCitations = parseCitations(cleaned);
- const withUnwrappedTables = unwrapTablesFromCodeBlocks(withInlineCitations);
- const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables);
- const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables);
- const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables);
- const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables));
- const htmlContent = addTargetBlankToExternalLinks(sanitizedHtml);
+ const messageConversationId = resolveMessageConversationId(fullMessageObject);
+
+ const renderedAiContent = renderAiMessageContent(messageContent);
+ const htmlContent = renderedAiContent.htmlContent;
const mainMessageHtml = `
${htmlContent}
`; // Renamed for clarity
@@ -652,7 +1114,7 @@ export function appendMessage(
`;
@@ -692,7 +1154,8 @@ export function appendMessage(
hybridCitations,
webCitations,
agentCitations,
- messageId
+ messageId,
+ messageConversationId
);
console.log(
`Generated citationsButtonsHtml (length ${
@@ -773,6 +1236,7 @@ export function appendMessage(
${senderLabel}
${mainMessageHtml}
+
${citationContentContainerHtml}
${thoughtsHtml.containerHtml}
${metadataContainerHtml}
@@ -780,13 +1244,41 @@ export function appendMessage(
`;
+ messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "AI";
+ messageDiv.dataset.replyPreviewText = buildPlainTextPreview(renderedAiContent.previewMarkdown);
+
messageDiv.classList.add(messageClass); // Add AI message class
chatbox.appendChild(messageDiv); // Append AI message
// Auto-play TTS if enabled (only for new messages, not when loading history)
if (isNewMessage && typeof autoplayTTSIfEnabled === 'function') {
- autoplayTTSIfEnabled(messageId, messageContent);
+ autoplayTTSIfEnabled(messageId, renderedAiContent.previewMarkdown || messageContent);
}
+
+ hydrateInlineCharts(messageDiv);
+ void (async () => {
+ await renderInlineVideoGalleries(
+ messageDiv,
+ hybridCitations || [],
+ webCitations || [],
+ agentCitations || [],
+ messageConversationId
+ );
+ await renderInlineImageGalleries(
+ messageDiv,
+ hybridCitations || [],
+ webCitations || [],
+ agentCitations || [],
+ messageId,
+ messageConversationId
+ );
+ await renderInlineAzureMaps(
+ messageDiv,
+ agentCitations || [],
+ messageId,
+ messageConversationId
+ );
+ })();
// Highlight code blocks in the messages
messageDiv.querySelectorAll('pre code[class^="language-"]').forEach((block) => {
@@ -799,10 +1291,7 @@ export function appendMessage(
// Apply masked state if message has masking
if (fullMessageObject?.metadata) {
- console.log('Applying masked state for AI message:', messageId, fullMessageObject.metadata);
applyMaskedState(messageDiv, fullMessageObject.metadata);
- } else {
- console.log('No metadata found for AI message:', messageId, 'fullMessageObject:', fullMessageObject);
}
// --- Attach Event Listeners specifically for AI message ---
@@ -1035,11 +1524,24 @@ export function appendMessage(
} else {
avatarImg = "/static/images/user-avatar.png";
}
-
+
+ const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject);
const sanitizedUserHtml = DOMPurify.sanitize(
- marked.parse(escapeHtml(messageContent))
+ marked.parse(escapeHtml(renderedMessageContent))
);
messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml);
+ } else if (sender === "Collaborator") {
+ messageClass = "collaborator-message";
+ senderLabel = fullMessageObject?.sender?.display_name
+ || fullMessageObject?.metadata?.sender?.display_name
+ || "Participant";
+ avatarAltText = `${senderLabel} Avatar`;
+ avatarHtml = createCollaboratorAvatarHtml(fullMessageObject, senderLabel);
+ const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject);
+ const sanitizedCollaboratorHtml = DOMPurify.sanitize(
+ marked.parse(escapeHtml(renderedMessageContent))
+ );
+ messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml);
} else if (sender === "File") {
messageClass = "file-message";
senderLabel = "File Added";
@@ -1121,6 +1623,17 @@ export function appendMessage(
// Create message footer for user, image, and file messages
let messageFooterHtml = "";
let metadataContainerHtml = "";
+ const replyQuoteHtml = (sender === "You" || sender === "Collaborator")
+ ? renderReplyQuoteHtml(fullMessageObject)
+ : "";
+ const invocationTargetHtml = (sender === "You" || sender === "Collaborator")
+ ? renderInvocationTargetHtml(fullMessageObject)
+ : "";
+ const mentionTagsHtml = (sender === "You" || sender === "Collaborator")
+ ? renderMentionTagsHtml(fullMessageObject)
+ : "";
+ const hasVisibleMessageText = sender === "image"
+ || Boolean(stripHtmlTags(messageContentHtml || "").replace(/\s+/g, " ").trim());
if (sender === "You") {
const metadataContainerId = `metadata-${messageId || Date.now()}`;
const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0);
@@ -1166,6 +1679,28 @@ export function appendMessage(