@@ -527,23 +539,21 @@ function formatClassifications(classifications) {
function formatChatType(chatType, context = []) {
// Use the actual chat_type value from the metadata
- if (chatType === 'personal') {
- return '
personal ';
+ if (chatType === 'personal' || chatType === 'personal_single_user') {
+ return '
personal ';
+ } else if (chatType === 'new') {
+ return '
new ';
} else if (chatType === 'group' || chatType.startsWith('group')) {
// For group chats, try to find the group name from context
const primaryContext = context.find(c => c.type === 'primary' && c.scope === 'group');
const groupName = primaryContext ? primaryContext.name || 'Group' : 'Group';
-
- // Determine if single-user or multi-user based on chat_type
- const userType = chatType.includes('multi-user') ? 'multi-user' : 'single-user';
-
- return `
-
group - ${groupName}
-
${userType}
- `;
+
+ return `
${escapeHtml(groupName)} `;
+ } else if (chatType && chatType.startsWith('public')) {
+ return '
public ';
} else {
// Fallback for unknown types
- return `
${chatType} `;
+ return `
${escapeHtml(chatType)} `;
}
}
@@ -570,8 +580,161 @@ function extractPageNumbers(chunkIds) {
return pages.sort((a, b) => parseInt(a) - parseInt(b));
}
+/**
+ * Render the summary card body content
+ * @param {Object|null} summary - Existing summary data or null
+ * @param {string} conversationId - The conversation ID
+ * @returns {string} HTML string
+ */
+function renderSummaryContent(summary, conversationId) {
+ if (summary && summary.content) {
+ return `
+
${escapeHtml(summary.content)}
+
+
+ Regenerate
+
+
+ `;
+ }
+
+ // Build model options from the global model-select dropdown
+ const modelOptions = getAvailableModelOptions();
+ return `
+
No summary has been generated for this conversation yet.
+
+
+ ${modelOptions}
+
+
+ Generate Summary
+
+
+ `;
+}
+
+/**
+ * Get available model options from the global #model-select dropdown
+ * @returns {string} HTML option elements
+ */
+function getAvailableModelOptions() {
+ const globalSelect = document.getElementById('model-select');
+ if (!globalSelect) {
+ return '
Default ';
+ }
+ let options = '';
+ for (const opt of globalSelect.options) {
+ options += `
${escapeHtml(opt.text)} `;
+ }
+ return options || '
Default ';
+}
+
+/**
+ * Handle summary generation (generate or regenerate)
+ * @param {string} conversationId - The conversation ID
+ * @param {string} modelDeployment - Selected model deployment
+ */
+async function handleGenerateSummary(conversationId, modelDeployment) {
+ const cardBody = document.getElementById('summary-card-body');
+ if (!cardBody) {
+ return;
+ }
+
+ cardBody.innerHTML = `
+
+
+ Generating...
+
+
Generating summary...
+
+ `;
+
+ try {
+ const response = await fetch(`/api/conversations/${conversationId}/summary`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model_deployment: modelDeployment })
+ });
+
+ if (!response.ok) {
+ const errData = await response.json().catch(() => ({}));
+ throw new Error(errData.error || `HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ const summary = data.summary;
+ cardBody.innerHTML = renderSummaryContent(summary, conversationId);
+
+ // Update card header with generation info
+ const cardHeader = cardBody.closest('.card').querySelector('.card-header');
+ if (cardHeader && summary) {
+ const smallEl = cardHeader.querySelector('small');
+ const infoText = `Generated ${formatDate(summary.generated_at)}${summary.model_deployment ? ` · ${summary.model_deployment}` : ''}`;
+ if (smallEl) {
+ smallEl.textContent = infoText;
+ } else {
+ const small = document.createElement('small');
+ small.className = 'opacity-75';
+ small.textContent = infoText;
+ cardHeader.appendChild(small);
+ }
+ }
+
+ } catch (error) {
+ console.error('Error generating summary:', error);
+ cardBody.innerHTML = `
+
+
+ Failed to generate summary: ${escapeHtml(error.message)}
+
+ ${renderSummaryContent(null, conversationId)}
+ `;
+ }
+}
+
+/**
+ * Simple HTML escapefor display
+ * @param {string} str - String to escape
+ * @returns {string} Escaped string
+ */
+function escapeHtml(str) {
+ if (!str) {
+ return '';
+ }
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
// Event listeners for details buttons
document.addEventListener('click', function(e) {
+ // Generate summary button
+ if (e.target.closest('#generate-summary-btn')) {
+ e.preventDefault();
+ const btn = e.target.closest('#generate-summary-btn');
+ const cid = btn.getAttribute('data-conversation-id');
+ const modelSelect = document.getElementById('summary-model-select');
+ const selectedOption = modelSelect ? modelSelect.options[modelSelect.selectedIndex] : null;
+ const model = selectedOption?.dataset?.deploymentName || (modelSelect ? modelSelect.value : '');
+ handleGenerateSummary(cid, model);
+ return;
+ }
+
+ // Regenerate summary button
+ if (e.target.closest('#regenerate-summary-btn')) {
+ e.preventDefault();
+ const btn = e.target.closest('#regenerate-summary-btn');
+ const cid = btn.getAttribute('data-conversation-id');
+ // Use the currently selected global model for regeneration
+ const globalSelect = document.getElementById('model-select');
+ const selectedOption = globalSelect ? globalSelect.options[globalSelect.selectedIndex] : null;
+ const model = selectedOption?.dataset?.deploymentName || (globalSelect ? globalSelect.value : '');
+ handleGenerateSummary(cid, model);
+ return;
+ }
+
if (e.target.closest('.details-btn')) {
e.preventDefault();
diff --git a/application/single_app/static/js/chat/chat-conversation-scope.js b/application/single_app/static/js/chat/chat-conversation-scope.js
new file mode 100644
index 00000000..4cd544ba
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-conversation-scope.js
@@ -0,0 +1,73 @@
+// chat-conversation-scope.js
+
+function sanitizeScopeId(rawValue) {
+ if (!rawValue && rawValue !== 0) {
+ return null;
+ }
+
+ const normalizedValue = String(rawValue).trim();
+ if (!normalizedValue) {
+ return null;
+ }
+
+ const loweredValue = normalizedValue.toLowerCase();
+ if (loweredValue === 'none' || loweredValue === 'null' || loweredValue === 'undefined') {
+ return null;
+ }
+
+ return normalizedValue;
+}
+
+export function getActiveConversationContext() {
+ const activeItem = document.querySelector('.conversation-item.active');
+ const chatType = activeItem?.getAttribute('data-chat-type') || '';
+ const chatState = activeItem?.getAttribute('data-chat-state') || '';
+ const groupId = sanitizeScopeId(activeItem?.getAttribute('data-group-id'));
+ const publicWorkspaceId = sanitizeScopeId(activeItem?.getAttribute('data-public-workspace-id'));
+
+ return {
+ activeItem,
+ chatType,
+ chatState,
+ groupId,
+ publicWorkspaceId,
+ };
+}
+
+export function getActiveConversationScope() {
+ const { activeItem, chatType, chatState } = getActiveConversationContext();
+
+ if (!activeItem || chatType === 'new' || chatState === 'new') {
+ return null;
+ }
+
+ if (chatType.startsWith('group')) {
+ return 'group';
+ }
+
+ if (chatType.startsWith('public')) {
+ return 'public';
+ }
+
+ return 'personal';
+}
+
+export function isActiveConversationNew() {
+ const { activeItem, chatType, chatState } = getActiveConversationContext();
+ return !activeItem || chatType === 'new' || chatState === 'new';
+}
+
+export function getConversationFilteringContext() {
+ const conversationScope = getActiveConversationScope();
+ const { activeItem, chatType, chatState, groupId, publicWorkspaceId } = getActiveConversationContext();
+
+ return {
+ activeItem,
+ chatType,
+ chatState,
+ groupId,
+ publicWorkspaceId,
+ conversationScope,
+ isNewConversation: isActiveConversationNew(),
+ };
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js
index 0af0a768..560571e9 100644
--- a/application/single_app/static/js/chat/chat-conversations.js
+++ b/application/single_app/static/js/chat/chat-conversations.js
@@ -3,9 +3,16 @@
import { showToast } from "./chat-toast.js";
import { loadMessages } from "./chat-messages.js";
import { isColorLight, toBoolean } from "./chat-utils.js";
-import { loadSidebarConversations, setActiveConversation as setSidebarActiveConversation } from "./chat-sidebar-conversations.js";
+import {
+ loadSidebarConversations,
+ applySidebarConversationMetadataUpdate,
+ setActiveConversation as setSidebarActiveConversation,
+ setConversationUnreadState as setSidebarConversationUnreadState,
+} from "./chat-sidebar-conversations.js";
import { toggleConversationInfoButton } 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";
const newConversationBtn = document.getElementById("new-conversation-btn");
const deleteSelectedBtn = document.getElementById("delete-selected-btn");
@@ -21,6 +28,63 @@ const chatbox = document.getElementById("chatbox");
let selectedConversations = new Set();
let currentlyEditingId = null; // Track which item is being edited
+
+async function ensureActiveGroupForConversation() {
+ const activeItem = document.querySelector('.conversation-item.active');
+ if (!activeItem) return null;
+
+ const chatType = activeItem.getAttribute('data-chat-type') || '';
+ if (!chatType.startsWith('group')) return null;
+
+ const convoGroupId = activeItem.getAttribute('data-group-id') || null;
+ if (!convoGroupId) return null;
+
+ try {
+ const settings = await loadUserSettings();
+ const currentActiveGroupId = settings?.activeGroupOid || null;
+ if (currentActiveGroupId && String(currentActiveGroupId) === String(convoGroupId)) {
+ return convoGroupId;
+ }
+
+ await setUserSetting('activeGroupOid', convoGroupId);
+ return convoGroupId;
+ } catch (error) {
+ console.warn('Failed to align active group with conversation context:', error);
+ return convoGroupId;
+ }
+}
+
+async function refreshAgentsForActiveConversation() {
+ try {
+ await ensureActiveGroupForConversation();
+ const agentsModule = await import('./chat-agents.js');
+ const enableAgentsBtn = document.getElementById("enable-agents-btn");
+ if (enableAgentsBtn && enableAgentsBtn.classList.contains('active')) {
+ await agentsModule.populateAgentDropdown();
+ }
+ } catch (error) {
+ console.warn('Failed to refresh agent dropdown:', error);
+ }
+}
+
+async function refreshModelSelection() {
+ try {
+ const settings = await loadUserSettings();
+ const modelSelectorModule = await import('./chat-model-selector.js');
+ await modelSelectorModule.populateModelDropdown({
+ preferredModelId: settings?.preferredModelId,
+ preferredModelDeployment: settings?.preferredModelDeployment,
+ preserveCurrentSelection: false,
+ });
+ } catch (error) {
+ console.warn('Failed to refresh model selection:', error);
+ }
+}
+
+async function refreshAgentsAndModelsForActiveConversation() {
+ await refreshAgentsForActiveConversation();
+ await refreshModelSelection();
+}
let selectionModeActive = false; // Track if selection mode is active
let selectionModeTimer = null; // Timer for auto-hiding checkboxes
let showHiddenConversations = false; // Track if hidden conversations should be shown
@@ -28,6 +92,284 @@ let allConversations = []; // Store all conversations for client-side filtering
let isLoadingConversations = false; // Prevent concurrent loads
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();
+
+function createUnreadDotElement() {
+ const unreadDot = document.createElement("span");
+ unreadDot.classList.add("conversation-unread-dot");
+ unreadDot.setAttribute("aria-hidden", "true");
+ return unreadDot;
+}
+
+function applyConversationContextAttributes(convoItem, chatType, context = []) {
+ if (!convoItem) {
+ return;
+ }
+
+ const normalizedChatType = chatType === 'personal' ? 'personal_single_user' : chatType;
+ const primaryContext = Array.isArray(context)
+ ? context.find(item => item?.type === 'primary')
+ : null;
+
+ convoItem.removeAttribute('data-group-name');
+ convoItem.removeAttribute('data-group-id');
+ convoItem.removeAttribute('data-public-workspace-id');
+
+ if (normalizedChatType) {
+ convoItem.setAttribute('data-chat-type', normalizedChatType);
+ } else if (primaryContext?.scope === 'group') {
+ convoItem.setAttribute('data-chat-type', 'group-single-user');
+ } else if (primaryContext?.scope === 'public') {
+ convoItem.setAttribute('data-chat-type', 'public');
+ } else {
+ convoItem.setAttribute('data-chat-type', 'personal_single_user');
+ }
+
+ const resolvedChatType = convoItem.getAttribute('data-chat-type') || '';
+ if (resolvedChatType.startsWith('group')) {
+ const groupContext = Array.isArray(context)
+ ? context.find(item => item?.type === 'primary' && item?.scope === 'group')
+ : null;
+
+ if (groupContext) {
+ convoItem.setAttribute('data-group-name', groupContext.name || 'Group');
+ if (groupContext.id) {
+ convoItem.setAttribute('data-group-id', groupContext.id);
+ }
+ }
+ } else if (resolvedChatType.startsWith('public')) {
+ const publicContext = Array.isArray(context)
+ ? context.find(item => item?.type === 'primary' && item?.scope === 'public')
+ : null;
+
+ if (publicContext) {
+ convoItem.setAttribute('data-group-name', publicContext.name || 'Workspace');
+ if (publicContext.id) {
+ convoItem.setAttribute('data-public-workspace-id', publicContext.id);
+ }
+ }
+ }
+}
+
+function renderConversationHeaderBadges(convoItem) {
+ if (!currentConversationClassificationsEl || !convoItem) {
+ return;
+ }
+
+ currentConversationClassificationsEl.innerHTML = "";
+
+ const isFeatureEnabled = toBoolean(window.enable_document_classification);
+ if (isFeatureEnabled) {
+ try {
+ const classifications = convoItem.dataset.classifications || '[]';
+ const classificationLabels = JSON.parse(classifications);
+
+ if (Array.isArray(classificationLabels) && classificationLabels.length > 0) {
+ const allCategories = window.classification_categories || [];
+
+ classificationLabels.forEach(label => {
+ const category = allCategories.find(cat => cat.label === label);
+ const pill = document.createElement("span");
+ pill.classList.add("chat-classification-badge");
+ pill.textContent = label;
+
+ if (category) {
+ pill.style.backgroundColor = category.color;
+ if (isColorLight(category.color)) {
+ pill.classList.add("text-dark");
+ }
+ } else {
+ pill.classList.add("bg-warning", "text-dark");
+ pill.title = `Definition for "${label}" not found`;
+ }
+
+ currentConversationClassificationsEl.appendChild(pill);
+ });
+ }
+ } catch (error) {
+ console.error("Error parsing classification data:", error);
+ }
+ }
+
+ addChatTypeBadges(convoItem, currentConversationClassificationsEl);
+}
+
+function updateConversationCache(conversationId, updates = {}) {
+ allConversations = allConversations.map(conversation => {
+ if (conversation.id !== conversationId) {
+ return conversation;
+ }
+
+ const normalizedUpdates = Object.fromEntries(
+ Object.entries(updates).filter(([, value]) => value !== undefined)
+ );
+
+ return {
+ ...conversation,
+ ...normalizedUpdates,
+ };
+ });
+}
+
+export function applyConversationMetadataUpdate(conversationId, updates = {}) {
+ if (!conversationId) {
+ return;
+ }
+
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`);
+ if (!convoItem) {
+ return;
+ }
+
+ if (updates.title) {
+ convoItem.setAttribute('data-conversation-title', updates.title);
+ const titleElement = convoItem.querySelector('.conversation-title');
+ if (titleElement) {
+ const pinIcon = titleElement.querySelector('.bi-pin-angle');
+ titleElement.innerHTML = '';
+ if (pinIcon) {
+ titleElement.appendChild(pinIcon);
+ }
+ titleElement.appendChild(document.createTextNode(updates.title));
+ titleElement.title = updates.title;
+ }
+ }
+
+ if (Array.isArray(updates.classification)) {
+ convoItem.dataset.classifications = JSON.stringify(updates.classification);
+ }
+
+ const hasContextUpdate = Object.prototype.hasOwnProperty.call(updates, 'chat_type') || Array.isArray(updates.context);
+
+ if (hasContextUpdate) {
+ applyConversationContextAttributes(convoItem, updates.chat_type || convoItem.getAttribute('data-chat-type') || '', updates.context || []);
+ convoItem.removeAttribute('data-chat-state');
+ }
+
+ updateConversationCache(conversationId, {
+ title: updates.title,
+ classification: updates.classification,
+ context: updates.context,
+ chat_type: updates.chat_type,
+ });
+
+ applySidebarConversationMetadataUpdate(conversationId, updates);
+
+ const isActiveConversation = currentConversationId === conversationId
+ || window.currentConversationId === conversationId
+ || convoItem.classList.contains('active');
+
+ if (isActiveConversation) {
+ if (updates.title && currentConversationTitleEl) {
+ const existingIcons = Array.from(currentConversationTitleEl.querySelectorAll('i')).map(icon => icon.cloneNode(true));
+ currentConversationTitleEl.innerHTML = '';
+ existingIcons.forEach(icon => currentConversationTitleEl.appendChild(icon));
+ currentConversationTitleEl.appendChild(document.createTextNode(updates.title));
+ }
+
+ renderConversationHeaderBadges(convoItem);
+
+ if (hasContextUpdate) {
+ void refreshAgentsAndModelsForActiveConversation();
+ }
+ }
+}
+
+function updateConversationUnreadStateCache(conversationId, hasUnread) {
+ allConversations = allConversations.map(convo => {
+ if (convo.id !== conversationId) {
+ return convo;
+ }
+
+ return {
+ ...convo,
+ has_unread_assistant_response: hasUnread,
+ last_unread_assistant_message_id: hasUnread ? convo.last_unread_assistant_message_id : null,
+ last_unread_assistant_at: hasUnread ? convo.last_unread_assistant_at : null,
+ };
+ });
+}
+
+function getConversationUnreadState(conversationId) {
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`);
+ if (convoItem) {
+ return convoItem.dataset.hasUnreadAssistantResponse === "true";
+ }
+
+ const conversation = allConversations.find(convo => convo.id === conversationId);
+ return Boolean(conversation?.has_unread_assistant_response);
+}
+
+export function setConversationUnreadState(conversationId, hasUnread) {
+ updateConversationUnreadStateCache(conversationId, hasUnread);
+
+ const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`);
+ if (convoItem) {
+ convoItem.dataset.hasUnreadAssistantResponse = hasUnread ? "true" : "false";
+
+ const titleRow = convoItem.querySelector(".conversation-title-row");
+ const titleElement = convoItem.querySelector(".conversation-title");
+ const existingDot = convoItem.querySelector(".conversation-unread-dot");
+
+ if (!hasUnread) {
+ if (existingDot) {
+ existingDot.remove();
+ }
+ } else if (!existingDot && titleRow && titleElement) {
+ titleRow.insertBefore(createUnreadDotElement(), titleElement);
+ }
+ }
+
+ setSidebarConversationUnreadState(conversationId, hasUnread);
+}
+
+export async function markConversationRead(conversationId, options = {}) {
+ const { force = false, suppressErrorToast = false } = options;
+ if (!conversationId) {
+ return null;
+ }
+
+ const previousUnreadState = getConversationUnreadState(conversationId);
+ if (!force && !previousUnreadState) {
+ return { success: true, skipped: true };
+ }
+
+ if (markConversationReadRequests.has(conversationId)) {
+ return markConversationReadRequests.get(conversationId);
+ }
+
+ setConversationUnreadState(conversationId, false);
+
+ const markReadRequest = fetch(`/api/conversations/${conversationId}/mark-read`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ })
+ .then(async response => {
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data.success === false) {
+ throw new Error(data.error || "Failed to mark conversation as read");
+ }
+ return data;
+ })
+ .catch(error => {
+ if (previousUnreadState) {
+ setConversationUnreadState(conversationId, true);
+ }
+
+ if (!suppressErrorToast) {
+ showToast(`Failed to clear unread state: ${error.message}`, "danger");
+ }
+
+ throw error;
+ })
+ .finally(() => {
+ markConversationReadRequests.delete(conversationId);
+ });
+
+ markConversationReadRequests.set(conversationId, markReadRequest);
+ return markReadRequest;
+}
// Clear selected conversations when loading the page
document.addEventListener('DOMContentLoaded', () => {
@@ -77,6 +419,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
+
+ void refreshAgentsAndModelsForActiveConversation();
});
// Function to enter selection mode
@@ -344,6 +688,9 @@ export async function ensureConversationPresent(conversationId) {
chat_type: metadata.chat_type || null,
is_pinned: metadata.is_pinned || false,
is_hidden: metadata.is_hidden || false,
+ 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,
};
// Keep allConversations in sync
@@ -365,6 +712,7 @@ 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
+ convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false";
// *** Store classification data as stringified JSON ***
convoItem.dataset.classifications = JSON.stringify(convo.classification || []);
@@ -373,23 +721,35 @@ export function createConversationItem(convo) {
// Use the actual chat_type from conversation metadata if available
console.log(`createConversationItem: Processing conversation ${convo.id}, chat_type="${convo.chat_type}"`);
- if (convo.chat_type) {
- convoItem.setAttribute("data-chat-type", convo.chat_type);
- console.log(`createConversationItem: Set data-chat-type to "${convo.chat_type}"`);
+ const normalizedChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type;
+ if (normalizedChatType) {
+ convoItem.setAttribute("data-chat-type", normalizedChatType);
+ console.log(`createConversationItem: Set data-chat-type to "${normalizedChatType}"`);
// For group chats, try to find group name from context
- if (convo.chat_type.startsWith('group') && convo.context && convo.context.length > 0) {
+ if (normalizedChatType.startsWith('group') && convo.context && convo.context.length > 0) {
const primaryContext = convo.context.find(c => c.type === 'primary' && c.scope === 'group');
if (primaryContext) {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Group');
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-group-id", primaryContext.id);
+ }
+ convoItem.removeAttribute("data-public-workspace-id");
console.log(`createConversationItem: Set data-group-name to "${primaryContext.name || 'Group'}"`);
}
- } else if (convo.chat_type.startsWith('public') && convo.context && convo.context.length > 0) {
+ } else if (normalizedChatType.startsWith('public') && convo.context && convo.context.length > 0) {
const primaryContext = convo.context.find(c => c.type === 'primary' && c.scope === 'public');
if (primaryContext) {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Workspace');
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-public-workspace-id", primaryContext.id);
+ }
+ convoItem.removeAttribute("data-group-id");
console.log(`createConversationItem: Set data-group-name to "${primaryContext.name || 'Workspace'}"`);
}
+ } else {
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
}
} else {
console.log(`createConversationItem: No chat_type found, determining from context`);
@@ -401,23 +761,38 @@ export function createConversationItem(convo) {
if (primaryContext.scope === 'group') {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Group');
convoItem.setAttribute("data-chat-type", "group-single-user"); // Default to single-user for now
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-group-id", primaryContext.id);
+ }
+ convoItem.removeAttribute("data-public-workspace-id");
console.log(`createConversationItem: Set to group-single-user with name "${primaryContext.name || 'Group'}"`);
} else if (primaryContext.scope === 'public') {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Workspace');
convoItem.setAttribute("data-chat-type", "public");
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-public-workspace-id", primaryContext.id);
+ }
+ convoItem.removeAttribute("data-group-id");
console.log(`createConversationItem: Set to public with name "${primaryContext.name || 'Workspace'}"`);
} else if (primaryContext.scope === 'personal') {
- convoItem.setAttribute("data-chat-type", "personal");
- console.log(`createConversationItem: Set to personal`);
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
+ console.log(`createConversationItem: Set to personal_single_user`);
}
} else {
- // No primary context - this is a model-only conversation
- // Don't set data-chat-type so no badges will be shown
- console.log(`createConversationItem: No primary context - model-only conversation (no badges)`);
+ // No primary context - default to personal_single_user
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
+ console.log(`createConversationItem: No primary context - defaulted to personal_single_user`);
}
} else {
- // No context at all - model-only conversation
- console.log(`createConversationItem: No context - model-only conversation (no badges)`);
+ // No context at all - default to personal_single_user
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
+ console.log(`createConversationItem: No context - defaulted to personal_single_user`);
}
}
@@ -437,8 +812,12 @@ export function createConversationItem(convo) {
leftDiv.classList.add("d-flex", "flex-column", "flex-grow-1", "pe-2"); // flex-grow and padding-end
leftDiv.style.overflow = "hidden"; // Prevent overflow issues
+ const titleRow = document.createElement("div");
+ titleRow.classList.add("conversation-title-row", "d-flex", "align-items-center", "gap-2", "overflow-hidden");
+
const titleSpan = document.createElement("span");
- titleSpan.classList.add("conversation-title", "text-truncate"); // Bold and truncate
+ titleSpan.classList.add("conversation-title", "text-truncate", "flex-grow-1"); // Bold and truncate
+ titleSpan.style.minWidth = "0";
// Add pin icon if conversation is pinned
const isPinned = convo.is_pinned || false;
@@ -451,12 +830,18 @@ export function createConversationItem(convo) {
titleSpan.appendChild(document.createTextNode(convo.title));
titleSpan.title = convo.title; // Tooltip for full title
+ if (convo.has_unread_assistant_response) {
+ titleRow.appendChild(createUnreadDotElement());
+ }
+
+ titleRow.appendChild(titleSpan);
+
const dateSpan = document.createElement("small");
dateSpan.classList.add("text-muted");
const date = new Date(convo.last_updated);
dateSpan.textContent = date.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }); // Shorter format
- leftDiv.appendChild(titleSpan);
+ leftDiv.appendChild(titleRow);
leftDiv.appendChild(dateSpan);
// Right part: three dots dropdown
@@ -692,29 +1077,10 @@ export function enterEditMode(convoItem, convo, dropdownBtn, rightDiv) {
// *** Call update API and get potentially updated convo data (including classification) ***
const updatedConvoData = await updateConversationTitle(convo.id, newTitle);
convo.title = updatedConvoData.title || newTitle; // Update local title
- convoItem.setAttribute('data-conversation-title', convo.title);
- // *** Update local classification data if returned from API ***
- if (updatedConvoData.classification) {
- convoItem.dataset.classifications = JSON.stringify(updatedConvoData.classification);
- }
- // *** Update chat type and group information if available ***
- if (updatedConvoData.context && updatedConvoData.context.length > 0) {
- const primaryContext = updatedConvoData.context.find(c => c.type === 'primary');
- if (primaryContext && primaryContext.scope === 'group') {
- convoItem.setAttribute("data-group-name", primaryContext.name || 'Group');
- convoItem.setAttribute("data-chat-type", "group-single-user");
- } else {
- convoItem.setAttribute("data-chat-type", "personal");
- }
- }
+ applyConversationMetadataUpdate(convo.id, updatedConvoData);
exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, saveBtn, cancelBtn);
- // *** Update sidebar conversation title if sidebar is available ***
- if (window.chatSidebarConversations && window.chatSidebarConversations.updateSidebarConversationTitle) {
- window.chatSidebarConversations.updateSidebarConversationTitle(convo.id, convo.title);
- }
-
// *** If this is the currently selected convo, refresh the header ***
if (currentConversationId === convo.id) {
selectConversation(convo.id); // Re-run selection logic to update header
@@ -799,10 +1165,20 @@ export function addConversationToList(conversationId, title = null, classificati
id: conversationId,
title: title || "New Conversation", // Default title
last_updated: new Date().toISOString(),
- classification: classifications // Include classifications
+ classification: classifications, // Include classifications
+ chat_type: "new", // Temporary chat type until metadata is fetched
+ has_unread_assistant_response: false,
};
const convoItem = createConversationItem(convo);
+ convoItem.dataset.chatState = "new";
+ const rawGroupId = window.groupWorkspaceContext?.activeGroupId || window.activeGroupId || null;
+ const normalizedGroupId = rawGroupId && !['none', 'null', 'undefined'].includes(String(rawGroupId).toLowerCase())
+ ? rawGroupId
+ : null;
+ if (normalizedGroupId) {
+ convoItem.setAttribute("data-group-id", normalizedGroupId);
+ }
convoItem.classList.add("active"); // Mark the new one as active
conversationsList.prepend(convoItem); // Add to the top
@@ -810,6 +1186,8 @@ export function addConversationToList(conversationId, title = null, classificati
if (document.getElementById("sidebar-conversations-list")) {
loadSidebarConversations();
}
+
+ void refreshAgentsAndModelsForActiveConversation();
}
// Select a conversation, load messages, update UI
@@ -865,23 +1243,34 @@ export async function selectConversation(conversationId) {
// Update conversation item with accurate chat_type from metadata
if (metadata.chat_type) {
- convoItem.setAttribute("data-chat-type", metadata.chat_type);
- console.log(`selectConversation: Updated data-chat-type to "${metadata.chat_type}"`);
+ const normalizedChatType = metadata.chat_type === 'personal' ? 'personal_single_user' : metadata.chat_type;
+ convoItem.setAttribute("data-chat-type", normalizedChatType);
+ console.log(`selectConversation: Updated data-chat-type to "${normalizedChatType}"`);
+
+ convoItem.removeAttribute("data-chat-state");
// Clear any existing group name first
convoItem.removeAttribute("data-group-name");
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
// If it's a group chat, also update group name
- if (metadata.chat_type.startsWith('group') && metadata.context && metadata.context.length > 0) {
+ if (normalizedChatType.startsWith('group') && metadata.context && metadata.context.length > 0) {
const primaryContext = metadata.context.find(c => c.type === 'primary' && c.scope === 'group');
if (primaryContext) {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Group');
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-group-id", primaryContext.id);
+ }
console.log(`selectConversation: Set data-group-name to "${primaryContext.name || 'Group'}"`);
}
- } else if (metadata.chat_type.startsWith('public') && metadata.context && metadata.context.length > 0) {
+ } else if (normalizedChatType.startsWith('public') && metadata.context && metadata.context.length > 0) {
const primaryContext = metadata.context.find(c => c.type === 'primary' && c.scope === 'public');
if (primaryContext) {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Workspace');
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-public-workspace-id", primaryContext.id);
+ }
console.log(`selectConversation: Set data-group-name to "${primaryContext.name || 'Workspace'}"`);
}
} else {
@@ -893,6 +1282,9 @@ export async function selectConversation(conversationId) {
// Clear any existing attributes first
convoItem.removeAttribute("data-chat-type");
convoItem.removeAttribute("data-group-name");
+ convoItem.removeAttribute("data-group-id");
+ convoItem.removeAttribute("data-public-workspace-id");
+ convoItem.removeAttribute("data-chat-state");
if (metadata.context && metadata.context.length > 0) {
const primaryContext = metadata.context.find(c => c.type === 'primary');
@@ -901,23 +1293,30 @@ export async function selectConversation(conversationId) {
if (primaryContext.scope === 'group') {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Group');
convoItem.setAttribute("data-chat-type", "group-single-user"); // Default to single-user for now
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-group-id", primaryContext.id);
+ }
console.log(`selectConversation: Set to group-single-user with name "${primaryContext.name || 'Group'}"`);
} else if (primaryContext.scope === 'public') {
convoItem.setAttribute("data-group-name", primaryContext.name || 'Workspace');
convoItem.setAttribute("data-chat-type", "public");
+ if (primaryContext.id) {
+ convoItem.setAttribute("data-public-workspace-id", primaryContext.id);
+ }
console.log(`selectConversation: Set to public with name "${primaryContext.name || 'Workspace'}"`);
} else if (primaryContext.scope === 'personal') {
- convoItem.setAttribute("data-chat-type", "personal");
- console.log(`selectConversation: Set to personal`);
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ console.log(`selectConversation: Set to personal_single_user`);
}
} else {
- // No primary context - this is a model-only conversation
- // Don't set data-chat-type so no badges will be shown
- console.log(`selectConversation: No primary context - model-only conversation (no badges)`);
+ // No primary context - default to personal_single_user
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ console.log(`selectConversation: No primary context - defaulted to personal_single_user`);
}
} else {
- // No context at all - model-only conversation
- console.log(`selectConversation: No context - model-only conversation (no badges)`);
+ // No context at all - default to personal_single_user
+ convoItem.setAttribute("data-chat-type", "personal_single_user");
+ console.log(`selectConversation: No context - defaulted to personal_single_user`);
}
}
@@ -933,61 +1332,19 @@ export async function selectConversation(conversationId) {
// Update Header Classifications
if (currentConversationClassificationsEl) {
- currentConversationClassificationsEl.innerHTML = ""; // Clear previous
-
- // Use the toBoolean helper for consistent checking
- const isFeatureEnabled = toBoolean(window.enable_document_classification);
-
- // Debug line to help troubleshoot
- console.log("Classification feature enabled:", isFeatureEnabled,
- "Raw value:", window.enable_document_classification,
- "Type:", typeof window.enable_document_classification);
-
- if (isFeatureEnabled) {
- try {
- const classifications = convoItem.dataset.classifications || '[]';
- console.log("Raw classifications:", classifications);
- const classificationLabels = JSON.parse(classifications);
- console.log("Parsed classification labels:", classificationLabels);
-
- if (Array.isArray(classificationLabels) && classificationLabels.length > 0) {
- const allCategories = window.classification_categories || [];
- console.log("Available categories:", allCategories);
-
- classificationLabels.forEach(label => {
- const category = allCategories.find(cat => cat.label === label);
- const pill = document.createElement("span");
- pill.classList.add("chat-classification-badge"); // Use specific class
- pill.textContent = label; // Display the label
-
- if (category) {
- // Found category definition, apply color
- pill.style.backgroundColor = category.color;
- if (isColorLight(category.color)) {
- pill.classList.add("text-dark"); // Add dark text for light backgrounds
- }
- } else {
- // Label exists but no definition found (maybe deleted in admin)
- pill.classList.add("bg-warning", "text-dark"); // Use warning style
- pill.title = `Definition for "${label}" not found`;
- }
- currentConversationClassificationsEl.appendChild(pill);
- });
- } else {
- // Optionally display "None" if no classifications
- // currentConversationClassificationsEl.innerHTML = '
None ';
- }
- } catch (e) {
- console.error("Error parsing classification data:", e);
- // Handle error, maybe display an error message
- }
- }
-
- // Add chat type information (now with updated data)
- addChatTypeBadges(convoItem, currentConversationClassificationsEl);
+ renderConversationHeaderBadges(convoItem);
}
- loadMessages(conversationId);
+ 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);
+ });
highlightSelectedConversation(conversationId);
// Show the conversation info button since we have an active conversation
@@ -998,6 +1355,12 @@ export async function selectConversation(conversationId) {
setSidebarActiveConversation(conversationId);
}
+ try {
+ await refreshAgentsAndModelsForActiveConversation();
+ } catch (error) {
+ console.warn('Failed to refresh agent or model dropdown:', error);
+ }
+
updateConversationUrl(conversationId);
// Clear any "edit mode" state if switching conversations
@@ -1067,7 +1430,21 @@ export function deleteConversation(conversationId) {
}
// Create a new conversation via API
-export async function createNewConversation(callback) {
+export async function createNewConversation(callback, options = {}) {
+ if (pendingConversationCreation) {
+ try {
+ await pendingConversationCreation;
+ if (typeof callback === "function") {
+ callback();
+ }
+ } catch (error) {
+ // The original caller already surfaced the creation failure.
+ }
+ return;
+ }
+
+ const { preserveSelections = false } = options;
+
// Disable new button? Show loading?
if (newConversationBtn) newConversationBtn.disabled = true;
@@ -1079,54 +1456,61 @@ export async function createNewConversation(callback) {
}
try {
- const response = await fetch("/api/create_conversation", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- credentials: "same-origin",
- });
- if (!response.ok) {
- const errData = await response.json().catch(() => ({}));
- throw new Error(errData.error || "Failed to create conversation");
- }
- const data = await response.json();
- if (!data.conversation_id) {
- throw new Error("No conversation_id returned from server.");
- }
+ pendingConversationCreation = (async () => {
+ const response = await fetch("/api/create_conversation", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "same-origin",
+ });
+ if (!response.ok) {
+ const errData = await response.json().catch(() => ({}));
+ throw new Error(errData.error || "Failed to create conversation");
+ }
+ const data = await response.json();
+ if (!data.conversation_id) {
+ throw new Error("No conversation_id returned from server.");
+ }
- currentConversationId = data.conversation_id;
- // Reset scope lock for new conversation
- resetScopeLock();
- // Add to list (pass empty classifications for new convo)
- addConversationToList(data.conversation_id, data.title /* Use title from API if provided */, []);
-
- // Don't call selectConversation here if we're about to send a message
- // because selectConversation clears the chatbox, which would remove
- // the user message that's about to be appended by actuallySendMessage
- // Instead, just update the UI elements directly
- window.currentConversationId = data.conversation_id;
- const titleEl = document.getElementById("current-conversation-title");
- if (titleEl) {
- titleEl.textContent = data.title || "New Conversation";
- }
- // Clear classification/tag badges from previous conversation
- if (currentConversationClassificationsEl) {
- currentConversationClassificationsEl.innerHTML = "";
- }
- updateConversationUrl(data.conversation_id);
- console.log('[createNewConversation] Created conversation without reload:', data.conversation_id);
+ currentConversationId = data.conversation_id;
+ // Reset scope lock for new conversation
+ resetScopeLock({ preserveSelections });
+ // Add to list (pass empty classifications for new convo)
+ addConversationToList(data.conversation_id, data.title /* Use title from API if provided */, []);
+
+ // Don't call selectConversation here if we're about to send a message
+ // because selectConversation clears the chatbox, which would remove
+ // the user message that's about to be appended by actuallySendMessage
+ // Instead, just update the UI elements directly
+ window.currentConversationId = data.conversation_id;
+ const titleEl = document.getElementById("current-conversation-title");
+ if (titleEl) {
+ titleEl.textContent = data.title || "New Conversation";
+ }
+ // Clear classification/tag badges from previous conversation
+ if (currentConversationClassificationsEl) {
+ currentConversationClassificationsEl.innerHTML = "";
+ }
+ updateConversationUrl(data.conversation_id);
+ console.log('[createNewConversation] Created conversation without reload:', data.conversation_id);
+
+ return data;
+ })();
+
+ const data = await pendingConversationCreation;
// Execute callback if provided (e.g., to send the first message)
if (typeof callback === "function") {
callback();
}
-
+ return data;
} catch (error) {
console.error("Error creating conversation:", error);
showToast(`Failed to create a new conversation: ${error.message}`, "danger");
} finally {
+ pendingConversationCreation = null;
if (newConversationBtn) newConversationBtn.disabled = false;
}
}
@@ -1513,6 +1897,8 @@ window.chatConversations = {
loadConversations,
highlightSelectedConversation,
addConversationToList,
+ markConversationRead,
+ setConversationUnreadState,
deleteConversation,
toggleConversationSelection,
deleteSelectedConversations,
@@ -1609,42 +1995,35 @@ function addChatTypeBadges(convoItem, classificationsEl) {
// Debug logging
console.log(`addChatTypeBadges: chatType="${chatType}", groupName="${groupName}"`);
-
- // Only show badges if there's a valid chat type (meaning documents were used for primary context)
- // Don't show badges for Model-only conversations
- if (chatType === 'personal') {
- // Personal workspace was used
- const personalBadge = document.createElement("span");
- personalBadge.classList.add("badge", "bg-primary");
- personalBadge.textContent = "personal";
-
- // Add some spacing between classification badges and chat type badges
+
+ const appendBadgeSpacer = () => {
if (classificationsEl.children.length > 0) {
const spacer = document.createElement("span");
spacer.innerHTML = " ";
classificationsEl.appendChild(spacer);
}
-
- classificationsEl.appendChild(personalBadge);
+ };
+
+ const getShortGroupLabel = (name) => {
+ const normalizedName = (name || "").trim();
+ if (!normalizedName) {
+ return "group";
+ }
+ return normalizedName.slice(0, 8);
+ };
+
+ // Only show badges if there's a valid chat type (meaning documents were used for primary context)
+ // Don't show badges for Model-only conversations
+ if (chatType === 'personal' || chatType === 'personal_single_user') {
+ return;
} else if (chatType && chatType.startsWith('group')) {
// Group workspace was used
const groupBadge = document.createElement("span");
- groupBadge.classList.add("badge", "bg-info", "me-1");
- groupBadge.textContent = groupName ? `group - ${groupName}` : 'group';
-
- const userTypeBadge = document.createElement("span");
- userTypeBadge.classList.add("badge", "bg-secondary");
- userTypeBadge.textContent = chatType.includes('multi-user') ? 'multi-user' : 'single-user';
-
- // Add some spacing between classification badges and chat type badges
- if (classificationsEl.children.length > 0) {
- const spacer = document.createElement("span");
- spacer.innerHTML = " ";
- classificationsEl.appendChild(spacer);
- }
-
+ groupBadge.classList.add("badge", "bg-info");
+ groupBadge.textContent = (groupName || 'group').trim() || 'group';
+
+ appendBadgeSpacer();
classificationsEl.appendChild(groupBadge);
- classificationsEl.appendChild(userTypeBadge);
} else if (chatType && chatType.startsWith('public')) {
// Public workspace was used
const publicBadge = document.createElement("span");
@@ -1652,16 +2031,12 @@ function addChatTypeBadges(convoItem, classificationsEl) {
publicBadge.textContent = groupName ? `public - ${groupName}` : 'public';
// Add some spacing between classification badges and chat type badges
- if (classificationsEl.children.length > 0) {
- const spacer = document.createElement("span");
- spacer.innerHTML = " ";
- classificationsEl.appendChild(spacer);
- }
+ appendBadgeSpacer();
classificationsEl.appendChild(publicBadge);
} else {
- // If chatType is unknown/null or model-only, don't add any workspace badges
- console.log(`addChatTypeBadges: No badges added for chatType="${chatType}" (likely model-only conversation)`);
+ // If chatType is unknown/null/new or model-only, don't add any workspace badges
+ console.log(`addChatTypeBadges: No badges added for chatType="${chatType}" (likely model-only or new conversation)`);
}
}
diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js
index 44596872..dde90dc5 100644
--- a/application/single_app/static/js/chat/chat-documents.js
+++ b/application/single_app/static/js/chat/chat-documents.js
@@ -1,6 +1,7 @@
// chat-documents.js
import { showToast } from "./chat-toast.js";
+import { initializeFilterableDropdownSearch } from "./chat-searchable-select.js";
export const docScopeSelect = document.getElementById("doc-scope-select");
const searchDocumentsBtn = document.getElementById("search-documents-btn");
@@ -8,6 +9,7 @@ const docSelectEl = document.getElementById("document-select"); // Hidden select
const searchDocumentsContainer = document.getElementById("search-documents-container"); // Container for scope/doc/class
// Custom dropdown elements
+const docDropdown = document.getElementById("document-dropdown");
const docDropdownButton = document.getElementById("document-dropdown-button");
const docDropdownItems = document.getElementById("document-dropdown-items");
const docDropdownMenu = document.getElementById("document-dropdown-menu");
@@ -17,17 +19,22 @@ const docSearchInput = document.getElementById("document-search-input");
const chatTagsFilter = document.getElementById("chat-tags-filter");
const tagsDropdown = document.getElementById("tags-dropdown");
const tagsDropdownButton = document.getElementById("tags-dropdown-button");
+const tagsDropdownMenu = document.getElementById("tags-dropdown-menu");
const tagsDropdownItems = document.getElementById("tags-dropdown-items");
+const tagsSearchInput = document.getElementById("tags-search-input");
// Scope dropdown elements
+const scopeDropdown = document.getElementById("scope-dropdown");
const scopeDropdownButton = document.getElementById("scope-dropdown-button");
const scopeDropdownItems = document.getElementById("scope-dropdown-items");
const scopeDropdownMenu = document.getElementById("scope-dropdown-menu");
+const scopeSearchInput = document.getElementById("scope-search-input");
// We'll store personalDocs/groupDocs/publicDocs in memory once loaded:
export let personalDocs = [];
export let groupDocs = [];
export let publicDocs = [];
+const citationMetadataCache = new Map();
// Items removed from the DOM by tag filtering (stored so they can be re-added)
// Each entry: { element, nextSibling }
@@ -49,6 +56,33 @@ let selectedPersonal = true;
let selectedGroupIds = (window.userGroups || []).map(g => g.id);
let selectedPublicWorkspaceIds = (window.userVisiblePublicWorkspaces || []).map(ws => ws.id);
+const documentSearchController = initializeFilterableDropdownSearch({
+ dropdownEl: docDropdown,
+ menuEl: docDropdownMenu,
+ searchInputEl: docSearchInput,
+ itemsContainerEl: docDropdownItems,
+ emptyMessage: 'No matching documents found',
+ isAlwaysVisibleItem: item => item.getAttribute('data-search-role') === 'action',
+});
+
+const scopeSearchController = initializeFilterableDropdownSearch({
+ dropdownEl: scopeDropdown,
+ menuEl: scopeDropdownMenu,
+ searchInputEl: scopeSearchInput,
+ itemsContainerEl: scopeDropdownItems,
+ emptyMessage: 'No matching workspaces found',
+ isAlwaysVisibleItem: item => item.getAttribute('data-search-role') === 'action',
+});
+
+const tagsSearchController = initializeFilterableDropdownSearch({
+ dropdownEl: tagsDropdown,
+ menuEl: tagsDropdownMenu,
+ searchInputEl: tagsSearchInput,
+ itemsContainerEl: tagsDropdownItems,
+ emptyMessage: 'No matching tags found',
+ isAlwaysVisibleItem: item => item.getAttribute('data-search-role') === 'action',
+});
+
/* ---------------------------------------------------------------------------
Get Effective Scopes — used by chat-messages.js and internally
--------------------------------------------------------------------------- */
@@ -160,10 +194,19 @@ export function restoreScopeLockState(lockState, contexts) {
* Reset scope lock for a new conversation.
* Resets to "All" with no lock.
*/
-export function resetScopeLock() {
+export function resetScopeLock(options = {}) {
+ const { preserveSelections = false } = options;
+
scopeLocked = null;
lockedContexts = [];
+ if (preserveSelections) {
+ buildScopeDropdown();
+ updateScopeLockIcon();
+ updateHeaderLockIcon();
+ return;
+ }
+
const groups = window.userGroups || [];
const publicWorkspaces = window.userVisiblePublicWorkspaces || [];
selectedPersonal = true;
@@ -209,6 +252,7 @@ export function setScopeFromUrlParam(scopeString, options = {}) {
}
buildScopeDropdown();
+ dispatchScopeChanged('workspace');
}
/* ---------------------------------------------------------------------------
@@ -227,6 +271,7 @@ function buildScopeDropdown() {
allItem.type = "button";
allItem.classList.add("dropdown-item", "d-flex", "align-items-center", "fw-bold");
allItem.setAttribute("data-scope-action", "toggle-all");
+ allItem.setAttribute("data-search-role", "action");
allItem.style.display = "flex";
allItem.style.width = "100%";
allItem.style.textAlign = "left";
@@ -283,6 +328,7 @@ function buildScopeDropdown() {
}
syncScopeButtonText();
+ scopeSearchController?.applyFilter(scopeSearchInput ? scopeSearchInput.value : '');
}
/* ---------------------------------------------------------------------------
@@ -358,6 +404,7 @@ function rebuildScopeDropdownWithLock() {
syncScopeButtonText();
updateScopeLockIcon();
+ scopeSearchController?.applyFilter(scopeSearchInput ? scopeSearchInput.value : '');
}
/* ---------------------------------------------------------------------------
@@ -421,6 +468,8 @@ function createScopeItem(value, label, checked) {
item.type = "button";
item.classList.add("dropdown-item", "d-flex", "align-items-center");
item.setAttribute("data-scope-value", value);
+ item.setAttribute("data-search-role", "item");
+ item.dataset.searchLabel = label;
item.style.display = "flex";
item.style.width = "100%";
item.style.textAlign = "left";
@@ -514,15 +563,155 @@ function syncScopeButtonText() {
}
}
+function dispatchScopeChanged(source = 'workspace') {
+ window.dispatchEvent(new CustomEvent('chat:scope-changed', {
+ detail: {
+ source,
+ scopes: getEffectiveScopes(),
+ },
+ }));
+}
+
+function runScopeRefreshPipeline(source = 'workspace') {
+ return loadAllDocs()
+ .then(() => loadTagsForScope())
+ .then(() => {
+ dispatchScopeChanged(source);
+ });
+}
+
+export function setEffectiveScopes(nextScopes = {}, options = {}) {
+ if (scopeLocked === true && !options.force) {
+ return Promise.resolve(false);
+ }
+
+ const groups = window.userGroups || [];
+ const publicWorkspaces = window.userVisiblePublicWorkspaces || [];
+ const validGroupIds = new Set(groups.map(group => group.id));
+ const validPublicWorkspaceIds = new Set(publicWorkspaces.map(workspace => workspace.id));
+
+ const normalizedPersonal = !!nextScopes.personal;
+ const normalizedGroupIds = Array.from(new Set((nextScopes.groupIds || []).filter(groupId => validGroupIds.has(groupId))));
+ const normalizedPublicWorkspaceIds = Array.from(new Set((nextScopes.publicWorkspaceIds || []).filter(workspaceId => validPublicWorkspaceIds.has(workspaceId))));
+
+ const selectionChanged = normalizedPersonal !== selectedPersonal
+ || normalizedGroupIds.length !== selectedGroupIds.length
+ || normalizedPublicWorkspaceIds.length !== selectedPublicWorkspaceIds.length
+ || normalizedGroupIds.some((groupId, index) => groupId !== selectedGroupIds[index])
+ || normalizedPublicWorkspaceIds.some((workspaceId, index) => workspaceId !== selectedPublicWorkspaceIds[index]);
+
+ selectedPersonal = normalizedPersonal;
+ selectedGroupIds = normalizedGroupIds;
+ selectedPublicWorkspaceIds = normalizedPublicWorkspaceIds;
+
+ if (scopeLocked === true) {
+ rebuildScopeDropdownWithLock();
+ } else {
+ buildScopeDropdown();
+ }
+
+ syncScopeButtonText();
+
+ if (options.reload === false) {
+ dispatchScopeChanged(options.source || 'programmatic');
+ return Promise.resolve(selectionChanged);
+ }
+
+ return runScopeRefreshPipeline(options.source || 'programmatic')
+ .then(() => selectionChanged);
+}
+
/* ---------------------------------------------------------------------------
Handle scope change — reload docs and tags
--------------------------------------------------------------------------- */
function onScopeChanged() {
syncScopeStateFromCheckboxes();
syncScopeButtonText();
- // Reload docs and tags for the new scope
- loadAllDocs().then(() => {
- loadTagsForScope();
+ runScopeRefreshPipeline('workspace');
+}
+
+function compareDisplayNames(leftValue, rightValue) {
+ return String(leftValue || '').localeCompare(String(rightValue || ''), undefined, {
+ sensitivity: 'base',
+ });
+}
+
+function getDocumentDisplayName(documentItem) {
+ return (documentItem.title || documentItem.file_name || 'Untitled Document').trim() || 'Untitled Document';
+}
+
+function createDropdownHeader(label) {
+ const header = document.createElement('div');
+ header.classList.add('dropdown-header', 'small', 'text-muted', 'px-2', 'pt-2', 'pb-1');
+ header.textContent = label;
+ return header;
+}
+
+function createDropdownDivider() {
+ const divider = document.createElement('div');
+ divider.classList.add('dropdown-divider');
+ return divider;
+}
+
+function buildDocumentDescriptor(documentItem, sectionLabel) {
+ return {
+ id: documentItem.id,
+ label: getDocumentDisplayName(documentItem),
+ searchLabel: `${getDocumentDisplayName(documentItem)} ${sectionLabel}`.trim(),
+ tags: documentItem.tags || [],
+ classification: documentItem.document_classification || '',
+ };
+}
+
+function appendDocumentSection(sectionLabel, documents, sectionIndex) {
+ if (!docDropdownItems || !documents.length) {
+ return;
+ }
+
+ if (sectionIndex > 0) {
+ docDropdownItems.appendChild(createDropdownDivider());
+ }
+
+ docDropdownItems.appendChild(createDropdownHeader(sectionLabel));
+
+ documents.forEach(documentItem => {
+ const doc = buildDocumentDescriptor(documentItem, sectionLabel);
+
+ const opt = document.createElement('option');
+ opt.value = doc.id;
+ opt.textContent = doc.label;
+ opt.dataset.tags = JSON.stringify(doc.tags || []);
+ opt.dataset.classification = doc.classification || '';
+ docSelectEl.appendChild(opt);
+
+ const dropdownItem = document.createElement('button');
+ dropdownItem.type = 'button';
+ dropdownItem.classList.add('dropdown-item', 'd-flex', 'align-items-center');
+ dropdownItem.setAttribute('data-document-id', doc.id);
+ dropdownItem.setAttribute('data-search-role', 'item');
+ dropdownItem.setAttribute('title', doc.label);
+ dropdownItem.dataset.searchLabel = doc.searchLabel;
+ dropdownItem.dataset.tags = JSON.stringify(doc.tags || []);
+ dropdownItem.dataset.classification = doc.classification || '';
+ dropdownItem.style.display = 'flex';
+ dropdownItem.style.width = '100%';
+ dropdownItem.style.textAlign = 'left';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.classList.add('form-check-input', 'me-2', 'doc-checkbox');
+ checkbox.style.pointerEvents = 'none';
+ checkbox.style.minWidth = '16px';
+
+ const label = document.createElement('span');
+ label.textContent = doc.label;
+ label.style.overflow = 'hidden';
+ label.style.textOverflow = 'ellipsis';
+ label.style.whiteSpace = 'nowrap';
+
+ dropdownItem.appendChild(checkbox);
+ dropdownItem.appendChild(label);
+ docDropdownItems.appendChild(dropdownItem);
});
}
@@ -556,6 +745,7 @@ export function populateDocumentSelectScope() {
allItem.type = "button";
allItem.classList.add("dropdown-item");
allItem.setAttribute("data-document-id", "");
+ allItem.setAttribute("data-search-role", "action");
allItem.textContent = "All Documents";
allItem.style.display = "block";
allItem.style.width = "100%";
@@ -563,89 +753,62 @@ export function populateDocumentSelectScope() {
docDropdownItems.appendChild(allItem);
}
- let finalDocs = [];
+ const sections = [];
- // Add personal docs if personal scope is selected
if (scopes.personal) {
- const pDocs = personalDocs.map((d) => ({
- id: d.id,
- label: `[Personal] ${d.title || d.file_name}`,
- tags: d.tags || [],
- classification: d.document_classification || '',
- }));
- finalDocs = finalDocs.concat(pDocs);
- }
-
- // Add group docs — label each with its group name
- if (scopes.groupIds.length > 0) {
- const gDocs = groupDocs.map((d) => ({
- id: d.id,
- label: `[Group: ${groupIdToName[d.group_id] || "Unknown"}] ${d.title || d.file_name}`,
- tags: d.tags || [],
- classification: d.document_classification || '',
- }));
- finalDocs = finalDocs.concat(gDocs);
- }
+ const personalSectionDocs = personalDocs.slice().sort((leftDoc, rightDoc) => {
+ return compareDisplayNames(getDocumentDisplayName(leftDoc), getDocumentDisplayName(rightDoc));
+ });
- // Add public docs — label each with its workspace name
- if (scopes.publicWorkspaceIds.length > 0) {
- // Filter publicDocs to only those in selected workspaces
- const selectedWsSet = new Set(scopes.publicWorkspaceIds);
- const pubDocs = publicDocs
- .filter(d => selectedWsSet.has(d.public_workspace_id))
- .map((d) => ({
- id: d.id,
- label: `[Public: ${publicWorkspaceIdToName[d.public_workspace_id] || "Unknown"}] ${d.title || d.file_name}`,
- tags: d.tags || [],
- classification: d.document_classification || '',
- }));
- finalDocs = finalDocs.concat(pubDocs);
+ if (personalSectionDocs.length > 0) {
+ sections.push({
+ label: 'Personal',
+ documents: personalSectionDocs,
+ });
+ }
}
- // Add document options to the hidden select and populate the custom dropdown
- finalDocs.forEach((doc) => {
- // Add to hidden select
- const opt = document.createElement("option");
- opt.value = doc.id;
- opt.textContent = doc.label;
- opt.dataset.tags = JSON.stringify(doc.tags || []);
- opt.dataset.classification = doc.classification || '';
- docSelectEl.appendChild(opt);
+ const sortedGroups = (window.userGroups || [])
+ .filter(group => scopes.groupIds.includes(group.id))
+ .sort((leftGroup, rightGroup) => compareDisplayNames(leftGroup.name, rightGroup.name));
+ sortedGroups.forEach(group => {
+ const sectionDocs = groupDocs
+ .filter(documentItem => String(documentItem.group_id || '') === String(group.id))
+ .slice()
+ .sort((leftDoc, rightDoc) => compareDisplayNames(getDocumentDisplayName(leftDoc), getDocumentDisplayName(rightDoc)));
+
+ if (sectionDocs.length > 0) {
+ sections.push({
+ label: `[Group] ${group.name || 'Unnamed Group'}`,
+ documents: sectionDocs,
+ });
+ }
+ });
- // Add to custom dropdown
- if (docDropdownItems) {
- const dropdownItem = document.createElement("button");
- dropdownItem.type = "button";
- dropdownItem.classList.add("dropdown-item", "d-flex", "align-items-center");
- dropdownItem.setAttribute("data-document-id", doc.id);
- dropdownItem.setAttribute("title", doc.label);
- dropdownItem.dataset.tags = JSON.stringify(doc.tags || []);
- dropdownItem.dataset.classification = doc.classification || '';
- dropdownItem.style.display = "flex";
- dropdownItem.style.width = "100%";
- dropdownItem.style.textAlign = "left";
-
- const checkbox = document.createElement("input");
- checkbox.type = "checkbox";
- checkbox.classList.add("form-check-input", "me-2", "doc-checkbox");
- checkbox.style.pointerEvents = "none"; // Click handled by button
- checkbox.style.minWidth = "16px";
-
- const label = document.createElement("span");
- label.textContent = doc.label;
- label.style.overflow = "hidden";
- label.style.textOverflow = "ellipsis";
- label.style.whiteSpace = "nowrap";
-
- dropdownItem.appendChild(checkbox);
- dropdownItem.appendChild(label);
- docDropdownItems.appendChild(dropdownItem);
+ const sortedPublicWorkspaces = (window.userVisiblePublicWorkspaces || [])
+ .filter(workspace => scopes.publicWorkspaceIds.includes(workspace.id))
+ .sort((leftWorkspace, rightWorkspace) => compareDisplayNames(leftWorkspace.name, rightWorkspace.name));
+ sortedPublicWorkspaces.forEach(workspace => {
+ const sectionDocs = publicDocs
+ .filter(documentItem => String(documentItem.public_workspace_id || '') === String(workspace.id))
+ .slice()
+ .sort((leftDoc, rightDoc) => compareDisplayNames(getDocumentDisplayName(leftDoc), getDocumentDisplayName(rightDoc)));
+
+ if (sectionDocs.length > 0) {
+ sections.push({
+ label: `[Public] ${workspace.name || 'Unnamed Workspace'}`,
+ documents: sectionDocs,
+ });
}
});
+ sections.forEach((section, sectionIndex) => {
+ appendDocumentSection(section.label, section.documents, sectionIndex);
+ });
+
// Show/hide search based on number of documents
if (docSearchInput && docDropdownItems) {
- const documentsCount = finalDocs.length;
+ const documentsCount = sections.reduce((count, section) => count + section.documents.length, 0);
const searchContainer = docSearchInput.closest('.document-search-container');
if (searchContainer) {
@@ -674,6 +837,7 @@ export function populateDocumentSelectScope() {
// Trigger UI update after populating
handleDocumentSelectChange();
+ documentSearchController?.applyFilter(docSearchInput ? docSearchInput.value : '');
}
export function getDocumentMetadata(docId) {
@@ -693,9 +857,46 @@ export function getDocumentMetadata(docId) {
if (publicMatch) {
return publicMatch;
}
+ const cachedMatch = citationMetadataCache.get(docId);
+ if (cachedMatch) {
+ return cachedMatch;
+ }
return null; // Not found in any list
}
+export async function fetchDocumentMetadata(docId) {
+ if (!docId) {
+ return null;
+ }
+
+ const existingMetadata = getDocumentMetadata(docId);
+ if (existingMetadata) {
+ return existingMetadata;
+ }
+
+ try {
+ const response = await fetch(`/api/enhanced_citations/document_metadata?doc_id=${encodeURIComponent(docId)}`, {
+ credentials: 'same-origin',
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const metadata = await response.json();
+ if (metadata && metadata.id) {
+ citationMetadataCache.set(metadata.id, metadata);
+ }
+ if (metadata && metadata.document_id) {
+ citationMetadataCache.set(metadata.document_id, metadata);
+ }
+ return metadata;
+ } catch (error) {
+ console.warn('Error fetching citation document metadata:', error);
+ return null;
+ }
+}
+
/* ---------------------------------------------------------------------------
Loading Documents
--------------------------------------------------------------------------- */
@@ -831,14 +1032,14 @@ export function loadAllDocs() {
function initializeDocumentDropdown() {
if (!docDropdownMenu) return;
- // Clear any leftover search-filter inline styles on visible items
+ // Clear any leftover search-filter state on visible items
docDropdownItems.querySelectorAll('.dropdown-item').forEach(item => {
- item.removeAttribute('data-filtered');
- item.style.display = '';
+ item.classList.remove('d-none');
});
// Re-apply tag filter (DOM removal approach — no CSS issues)
filterDocumentsBySelectedTags();
+ documentSearchController?.applyFilter(docSearchInput ? docSearchInput.value : '');
// Size the dropdown to fill its parent container
const parentContainer = docDropdownButton.closest('.flex-grow-1');
@@ -871,6 +1072,7 @@ export async function loadTagsForScope() {
// Clear existing options in both hidden select and custom dropdown
chatTagsFilter.innerHTML = '';
if (tagsDropdownItems) tagsDropdownItems.innerHTML = '';
+ resetTagSelectionState();
try {
const scopes = getEffectiveScopes();
@@ -976,6 +1178,7 @@ export async function loadTagsForScope() {
allItem.type = 'button';
allItem.classList.add('dropdown-item', 'text-muted', 'small');
allItem.setAttribute('data-tag-value', '');
+ allItem.setAttribute('data-search-role', 'action');
allItem.textContent = 'Clear All';
allItem.style.display = 'block';
allItem.style.width = '100%';
@@ -993,6 +1196,8 @@ export async function loadTagsForScope() {
item.type = 'button';
item.classList.add('dropdown-item', 'd-flex', 'align-items-center');
item.setAttribute('data-tag-value', tag.name);
+ item.setAttribute('data-search-role', 'item');
+ item.dataset.searchLabel = tag.displayName;
item.style.display = 'flex';
item.style.width = '100%';
item.style.textAlign = 'left';
@@ -1029,6 +1234,8 @@ export async function loadTagsForScope() {
item.type = 'button';
item.classList.add('dropdown-item', 'd-flex', 'align-items-center');
item.setAttribute('data-tag-value', cls.name);
+ item.setAttribute('data-search-role', 'item');
+ item.dataset.searchLabel = cls.displayName;
item.style.display = 'flex';
item.style.width = '100%';
item.style.textAlign = 'left';
@@ -1053,6 +1260,8 @@ export async function loadTagsForScope() {
tagsDropdownItems.appendChild(item);
});
}
+
+ tagsSearchController?.applyFilter(tagsSearchInput ? tagsSearchInput.value : '');
}
} else {
hideTagsDropdown();
@@ -1069,6 +1278,27 @@ function showTagsDropdown() {
function hideTagsDropdown() {
if (tagsDropdown) tagsDropdown.style.display = 'none';
+ if (tagsSearchController) {
+ tagsSearchController.resetFilter();
+ }
+}
+
+function resetTagSelectionState() {
+ if (chatTagsFilter) {
+ Array.from(chatTagsFilter.options).forEach(option => {
+ option.selected = false;
+ });
+ }
+
+ if (tagsDropdownItems) {
+ tagsDropdownItems.querySelectorAll('.tag-checkbox').forEach(checkbox => {
+ checkbox.checked = false;
+ });
+ }
+
+ tagsSearchController?.resetFilter();
+ syncTagsDropdownButtonText();
+ filterDocumentsBySelectedTags();
}
/* ---------------------------------------------------------------------------
@@ -1166,6 +1396,8 @@ export function filterDocumentsBySelectedTags() {
opt.disabled = !matchesSelection(optTags, optClassification);
});
}
+
+ documentSearchController?.applyFilter(docSearchInput ? docSearchInput.value : '');
}
/* ---------------------------------------------------------------------------
@@ -1250,7 +1482,6 @@ if (chatTagsFilter) {
// Tags dropdown: prevent closing when clicking inside
if (tagsDropdownItems) {
- const tagsDropdownMenu = document.getElementById("tags-dropdown-menu");
if (tagsDropdownMenu) {
tagsDropdownMenu.addEventListener('click', function(e) {
e.stopPropagation();
@@ -1413,70 +1644,6 @@ if (docDropdownItems) {
});
}
-// Add search functionality
-if (docSearchInput) {
- // Define our filtering function to ensure consistent filtering logic.
- // Items hidden by tag filter are physically removed from the DOM,
- // so querySelectorAll naturally excludes them.
- const filterDocumentItems = function(searchTerm) {
- if (!docDropdownItems) return;
-
- const items = docDropdownItems.querySelectorAll('.dropdown-item');
- let matchFound = false;
-
- items.forEach(item => {
- const docName = item.textContent.toLowerCase();
-
- if (!searchTerm || docName.includes(searchTerm)) {
- item.style.display = '';
- item.setAttribute('data-filtered', 'visible');
- matchFound = true;
- } else {
- item.style.display = 'none';
- item.setAttribute('data-filtered', 'hidden');
- }
- });
-
- // Show a message if no matches found
- const noMatchesEl = docDropdownItems.querySelector('.no-matches');
- if (!matchFound && searchTerm && searchTerm.length > 0) {
- if (!noMatchesEl) {
- const noMatchesMsg = document.createElement('div');
- noMatchesMsg.className = 'no-matches text-center text-muted py-2';
- noMatchesMsg.textContent = 'No matching documents found';
- docDropdownItems.appendChild(noMatchesMsg);
- }
- } else {
- if (noMatchesEl) {
- noMatchesEl.remove();
- }
- }
- };
-
- // Attach input event directly
- docSearchInput.addEventListener('input', function() {
- const searchTerm = this.value.toLowerCase().trim();
- filterDocumentItems(searchTerm);
- });
-
- // Also attach keyup event as a fallback
- docSearchInput.addEventListener('keyup', function() {
- const searchTerm = this.value.toLowerCase().trim();
- filterDocumentItems(searchTerm);
- });
-
- // Prevent dropdown from closing when clicking in search input
- docSearchInput.addEventListener('click', function(e) {
- e.stopPropagation();
- e.preventDefault();
- });
-
- // Prevent dropdown from closing when pressing keys in search input
- docSearchInput.addEventListener('keydown', function(e) {
- e.stopPropagation();
- });
-}
-
/* ---------------------------------------------------------------------------
Handle Document Selection & Update UI
--------------------------------------------------------------------------- */
@@ -1513,10 +1680,7 @@ document.addEventListener('DOMContentLoaded', function() {
// If search documents button exists, it needs to be clicked to show controls
if (searchDocumentsBtn && docDropdownButton) {
try {
- // Get the dropdown element
- const dropdownEl = document.getElementById('document-dropdown');
-
- if (dropdownEl) {
+ if (docDropdown) {
// Initialize Bootstrap dropdown with the right configuration
new bootstrap.Dropdown(docDropdownButton, {
boundary: 'viewport',
@@ -1537,14 +1701,15 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Clear search when opening
- dropdownEl.addEventListener('show.bs.dropdown', function() {
+ docDropdown.addEventListener('show.bs.dropdown', function() {
if (docSearchInput) {
docSearchInput.value = '';
}
+ documentSearchController?.applyFilter('');
});
// Adjust sizing and focus search when shown
- dropdownEl.addEventListener('shown.bs.dropdown', function() {
+ docDropdown.addEventListener('shown.bs.dropdown', function() {
initializeDocumentDropdown();
if (docSearchInput) {
setTimeout(() => docSearchInput.focus(), 50);
@@ -1552,20 +1717,8 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Clean up inline styles and reset state when hidden
- dropdownEl.addEventListener('hidden.bs.dropdown', function() {
- if (docSearchInput) {
- docSearchInput.value = '';
- }
- // Clear search filtering state
- if (docDropdownItems) {
- const items = docDropdownItems.querySelectorAll('.dropdown-item');
- items.forEach(item => {
- item.removeAttribute('data-filtered');
- item.style.display = '';
- });
- const noMatchesEl = docDropdownItems.querySelector('.no-matches');
- if (noMatchesEl) noMatchesEl.remove();
- }
+ docDropdown.addEventListener('hidden.bs.dropdown', function() {
+ documentSearchController?.resetFilter();
// Clear inline styles set by initializeDocumentDropdown so they
// don't interfere with Bootstrap's positioning on next open
if (docDropdownMenu) {
diff --git a/application/single_app/static/js/chat/chat-edit.js b/application/single_app/static/js/chat/chat-edit.js
index 0e09b0d6..f8d109a7 100644
--- a/application/single_app/static/js/chat/chat-edit.js
+++ b/application/single_app/static/js/chat/chat-edit.js
@@ -3,6 +3,7 @@
import { showToast } from './chat-toast.js';
import { showLoadingIndicatorInChatbox, hideLoadingIndicatorInChatbox } from './chat-loading-indicator.js';
+import { sendMessageWithStreaming } from './chat-streaming.js';
/**
* Handle edit button click - opens edit modal
@@ -146,70 +147,44 @@ window.executeMessageEdit = function() {
console.log(' retry_thread_id:', data.chat_request.retry_thread_id);
console.log(' retry_thread_attempt:', data.chat_request.retry_thread_attempt);
console.log(' Full chat_request:', data.chat_request);
-
- // Call chat API with the edit parameters
- return fetch('/api/chat', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- credentials: 'same-origin',
- body: JSON.stringify(data.chat_request)
- });
+
+ sendMessageWithStreaming(
+ data.chat_request,
+ null,
+ data.chat_request.conversation_id,
+ {
+ onDone: () => {
+ const conversationId = window.chatConversations?.getCurrentConversationId() || data.chat_request.conversation_id;
+ if (conversationId) {
+ import('./chat-messages.js').then(module => {
+ module.loadMessages(conversationId);
+ }).catch(err => {
+ console.error('❌ Error loading chat-messages module:', err);
+ showToast('Failed to reload messages', 'error');
+ });
+ }
+ },
+ onError: (errorMessage) => {
+ showToast(`Edit failed: ${errorMessage}`, 'error');
+ },
+ onFinally: () => {
+ hideLoadingIndicatorInChatbox();
+ }
+ }
+ );
+
+ return null;
} else {
throw new Error('Edit response missing chat_request');
}
})
- .then(response => {
- if (!response.ok) {
- return response.json().then(data => {
- throw new Error(data.error || 'Chat API failed');
- });
- }
- return response.json();
- })
- .then(chatData => {
- console.log('✅ Chat API response:', chatData);
-
- // Hide typing indicator
- hideLoadingIndicatorInChatbox();
- console.log('🧹 Typing indicator removed');
-
- // Get current conversation ID using the proper API
- const conversationId = window.chatConversations?.getCurrentConversationId();
-
- console.log(`🔍 Current conversation ID: ${conversationId}`);
-
- // Reload messages to show edited message and new response
- if (conversationId) {
- console.log('🔄 Reloading messages for conversation:', conversationId);
-
- // Import loadMessages dynamically
- import('./chat-messages.js').then(module => {
- console.log('📦 chat-messages.js module loaded, calling loadMessages...');
- module.loadMessages(conversationId);
- // No toast - the reloaded messages are enough feedback
- }).catch(err => {
- console.error('❌ Error loading chat-messages module:', err);
- showToast('error', 'Failed to reload messages');
- });
- } else {
- console.error('❌ No currentConversationId found!');
-
- // Try to force a page refresh as fallback
- console.log('🔄 Attempting page refresh as fallback...');
- setTimeout(() => {
- window.location.reload();
- }, 1000);
- }
- })
.catch(error => {
console.error('❌ Edit error:', error);
// Hide typing indicator on error
hideLoadingIndicatorInChatbox();
- showToast('error', `Edit failed: ${error.message}`);
+ showToast(`Edit failed: ${error.message}`, 'error');
})
.finally(() => {
// Clean up pending edit
diff --git a/application/single_app/static/js/chat/chat-enhanced-citations.js b/application/single_app/static/js/chat/chat-enhanced-citations.js
index dcda708b..561a7831 100644
--- a/application/single_app/static/js/chat/chat-enhanced-citations.js
+++ b/application/single_app/static/js/chat/chat-enhanced-citations.js
@@ -3,7 +3,7 @@
import { showToast } from "./chat-toast.js";
import { showLoadingIndicator, hideLoadingIndicator } from "./chat-loading-indicator.js";
-import { getDocumentMetadata } from './chat-documents.js';
+import { getDocumentMetadata, fetchDocumentMetadata } from './chat-documents.js';
/**
* Determine file type from filename extension
@@ -18,11 +18,13 @@ export function getFileType(fileName) {
const imageExtensions = ['jpg', 'jpeg', 'png', 'bmp', 'tiff', 'tif'];
const videoExtensions = ['mp4', 'mov', 'avi', 'mkv', 'flv', 'webm', 'wmv', 'm4v', '3gp'];
const audioExtensions = ['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'];
-
+ const tabularExtensions = ['csv', 'xlsx', 'xls', 'xlsm'];
+
if (imageExtensions.includes(ext)) return 'image';
if (ext === 'pdf') return 'pdf';
if (videoExtensions.includes(ext)) return 'video';
if (audioExtensions.includes(ext)) return 'audio';
+ if (tabularExtensions.includes(ext)) return 'tabular';
return 'other';
}
@@ -32,10 +34,16 @@ export function getFileType(fileName) {
* @param {string} docId - Document ID
* @param {string|number} pageNumberOrTimestamp - Page number for PDF or timestamp for video/audio
* @param {string} citationId - Citation ID for fallback
+ * @param {string|null} initialSheetName - Workbook sheet to open initially for tabular files
*/
-export function showEnhancedCitationModal(docId, pageNumberOrTimestamp, citationId) {
- // Get document metadata to determine file type
- const docMetadata = getDocumentMetadata(docId);
+export async function showEnhancedCitationModal(docId, pageNumberOrTimestamp, citationId, initialSheetName = null) {
+ // Get document metadata to determine file type. Historical cited revisions
+ // are not in the current workspace list, so fetch on demand when needed.
+ let docMetadata = getDocumentMetadata(docId);
+ if (!docMetadata || !docMetadata.file_name) {
+ docMetadata = await fetchDocumentMetadata(docId);
+ }
+
if (!docMetadata || !docMetadata.file_name) {
console.warn('Document metadata not found, falling back to text citation');
// Import fetchCitedText dynamically to avoid circular imports
@@ -66,6 +74,9 @@ export function showEnhancedCitationModal(docId, pageNumberOrTimestamp, citation
const audioTimestamp = convertTimestampToSeconds(pageNumberOrTimestamp);
showAudioModal(docId, audioTimestamp, docMetadata.file_name);
break;
+ case 'tabular':
+ showTabularDownloadModal(docId, docMetadata.file_name, initialSheetName);
+ break;
default:
// Fall back to text citation for unsupported types
import('./chat-citations.js').then(module => {
@@ -291,6 +302,249 @@ export function showAudioModal(docId, timestamp, fileName) {
modalInstance.show();
}
+function triggerBlobDownload(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+function getDownloadFilename(response, fallbackFilename) {
+ const contentDisposition = response.headers.get('Content-Disposition') || '';
+ const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
+ if (utf8Match && utf8Match[1]) {
+ try {
+ return decodeURIComponent(utf8Match[1]);
+ } catch (error) {
+ console.warn('Could not decode UTF-8 filename from Content-Disposition:', error);
+ return utf8Match[1];
+ }
+ }
+
+ const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i);
+ if (quotedMatch && quotedMatch[1]) {
+ return quotedMatch[1];
+ }
+
+ const unquotedMatch = contentDisposition.match(/filename=([^;]+)/i);
+ if (unquotedMatch && unquotedMatch[1]) {
+ return unquotedMatch[1].trim();
+ }
+
+ return fallbackFilename || 'download';
+}
+
+async function downloadTabularFile(downloadUrl, fallbackFilename, downloadBtn) {
+ const originalMarkup = downloadBtn.innerHTML;
+ downloadBtn.disabled = true;
+ downloadBtn.classList.add('disabled');
+ downloadBtn.innerHTML = '
Downloading...';
+
+ try {
+ const response = await fetch(downloadUrl, {
+ credentials: 'same-origin',
+ });
+
+ if (!response.ok) {
+ let errorMessage = `Could not download file (${response.status}).`;
+ const contentType = response.headers.get('Content-Type') || '';
+
+ if (contentType.includes('application/json')) {
+ const errorData = await response.json().catch(() => null);
+ if (errorData && errorData.error) {
+ errorMessage = errorData.error;
+ }
+ } else {
+ const errorText = await response.text().catch(() => '');
+ if (errorText) {
+ errorMessage = errorText;
+ }
+ }
+
+ throw new Error(errorMessage);
+ }
+
+ const blob = await response.blob();
+ const downloadFilename = getDownloadFilename(response, fallbackFilename);
+ triggerBlobDownload(blob, downloadFilename);
+ } catch (error) {
+ console.error('Error downloading tabular file:', error);
+ showToast(error.message || 'Could not download file.', 'danger');
+ } finally {
+ downloadBtn.disabled = false;
+ downloadBtn.classList.remove('disabled');
+ downloadBtn.innerHTML = originalMarkup;
+ }
+}
+
+/**
+ * Show tabular file preview modal with data table
+ * @param {string} docId - Document ID
+ * @param {string} fileName - File name
+ * @param {string|null} initialSheetName - Workbook sheet to open initially
+ */
+export function showTabularDownloadModal(docId, fileName, initialSheetName = null) {
+ console.log(`Showing tabular preview modal for docId: ${docId}, fileName: ${fileName}`);
+ showLoadingIndicator();
+
+ // Create or get tabular modal
+ let tabularModal = document.getElementById("enhanced-tabular-modal");
+ if (!tabularModal) {
+ tabularModal = createTabularModal();
+ }
+
+ const title = tabularModal.querySelector(".modal-title");
+ const tableContainer = tabularModal.querySelector("#enhanced-tabular-table-container");
+ const rowInfo = tabularModal.querySelector("#enhanced-tabular-row-info");
+ const downloadBtn = tabularModal.querySelector("#enhanced-tabular-download");
+ const errorContainer = tabularModal.querySelector("#enhanced-tabular-error");
+ const sheetControls = tabularModal.querySelector("#enhanced-tabular-sheet-controls");
+ const sheetSelect = tabularModal.querySelector("#enhanced-tabular-sheet-select");
+
+ title.textContent = `Tabular Data: ${fileName}`;
+ tableContainer.innerHTML = '
Loading...
Loading data preview...
';
+ rowInfo.textContent = '';
+ errorContainer.classList.add('d-none');
+ sheetControls.classList.add('d-none');
+ sheetSelect.innerHTML = '';
+
+ const downloadUrl = `/api/enhanced_citations/tabular_workspace?doc_id=${encodeURIComponent(docId)}`;
+ downloadBtn.onclick = (event) => {
+ event.preventDefault();
+ downloadTabularFile(downloadUrl, fileName, downloadBtn);
+ };
+
+ // Show modal immediately with loading state
+ const modalInstance = new bootstrap.Modal(tabularModal);
+ modalInstance.show();
+
+ const escapeOptionValue = (value) => String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+
+ const loadTabularPreview = (selectedSheetName = null) => {
+ errorContainer.classList.add('d-none');
+
+ const params = new URLSearchParams({
+ doc_id: docId,
+ });
+ if (selectedSheetName) {
+ params.set('sheet_name', selectedSheetName);
+ }
+
+ const previewUrl = `/api/enhanced_citations/tabular_preview?${params.toString()}`;
+ fetch(previewUrl)
+ .then(response => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json();
+ })
+ .then(data => {
+ hideLoadingIndicator();
+ if (data.error) {
+ showTabularError(tableContainer, errorContainer, data.error);
+ return;
+ }
+
+ title.textContent = data.selected_sheet
+ ? `Tabular Data: ${fileName} [${data.selected_sheet}]`
+ : `Tabular Data: ${fileName}`;
+
+ const sheetNames = Array.isArray(data.sheet_names) ? data.sheet_names : [];
+ if (sheetNames.length > 1) {
+ sheetControls.classList.remove('d-none');
+ sheetSelect.innerHTML = sheetNames
+ .map(sheetName => {
+ const isSelected = sheetName === data.selected_sheet ? ' selected' : '';
+ return `
${escapeOptionValue(sheetName)} `;
+ })
+ .join('');
+ sheetSelect.onchange = () => {
+ showLoadingIndicator();
+ loadTabularPreview(sheetSelect.value);
+ };
+ } else {
+ sheetControls.classList.add('d-none');
+ sheetSelect.innerHTML = '';
+ }
+
+ renderTabularPreview(tableContainer, rowInfo, data);
+ })
+ .catch(error => {
+ hideLoadingIndicator();
+ console.error('Error loading tabular preview:', error);
+ showTabularError(tableContainer, errorContainer, 'Could not load data preview.');
+ });
+ };
+
+ loadTabularPreview(initialSheetName);
+}
+
+/**
+ * Render tabular data as an HTML table
+ * @param {HTMLElement} container - Table container element
+ * @param {HTMLElement} rowInfo - Row info display element
+ * @param {Object} data - Preview data from API
+ */
+function renderTabularPreview(container, rowInfo, data) {
+ const { columns, rows, total_rows, truncated, selected_sheet } = data;
+
+ // Build table HTML
+ let html = '
';
+
+ // Header
+ html += '';
+ for (const col of columns) {
+ const escaped = col.replace(/&/g, '&').replace(//g, '>');
+ html += `${escaped} `;
+ }
+ html += ' ';
+
+ // Body
+ html += '';
+ for (const row of rows) {
+ html += '';
+ for (const cell of row) {
+ const val = cell === null || cell === undefined ? '' : String(cell);
+ const escaped = val.replace(/&/g, '&').replace(//g, '>');
+ html += `${escaped} `;
+ }
+ html += ' ';
+ }
+ html += '
';
+
+ container.innerHTML = html;
+
+ // Row info
+ const displayedRows = rows.length;
+ const hasTotalRows = total_rows !== null && total_rows !== undefined;
+ const totalFormatted = hasTotalRows ? total_rows.toLocaleString() : displayedRows.toLocaleString();
+ const sheetPrefix = selected_sheet ? `Sheet ${selected_sheet} · ` : '';
+ if (truncated) {
+ const truncationSuffix = hasTotalRows ? `${totalFormatted} rows` : `${displayedRows.toLocaleString()}+ rows`;
+ rowInfo.textContent = `${sheetPrefix}Showing ${displayedRows.toLocaleString()} of ${truncationSuffix}`;
+ } else {
+ rowInfo.textContent = `${sheetPrefix}${totalFormatted} rows, ${columns.length} columns`;
+ }
+}
+
+/**
+ * Show error state in tabular modal with download fallback
+ * @param {HTMLElement} tableContainer - Table container element
+ * @param {HTMLElement} errorContainer - Error display element
+ * @param {string} message - Error message
+ */
+function showTabularError(tableContainer, errorContainer, message) {
+ tableContainer.innerHTML = '
';
+ errorContainer.textContent = message + ' You can still download the file below.';
+ errorContainer.classList.remove('d-none');
+}
+
/**
* Convert timestamp string to seconds
* @param {string|number} timestamp - Timestamp in various formats
@@ -445,3 +699,40 @@ function createPdfModal() {
document.body.appendChild(modal);
return modal;
}
+
+/**
+ * Create tabular file preview modal HTML structure
+ * @returns {HTMLElement} - Modal element
+ */
+function createTabularModal() {
+ const modal = document.createElement("div");
+ modal.id = "enhanced-tabular-modal";
+ modal.classList.add("modal", "fade");
+ modal.tabIndex = -1;
+ modal.innerHTML = `
+
+ `;
+ document.body.appendChild(modal);
+ return modal;
+}
diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js
index 269cbfe0..f42e0123 100644
--- a/application/single_app/static/js/chat/chat-export.js
+++ b/application/single_app/static/js/chat/chat-export.js
@@ -15,6 +15,8 @@ let exportConversationIds = [];
let exportConversationTitles = {};
let exportFormat = 'json';
let exportPackaging = 'single';
+let includeSummaryIntro = false;
+let summaryModelDeployment = '';
let currentStep = 1;
let totalSteps = 3;
let skipSelectionStep = false;
@@ -53,14 +55,16 @@ function openExportWizard(conversationIds, skipSelection) {
exportConversationTitles = {};
exportFormat = 'json';
exportPackaging = conversationIds.length > 1 ? 'zip' : 'single';
+ includeSummaryIntro = false;
+ summaryModelDeployment = _getDefaultSummaryModel();
skipSelectionStep = !!skipSelection;
// Determine step configuration
if (skipSelectionStep) {
- totalSteps = 3;
+ totalSteps = 4;
currentStep = 1; // Format step (mapped to visual step)
} else {
- totalSteps = 4;
+ totalSteps = 5;
currentStep = 1; // Selection review step
}
@@ -142,19 +146,21 @@ function _renderCurrentStep() {
if (!stepBody) return;
if (skipSelectionStep) {
- // Steps: 1=Format, 2=Packaging, 3=Download
+ // Steps: 1=Format, 2=Packaging, 3=Summary, 4=Download
switch (currentStep) {
case 1: _renderFormatStep(stepBody); break;
case 2: _renderPackagingStep(stepBody); break;
- case 3: _renderDownloadStep(stepBody); break;
+ case 3: _renderSummaryStep(stepBody); break;
+ case 4: _renderDownloadStep(stepBody); break;
}
} else {
- // Steps: 1=Selection, 2=Format, 3=Packaging, 4=Download
+ // Steps: 1=Selection, 2=Format, 3=Packaging, 4=Summary, 5=Download
switch (currentStep) {
case 1: _renderSelectionStep(stepBody); break;
case 2: _renderFormatStep(stepBody); break;
case 3: _renderPackagingStep(stepBody); break;
- case 4: _renderDownloadStep(stepBody); break;
+ case 4: _renderSummaryStep(stepBody); break;
+ case 5: _renderDownloadStep(stepBody); break;
}
}
}
@@ -210,7 +216,7 @@ function _renderFormatStep(container) {
Select the format for your exported conversations.
-
+
@@ -219,7 +225,7 @@ function _renderFormatStep(container) {
-
+
@@ -228,6 +234,15 @@ function _renderFormatStep(container) {
+
+
+
+
+
PDF
+
Print-ready format with chat bubbles. Ideal for archiving and printing.
+
+
+
`;
// Wire card clicks
@@ -297,11 +312,68 @@ function _renderPackagingStep(container) {
});
}
+function _renderSummaryStep(container) {
+ const mainModelSelect = getEl('model-select');
+ const hasModelOptions = Boolean(mainModelSelect && mainModelSelect.options.length > 0);
+ const defaultSummaryModel = summaryModelDeployment || _getDefaultSummaryModel();
+ const perConversationText = exportConversationIds.length > 1
+ ? 'An intro will be generated for each exported conversation.'
+ : 'An intro will be generated for this conversation.';
+
+ container.innerHTML = `
+
+
Optional Intro Summary
+
Add a short abstract before the exported transcript. ${perConversationText}
+
+
+
+ Include AI-generated intro summary
+
+
+
+
Summary model
+
+ ${hasModelOptions ? mainModelSelect.innerHTML : 'No chat models available '}
+
+
Uses the same model list as the chat composer.
+
+
`;
+
+ const toggle = getEl('export-summary-toggle');
+ const modelContainer = getEl('export-summary-model-container');
+ const summaryModelSelect = getEl('export-summary-model');
+
+ if (summaryModelSelect && hasModelOptions) {
+ summaryModelSelect.value = defaultSummaryModel || summaryModelSelect.value;
+ summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value;
+ summaryModelSelect.addEventListener('change', () => {
+ summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value;
+ });
+ }
+
+ if (toggle) {
+ toggle.addEventListener('change', () => {
+ includeSummaryIntro = toggle.checked;
+ if (modelContainer) {
+ modelContainer.classList.toggle('d-none', !includeSummaryIntro);
+ }
+ if (includeSummaryIntro && summaryModelSelect && !summaryModelSelect.value) {
+ summaryModelSelect.value = _getDefaultSummaryModel();
+ summaryModelDeployment = summaryModelSelect.options[summaryModelSelect.selectedIndex]?.dataset?.deploymentName || summaryModelSelect.value;
+ }
+ });
+ }
+}
+
function _renderDownloadStep(container) {
const count = exportConversationIds.length;
- const formatLabel = exportFormat === 'json' ? 'JSON' : 'Markdown';
+ const formatLabels = { json: 'JSON', markdown: 'Markdown', pdf: 'PDF' };
+ const formatLabel = formatLabels[exportFormat] || exportFormat.toUpperCase();
const packagingLabel = exportPackaging === 'zip' ? 'ZIP Archive' : 'Single File';
- const ext = exportPackaging === 'zip' ? '.zip' : (exportFormat === 'json' ? '.json' : '.md');
+ const extMap = { json: '.json', markdown: '.md', pdf: '.pdf' };
+ const ext = exportPackaging === 'zip' ? '.zip' : (extMap[exportFormat] || '.bin');
+ const summaryLabel = includeSummaryIntro ? 'Enabled' : 'Disabled';
+ const summaryModelLabel = includeSummaryIntro ? (summaryModelDeployment || 'Configured default') : '—';
let conversationsList = '';
exportConversationIds.forEach(id => {
@@ -328,6 +400,14 @@ function _renderDownloadStep(container) {
Packaging:
${packagingLabel}
+
+
Intro summary:
+
${summaryLabel}
+
+
+
Summary model:
+
${_escapeHtml(summaryModelLabel)}
+
File type:
${ext}
@@ -364,6 +444,7 @@ function _updateStepIndicators() {
steps = [
{ label: 'Format', icon: 'bi-filetype-json' },
{ label: 'Packaging', icon: 'bi-box' },
+ { label: 'Summary', icon: 'bi-card-text' },
{ label: 'Download', icon: 'bi-download' }
];
} else {
@@ -371,6 +452,7 @@ function _updateStepIndicators() {
{ label: 'Select', icon: 'bi-list-check' },
{ label: 'Format', icon: 'bi-filetype-json' },
{ label: 'Packaging', icon: 'bi-box' },
+ { label: 'Summary', icon: 'bi-card-text' },
{ label: 'Download', icon: 'bi-download' }
];
}
@@ -448,7 +530,9 @@ async function _executeExport() {
body: JSON.stringify({
conversation_ids: exportConversationIds,
format: exportFormat,
- packaging: exportPackaging
+ packaging: exportPackaging,
+ include_summary_intro: includeSummaryIntro,
+ summary_model_deployment: includeSummaryIntro ? summaryModelDeployment : null
})
});
@@ -460,7 +544,8 @@ async function _executeExport() {
// Get filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
- const filename = filenameMatch ? filenameMatch[1] : `conversations_export.${exportPackaging === 'zip' ? 'zip' : (exportFormat === 'json' ? 'json' : 'md')}`;
+ const fallbackExtMap = { json: 'json', markdown: 'md', pdf: 'pdf' };
+ const filename = filenameMatch ? filenameMatch[1] : `conversations_export.${exportPackaging === 'zip' ? 'zip' : (fallbackExtMap[exportFormat] || 'bin')}`;
// Download the blob
const blob = await response.blob();
@@ -511,6 +596,15 @@ function _escapeHtml(text) {
return div.innerHTML;
}
+function _getDefaultSummaryModel() {
+ const mainModelSelect = getEl('model-select');
+ if (!mainModelSelect) {
+ return '';
+ }
+
+ return mainModelSelect.value || (mainModelSelect.options[0] ? mainModelSelect.options[0].value : '');
+}
+
// --- Expose Globally ---
window.chatExport = {
openExportWizard
diff --git a/application/single_app/static/js/chat/chat-input-actions.js b/application/single_app/static/js/chat/chat-input-actions.js
index 77851319..66eaf044 100644
--- a/application/single_app/static/js/chat/chat-input-actions.js
+++ b/application/single_app/static/js/chat/chat-input-actions.js
@@ -127,11 +127,11 @@ export function fetchFileContent(conversationId, fileId) {
hideLoadingIndicator();
if (data.file_content && data.filename) {
- showFileContentPopup(data.file_content, data.filename, data.is_table);
+ showFileContentPopup(data.file_content, data.filename, data.is_table, data.file_content_source, conversationId, fileId);
} else if (data.error) {
showToast(data.error, "danger");
} else {
- ashowToastlert("Unexpected response from server.", "danger");
+ showToast("Unexpected response from server.", "danger");
}
})
.catch((error) => {
@@ -141,7 +141,7 @@ export function fetchFileContent(conversationId, fileId) {
});
}
-export function showFileContentPopup(fileContent, filename, isTable) {
+export function showFileContentPopup(fileContent, filename, isTable, fileContentSource, conversationId, fileId) {
let modalContainer = document.getElementById("file-modal");
if (!modalContainer) {
modalContainer = document.createElement("div");
@@ -155,6 +155,7 @@ export function showFileContentPopup(fileContent, filename, isTable) {
`;
@@ -743,11 +755,13 @@ export function appendMessage(
const metadataContainerId = `metadata-${messageId || Date.now()}`;
const metadataContainerHtml = `
`;
-
+
+ const thoughtsHtml = createThoughtsToggleHtml(messageId);
+
const footerContentHtml = `
@@ -816,6 +831,9 @@ export function appendMessage(
}
});
}
+
+ // Attach thoughts toggle listener
+ attachThoughtsToggleListener(messageDiv, messageId, currentConversationId);
const maskBtn = messageDiv.querySelector(".mask-btn");
if (maskBtn) {
@@ -851,6 +869,50 @@ export function appendMessage(
handleRetryButtonClick(messageDiv, currentMessageId, 'assistant');
});
}
+
+ const dropdownExportMdBtn = messageDiv.querySelector(".dropdown-export-md-btn");
+ if (dropdownExportMdBtn) {
+ dropdownExportMdBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.exportMessageAsMarkdown(messageDiv, currentMessageId, 'assistant');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownExportWordBtn = messageDiv.querySelector(".dropdown-export-word-btn");
+ if (dropdownExportWordBtn) {
+ dropdownExportWordBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.exportMessageAsWord(messageDiv, currentMessageId, 'assistant');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownCopyPromptBtn = messageDiv.querySelector(".dropdown-copy-prompt-btn");
+ if (dropdownCopyPromptBtn) {
+ dropdownCopyPromptBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.copyAsPrompt(messageDiv, currentMessageId, 'assistant');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownOpenEmailBtn = messageDiv.querySelector(".dropdown-open-email-btn");
+ if (dropdownOpenEmailBtn) {
+ dropdownOpenEmailBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.openInEmail(messageDiv, currentMessageId, 'assistant');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
// Handle dropdown positioning manually - move to chatbox container
const dropdownToggle = messageDiv.querySelector(".message-actions .dropdown button[data-bs-toggle='dropdown']");
@@ -1076,6 +1138,11 @@ export function appendMessage(
Edit
Delete
Retry
+
+
Export to Markdown
+
Export to Word
+
Use as Prompt
+
Open in Email
@@ -1324,7 +1391,7 @@ export function sendMessage() {
if (!currentConversationId) {
createNewConversation(() => {
actuallySendMessage(combinedMessage);
- });
+ }, { preserveSelections: true });
} else {
actuallySendMessage(combinedMessage);
}
@@ -1350,13 +1417,18 @@ export function actuallySendMessage(finalMessageToSend) {
userInput.style.height = "";
// Update send button visibility after clearing input
updateSendButtonVisibility();
-
- // Only show loading indicator if NOT using streaming (streaming creates its own placeholder)
- if (!isStreamingEnabled()) {
- showLoadingIndicatorInChatbox();
- }
- const modelDeployment = modelSelect?.value;
+ let modelDeployment = modelSelect?.value;
+ let modelId = null;
+ let modelEndpointId = null;
+ let modelProvider = null;
+ if (window.appSettings?.enable_multi_model_endpoints && modelSelect) {
+ const selectedOption = modelSelect.options[modelSelect.selectedIndex];
+ modelId = selectedOption?.dataset?.modelId || selectedOption?.value || null;
+ modelEndpointId = selectedOption?.dataset?.endpointId || null;
+ modelProvider = selectedOption?.dataset?.provider || null;
+ modelDeployment = selectedOption?.dataset?.deploymentName || null;
+ }
// ... (keep existing logic for hybridSearchEnabled, selectedDocumentId, classificationsToSend, imageGenEnabled)
let hybridSearchEnabled = false;
@@ -1494,278 +1566,23 @@ export function actuallySendMessage(finalMessageToSend) {
chat_type: chat_type,
active_group_ids: finalGroupIds,
active_group_id: finalGroupId,
+ active_public_workspace_ids: scopes.publicWorkspaceIds,
active_public_workspace_id: finalPublicWorkspaceId,
model_deployment: modelDeployment,
+ model_id: modelId,
+ model_endpoint_id: modelEndpointId,
+ model_provider: modelProvider,
prompt_info: promptInfo,
agent_info: agentInfo,
reasoning_effort: getCurrentReasoningEffort()
};
-
- // Check if streaming is enabled (but not for image generation)
- const agentsEnabled = typeof areAgentsEnabled === 'function' && areAgentsEnabled();
- if (isStreamingEnabled() && !imageGenEnabled) {
- const streamInitiated = sendMessageWithStreaming(
- messageData,
- tempUserMessageId,
- currentConversationId
- );
- if (streamInitiated) {
- return; // Streaming handles the rest
- }
- // If streaming failed to initiate, fall through to regular fetch
- }
-
- // Regular non-streaming fetch
- fetch("/api/chat", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- credentials: "same-origin",
- body: JSON.stringify(messageData),
- })
- .then((response) => {
- if (!response.ok) {
- // Handle non-OK responses, try to parse JSON error
- return response
- .json()
- .then((errData) => {
- // Throw an error object including the status and parsed data
- const error = new Error(
- errData.error || `HTTP error! status: ${response.status}`
- );
- error.status = response.status;
- error.data = errData; // Attach full error data
- throw error;
- })
- .catch(() => {
- // If JSON parsing fails, throw a generic error
- throw new Error(`HTTP error! status: ${response.status}`);
- });
- }
- return response.json(); // Parse JSON for successful responses
- })
- .then((data) => {
- // Only successful responses reach here
- hideLoadingIndicatorInChatbox();
+ sendMessageWithStreaming(
+ messageData,
+ tempUserMessageId,
+ currentConversationId
+ );
- console.log("--- Data received from /api/chat ---");
- console.log("Full data object:", data);
- console.log(
- `data.augmented: ${data.augmented} (Type: ${typeof data.augmented})`
- );
- console.log("data.hybrid_citations:", data.hybrid_citations);
- console.log("data.web_search_citations:", data.web_search_citations);
- console.log("data.agent_citations:", data.agent_citations);
- console.log(`data.message_id: ${data.message_id}`);
- console.log(`data.user_message_id: ${data.user_message_id}`);
- console.log(`tempUserMessageId: ${tempUserMessageId}`);
-
- // Update the user message with the real message ID
- if (data.user_message_id) {
- console.log(`🔄 Calling updateUserMessageId(${tempUserMessageId}, ${data.user_message_id})`);
- updateUserMessageId(tempUserMessageId, data.user_message_id);
- } else {
- console.warn(`⚠️ No user_message_id in response! User message will keep temporary ID: ${tempUserMessageId}`);
- }
-
- if (data.reply) {
- // *** Pass the new fields to appendMessage ***
- appendMessage(
- "AI",
- data.reply,
- data.model_deployment_name,
- data.message_id,
- data.augmented, // Pass augmented flag
- data.hybrid_citations, // Pass hybrid citations
- data.web_search_citations, // Pass web citations
- data.agent_citations, // Pass agent citations
- data.agent_display_name, // Pass agent display name
- data.agent_name, // Pass agent name
- null, // fullMessageObject
- true // isNewMessage - trigger autoplay for new responses
- );
- }
- // Show kernel fallback notice if present
- if (data.kernel_fallback_notice) {
- showToast(data.kernel_fallback_notice, 'warning');
- }
- if (data.image_url) {
- // Assuming image messages don't have citations in this flow
- appendMessage(
- "image",
- data.image_url,
- data.model_deployment_name,
- null, // messageId
- false, // augmented
- [], // hybridCitations
- [], // webCitations
- [], // agentCitations
- data.agent_display_name, // Pass agent display name
- data.agent_name // Pass agent name
- );
- }
-
- if (data.reload_messages && currentConversationId) {
- console.log("Reload flag received from backend - refreshing messages.");
- loadMessages(currentConversationId);
- }
-
- // Update conversation list item and header if needed
- if (data.conversation_id) {
- currentConversationId = data.conversation_id; // Update current ID
- const convoItem = document.querySelector(
- `.conversation-item[data-conversation-id="${currentConversationId}"]`
- );
- if (convoItem) {
- let updated = false;
- // Update Title
- if (
- data.conversation_title &&
- convoItem.getAttribute("data-conversation-title") !==
- data.conversation_title
- ) {
- convoItem.setAttribute(
- "data-conversation-title",
- data.conversation_title
- );
- const titleEl = convoItem.querySelector(".conversation-title");
- if (titleEl) titleEl.textContent = data.conversation_title;
-
- // Update sidebar conversation title in real-time
- updateSidebarConversationTitle(currentConversationId, data.conversation_title);
-
- updated = true;
- }
- // Update Classifications
- if (data.classification) {
- // Check if API returned classification
- const currentClassificationJson =
- convoItem.dataset.classifications || "[]";
- const newClassificationJson = JSON.stringify(data.classification);
- if (currentClassificationJson !== newClassificationJson) {
- convoItem.dataset.classifications = newClassificationJson;
- updated = true;
- }
- }
- // Update Timestamp (optional, could be done on load)
- const dateEl = convoItem.querySelector("small");
- if (dateEl)
- dateEl.textContent = new Date().toLocaleString([], {
- dateStyle: "short",
- timeStyle: "short",
- });
-
- if (updated) {
- selectConversation(currentConversationId); // Re-select to update header
- }
- } else {
- // New conversation case
- console.log('[sendMessage] New conversation created, adding to list without reload');
- addConversationToList(
- currentConversationId,
- data.conversation_title,
- data.classification || []
- );
- // Don't call selectConversation here - messages are already displayed
- // Just update the current conversation ID and title
- window.currentConversationId = currentConversationId;
- document.getElementById("current-conversation-title").textContent = data.conversation_title || "New Conversation";
- console.log('[sendMessage] New conversation setup complete, conversation ID:', currentConversationId);
- }
- }
-
- // Apply scope lock if document search was used
- if (data.augmented && currentConversationId) {
- fetch(`/api/conversations/${currentConversationId}/metadata`, { credentials: 'same-origin' })
- .then(r => r.json())
- .then(metadata => {
- if (metadata.scope_locked === true && metadata.locked_contexts) {
- applyScopeLock(metadata.locked_contexts, metadata.scope_locked);
- }
- })
- .catch(err => console.warn('Failed to fetch scope lock metadata:', err));
- }
- })
- .catch((error) => {
- hideLoadingIndicatorInChatbox();
- console.error("Error sending message:", error);
-
- // Display specific error messages based on status or content
- if (error.status === 403 && error.data) {
- // Check for status and data from thrown error
- const categories = (error.data.triggered_categories || [])
- .map((catObj) => `${catObj.category} (severity=${catObj.severity})`)
- .join(", ");
- const reasonMsg = Array.isArray(error.data.reason)
- ? error.data.reason.join(", ")
- : error.data.reason;
-
- appendMessage(
- "safety", // Use 'safety' sender type
- `Your message was blocked by Content Safety.\n\n` +
- `**Categories triggered**: ${categories}\n` +
- `**Reason**: ${reasonMsg}`,
- null, // No model name for safety message
- error.data.message_id, // Use message_id if provided in error
- false, // augmented
- [], // hybridCitations
- [], // webCitations
- [], // agentCitations
- null, // agentDisplayName
- null // agentName
- );
- } else {
- // Show specific embedding error if present, or if status is 500 (embedding backend error)
- const errMsg = (error.message || "").toLowerCase();
-
- // Handle image generation content safety errors
- if (errMsg.includes("safety system") || errMsg.includes("moderation_blocked") || errMsg.includes("content safety")) {
- appendMessage(
- "safety", // Use 'safety' sender type
- `**Image Generation Blocked by Content Safety**\n\n` +
- `Your image generation request was blocked by Azure OpenAI's content safety system. ` +
- `Please try a different prompt that doesn't involve potentially harmful, violent, or illicit content.\n\n` +
- `**Error**: ${error.message || "Content safety violation"}`,
- null, // No model name for safety message
- null, // No message ID for error
- false, // augmented
- [], // hybridCitations
- [], // webCitations
- [], // agentCitations
- null, // agentDisplayName
- null // agentName
- );
- } else if (errMsg.includes("embedding") || error.status === 500) {
- appendMessage(
- "Error",
- "There was an issue with the embedding process. Please check with an admin on embedding configuration.",
- null, // No model name for error message
- null, // No message ID for error
- false, // augmented
- [], // hybridCitations
- [], // webCitations
- [], // agentCitations
- null, // agentDisplayName
- null // agentName
- );
- } else {
- // General error message
- appendMessage(
- "Error",
- `Could not get a response. ${error.message || ""}`,
- null, // No model name for error message
- null, // No message ID for error
- false, // augmented
- [], // hybridCitations
- [], // webCitations
- [], // agentCitations
- null, // agentDisplayName
- null // agentName
- );
- }
- }
- });
+ return;
}
function attachCodeBlockCopyButtons(parentElement) {
@@ -1987,6 +1804,50 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent)
});
});
}
+
+ const dropdownExportMdBtn = messageDiv.querySelector(".dropdown-export-md-btn");
+ if (dropdownExportMdBtn) {
+ dropdownExportMdBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.exportMessageAsMarkdown(messageDiv, currentMessageId, 'user');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownExportWordBtn = messageDiv.querySelector(".dropdown-export-word-btn");
+ if (dropdownExportWordBtn) {
+ dropdownExportWordBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.exportMessageAsWord(messageDiv, currentMessageId, 'user');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownCopyPromptBtn = messageDiv.querySelector(".dropdown-copy-prompt-btn");
+ if (dropdownCopyPromptBtn) {
+ dropdownCopyPromptBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.copyAsPrompt(messageDiv, currentMessageId, 'user');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
+
+ const dropdownOpenEmailBtn = messageDiv.querySelector(".dropdown-open-email-btn");
+ if (dropdownOpenEmailBtn) {
+ dropdownOpenEmailBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const currentMessageId = messageDiv.getAttribute('data-message-id');
+ import('./chat-message-export.js').then(module => {
+ module.openInEmail(messageDiv, currentMessageId, 'user');
+ }).catch(err => console.error('Error loading message export module:', err));
+ });
+ }
// Handle dropdown positioning manually for user messages - move to chatbox
const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']");
@@ -2501,8 +2362,15 @@ document.addEventListener('DOMContentLoaded', function() {
if (modelSelect) {
modelSelect.addEventListener("change", function() {
const selectedModel = modelSelect.value;
- console.log(`Saving preferred model: ${selectedModel}`);
- saveUserSetting({ 'preferredModelDeployment': selectedModel });
+ if (window.appSettings?.enable_multi_model_endpoints) {
+ const selectedOption = modelSelect.options[modelSelect.selectedIndex];
+ const selectionKey = selectedOption?.dataset?.selectionKey || selectedModel;
+ console.log(`Saving preferred model ID: ${selectionKey}`);
+ saveUserSetting({ preferredModelId: selectionKey });
+ } else {
+ console.log(`Saving preferred model deployment: ${selectedModel}`);
+ saveUserSetting({ preferredModelDeployment: selectedModel });
+ }
});
}
@@ -2582,6 +2450,46 @@ function toggleMessageMetadata(messageDiv, messageId) {
* Load message metadata into the drawer for AI/image/file messages
*/
function loadMessageMetadataForDisplay(messageId, container) {
+ function renderHistoryContextRefRow(label, refs) {
+ if (!Array.isArray(refs) || refs.length === 0) {
+ return `${label}: none
`;
+ }
+
+ return `
+
+
${label}:
+
${escapeHtml(refs.join(', '))}
+
+ `;
+ }
+
+ function renderHistoryContextSection(historyContext) {
+ if (!historyContext || typeof historyContext !== 'object') {
+ return '';
+ }
+
+ let sectionHtml = '';
+ sectionHtml += '
History Context
';
+ sectionHtml += '
';
+ sectionHtml += `
Path: ${escapeHtml(String(historyContext.path || 'unknown'))}
`;
+ sectionHtml += `
Stored Messages: ${Number(historyContext.stored_total_messages || 0)}
`;
+ sectionHtml += `
History Limit: ${Number(historyContext.history_limit || 0)}
`;
+ sectionHtml += `
Older Messages: ${Number(historyContext.older_message_count || 0)}
`;
+ sectionHtml += `
Recent Selected: ${Number(historyContext.recent_message_count || 0)}
`;
+ sectionHtml += `
Final API Messages: ${Number(historyContext.final_api_message_count || 0)}
`;
+ sectionHtml += `
Summary Requested: ${historyContext.summary_requested ? 'Yes' : 'No'}
`;
+ sectionHtml += `
Summary Used: ${historyContext.summary_used ? 'Yes' : 'No'}
`;
+ sectionHtml += `
Default System Prompt: ${historyContext.default_system_prompt_inserted ? 'Inserted' : 'Not inserted'}
`;
+ sectionHtml += renderHistoryContextRefRow('Recent Refs', historyContext.selected_recent_message_refs);
+ sectionHtml += renderHistoryContextRefRow('Summarized Refs', historyContext.summarized_message_refs);
+ sectionHtml += renderHistoryContextRefRow('Skipped Inactive', historyContext.skipped_inactive_message_refs);
+ sectionHtml += renderHistoryContextRefRow('Skipped Masked', historyContext.skipped_masked_message_refs);
+ sectionHtml += renderHistoryContextRefRow('Final API Refs', historyContext.final_api_source_refs);
+ sectionHtml += '
';
+
+ return sectionHtml;
+ }
+
fetch(`/api/message/${messageId}/metadata`)
.then(response => {
if (!response.ok) {
@@ -2605,6 +2513,7 @@ function loadMessageMetadataForDisplay(messageId, container) {
active_thread: metadata.active_thread,
thread_attempt: metadata.thread_attempt
};
+ const historyContext = metadata.metadata?.history_context || null;
if (threadInfo.thread_id) {
html += '';
@@ -2667,6 +2576,10 @@ function loadMessageMetadataForDisplay(messageId, container) {
html += '
';
}
+
+ if (metadata.role === 'assistant' && historyContext) {
+ html += renderHistoryContextSection(historyContext);
+ }
html += '
';
container.innerHTML = html;
diff --git a/application/single_app/static/js/chat/chat-model-selector.js b/application/single_app/static/js/chat/chat-model-selector.js
index e69de29b..3c4c5daa 100644
--- a/application/single_app/static/js/chat/chat-model-selector.js
+++ b/application/single_app/static/js/chat/chat-model-selector.js
@@ -0,0 +1,536 @@
+// chat-model-selector.js
+
+import { createSearchableSingleSelect } from './chat-searchable-select.js';
+import { getEffectiveScopes, setEffectiveScopes } from './chat-documents.js';
+import { getConversationFilteringContext } from './chat-conversation-scope.js';
+
+const modelSelect = document.getElementById('model-select');
+const modelDropdown = document.getElementById('model-dropdown');
+const modelDropdownButton = document.getElementById('model-dropdown-button');
+const modelDropdownMenu = document.getElementById('model-dropdown-menu');
+const modelDropdownText = modelDropdownButton
+ ? modelDropdownButton.querySelector('.chat-searchable-select-text')
+ : null;
+const modelSearchInput = document.getElementById('model-search-input');
+const modelDropdownItems = document.getElementById('model-dropdown-items');
+
+let modelSelectorController = null;
+let scopeChangeListenerInitialized = false;
+let suppressScopeNarrowing = false;
+let pendingScopeNarrowingModel = null;
+let scopeClearActionInitialized = false;
+let dropdownHideListenerInitialized = false;
+
+function compareByName(leftValue, rightValue) {
+ return String(leftValue || '').localeCompare(String(rightValue || ''), undefined, {
+ sensitivity: 'base',
+ });
+}
+
+function getBroadScopes() {
+ return {
+ personal: true,
+ groupIds: getKnownGroupIds(),
+ publicWorkspaceIds: getKnownPublicWorkspaceIds(),
+ };
+}
+
+function getSortedGroups() {
+ return (window.userGroups || []).slice().sort((leftGroup, rightGroup) => {
+ return compareByName(leftGroup?.name, rightGroup?.name);
+ });
+}
+
+function getModelDisplayName(option) {
+ return (option.display_name || option.model_id || option.deployment_name || 'Unnamed Model').trim() || 'Unnamed Model';
+}
+
+function getModelSearchText(option, sectionLabel) {
+ return [
+ getModelDisplayName(option),
+ option.model_id || '',
+ option.deployment_name || '',
+ sectionLabel,
+ ].join(' ').trim();
+}
+
+function getSectionDuplicateCounts(options) {
+ return options.reduce((counts, option) => {
+ const key = getModelDisplayName(option).toLowerCase();
+ counts[key] = (counts[key] || 0) + 1;
+ return counts;
+ }, {});
+}
+
+function getModelOptionLabel(option, duplicateCounts) {
+ const displayName = getModelDisplayName(option);
+ const duplicateCount = duplicateCounts[displayName.toLowerCase()] || 0;
+ if (duplicateCount <= 1) {
+ return displayName;
+ }
+
+ return `${displayName} (${option.deployment_name || option.model_id || 'model'})`;
+}
+
+function getKnownGroupIds() {
+ return (window.userGroups || [])
+ .map(group => group?.id)
+ .filter(Boolean)
+ .map(String);
+}
+
+function getKnownPublicWorkspaceIds() {
+ return (window.userVisiblePublicWorkspaces || [])
+ .map(workspace => workspace?.id)
+ .filter(Boolean)
+ .map(String);
+}
+
+function normalizeStringArray(values = []) {
+ return Array.from(new Set(values.filter(Boolean).map(String)));
+}
+
+function areScopesBroad(scopes) {
+ const knownGroupIds = normalizeStringArray(getKnownGroupIds());
+ const selectedGroupIds = normalizeStringArray(scopes.groupIds || []);
+ const knownPublicWorkspaceIds = normalizeStringArray(getKnownPublicWorkspaceIds());
+ const selectedPublicWorkspaceIds = normalizeStringArray(scopes.publicWorkspaceIds || []);
+
+ return scopes.personal === true
+ && knownGroupIds.length === selectedGroupIds.length
+ && knownGroupIds.every(groupId => selectedGroupIds.includes(groupId))
+ && knownPublicWorkspaceIds.length === selectedPublicWorkspaceIds.length
+ && knownPublicWorkspaceIds.every(workspaceId => selectedPublicWorkspaceIds.includes(workspaceId));
+}
+
+function getPreloadedModelOptions() {
+ return Array.isArray(window.chatModelOptions) ? window.chatModelOptions : [];
+}
+
+function isModelEnabledForContext(option, scopes, filteringContext) {
+ if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'group') {
+ return option.scope_type === 'global'
+ || String(option.scope_id || '') === String(filteringContext.groupId || '');
+ }
+
+ if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'public') {
+ return option.scope_type === 'global';
+ }
+
+ if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'personal') {
+ return option.scope_type === 'global' || option.scope_type === 'personal';
+ }
+
+ if (option.scope_type === 'global') {
+ return true;
+ }
+
+ if (option.scope_type === 'group') {
+ return normalizeStringArray(scopes.groupIds || []).includes(String(option.scope_id || ''));
+ }
+
+ if (option.scope_type === 'personal') {
+ return scopes.personal === true;
+ }
+
+ return false;
+}
+
+function buildModelSections(scopes, filteringContext) {
+ const modelOptions = getPreloadedModelOptions();
+ const sections = [];
+
+ const globalModels = modelOptions
+ .filter(option => option.scope_type === 'global')
+ .slice()
+ .sort((leftOption, rightOption) => compareByName(getModelDisplayName(leftOption), getModelDisplayName(rightOption)));
+ if (globalModels.length > 0) {
+ sections.push({
+ label: 'Global',
+ options: globalModels,
+ });
+ }
+
+ const personalModels = modelOptions
+ .filter(option => option.scope_type === 'personal')
+ .slice()
+ .sort((leftOption, rightOption) => compareByName(getModelDisplayName(leftOption), getModelDisplayName(rightOption)));
+ if (personalModels.length > 0) {
+ sections.push({
+ label: 'Personal',
+ options: personalModels,
+ });
+ }
+
+ getSortedGroups().forEach(group => {
+ const sectionOptions = modelOptions
+ .filter(option => option.scope_type === 'group' && String(option.scope_id || '') === String(group.id))
+ .slice()
+ .sort((leftOption, rightOption) => compareByName(getModelDisplayName(leftOption), getModelDisplayName(rightOption)));
+
+ if (sectionOptions.length > 0) {
+ sections.push({
+ label: `[Group] ${group.name || 'Unnamed Group'}`,
+ options: sectionOptions,
+ });
+ }
+ });
+
+ return sections.map(section => {
+ const duplicateCounts = getSectionDuplicateCounts(section.options);
+ return {
+ label: section.label,
+ options: section.options.map(option => ({
+ ...option,
+ optionLabel: getModelOptionLabel(option, duplicateCounts),
+ searchText: getModelSearchText(option, section.label),
+ disabled: !isModelEnabledForContext(option, scopes, filteringContext),
+ })),
+ };
+ });
+}
+
+function getSelectionSnapshot() {
+ if (!modelSelect) {
+ return {
+ value: null,
+ selectionKey: null,
+ modelId: null,
+ deploymentName: null,
+ };
+ }
+
+ const selectedOption = modelSelect.options[modelSelect.selectedIndex];
+ return {
+ value: modelSelect.value || null,
+ selectionKey: selectedOption?.dataset?.selectionKey || null,
+ modelId: selectedOption?.dataset?.modelId || null,
+ deploymentName: selectedOption?.dataset?.deploymentName || null,
+ };
+}
+
+function restoreLegacyPreferredModelSelection(preferredModelDeployment) {
+ if (!modelSelect || !preferredModelDeployment) {
+ return;
+ }
+
+ const matchingOption = Array.from(modelSelect.options).find(option => (
+ option.value === preferredModelDeployment
+ || option.dataset.deploymentName === preferredModelDeployment
+ ));
+
+ if (!matchingOption || matchingOption.disabled) {
+ return;
+ }
+
+ modelSelect.value = matchingOption.value;
+}
+
+function resolveSelectedSelectionKey(options, restoreOptions = {}) {
+ const {
+ currentSelection = null,
+ preferredModelId = null,
+ preferredModelDeployment = null,
+ preserveCurrentSelection = true,
+ } = restoreOptions;
+
+ const enabledOptions = options.filter(option => !option.disabled);
+ const matchBy = predicate => enabledOptions.find(predicate);
+
+ if (preserveCurrentSelection && currentSelection?.selectionKey) {
+ const currentOption = matchBy(option => option.selection_key === currentSelection.selectionKey);
+ if (currentOption) {
+ return currentOption.selection_key;
+ }
+ }
+
+ if (preferredModelId) {
+ const preferredOption = matchBy(option => option.selection_key === preferredModelId || option.model_id === preferredModelId);
+ if (preferredOption) {
+ return preferredOption.selection_key;
+ }
+ }
+
+ if (preferredModelDeployment) {
+ const deploymentOption = matchBy(option => option.deployment_name === preferredModelDeployment);
+ if (deploymentOption) {
+ return deploymentOption.selection_key;
+ }
+ }
+
+ if (preserveCurrentSelection && currentSelection?.deploymentName) {
+ const currentDeploymentOption = matchBy(option => option.deployment_name === currentSelection.deploymentName);
+ if (currentDeploymentOption) {
+ return currentDeploymentOption.selection_key;
+ }
+ }
+
+ if (preserveCurrentSelection && currentSelection?.modelId) {
+ const currentModelOption = matchBy(option => option.model_id === currentSelection.modelId);
+ if (currentModelOption) {
+ return currentModelOption.selection_key;
+ }
+ }
+
+ return enabledOptions[0]?.selection_key || null;
+}
+
+function rebuildModelOptions(sections, restoreOptions = {}) {
+ if (!modelSelect) {
+ return;
+ }
+
+ modelSelect.innerHTML = '';
+
+ const filteringContext = restoreOptions.filteringContext || getConversationFilteringContext();
+ const hideUnavailableOptions = !filteringContext.isNewConversation;
+ const renderedSections = sections
+ .map(section => ({
+ ...section,
+ options: hideUnavailableOptions
+ ? section.options.filter(option => !option.disabled)
+ : section.options,
+ }))
+ .filter(section => section.options.length > 0);
+
+ const flattenedOptions = renderedSections.flatMap(section => section.options);
+
+ if (!flattenedOptions.length) {
+ const emptyOption = document.createElement('option');
+ emptyOption.value = '';
+ emptyOption.textContent = 'No models available';
+ modelSelect.appendChild(emptyOption);
+ modelSelect.disabled = true;
+ return;
+ }
+
+ const selectedSelectionKey = resolveSelectedSelectionKey(flattenedOptions, restoreOptions);
+
+ renderedSections.forEach(section => {
+ const optGroup = document.createElement('optgroup');
+ optGroup.label = section.label;
+
+ section.options.forEach(option => {
+ const modelOption = document.createElement('option');
+ modelOption.value = option.deployment_name || option.model_id || option.selection_key;
+ modelOption.textContent = option.optionLabel;
+ modelOption.dataset.selectionKey = option.selection_key || '';
+ modelOption.dataset.modelId = option.model_id || '';
+ modelOption.dataset.displayName = option.display_name || '';
+ modelOption.dataset.deploymentName = option.deployment_name || '';
+ modelOption.dataset.endpointId = option.endpoint_id || '';
+ modelOption.dataset.provider = option.provider || '';
+ modelOption.dataset.scopeType = option.scope_type || '';
+ modelOption.dataset.scopeId = option.scope_id || '';
+ modelOption.dataset.scopeName = option.scope_name || '';
+ modelOption.dataset.searchText = option.searchText || '';
+ modelOption.disabled = option.disabled;
+ modelOption.selected = !option.disabled && option.selection_key === selectedSelectionKey;
+ optGroup.appendChild(modelOption);
+ });
+
+ modelSelect.appendChild(optGroup);
+ });
+
+ modelSelect.disabled = !flattenedOptions.some(option => !option.disabled);
+}
+
+async function maybeNarrowScopeForSelectedModel(payload) {
+ const filteringContext = getConversationFilteringContext();
+ if (!filteringContext.isNewConversation || !payload) {
+ return;
+ }
+
+ const scopeType = payload.scopeType || '';
+ const scopeId = payload.scopeId || null;
+
+ if (scopeType === 'group' && scopeId) {
+ await setEffectiveScopes(
+ {
+ personal: false,
+ groupIds: [scopeId],
+ publicWorkspaceIds: [],
+ },
+ {
+ source: 'model',
+ }
+ );
+ return;
+ }
+
+ if (scopeType === 'personal') {
+ await setEffectiveScopes(
+ {
+ personal: true,
+ groupIds: [],
+ publicWorkspaceIds: [],
+ },
+ {
+ source: 'model',
+ }
+ );
+ }
+}
+
+function ensureScopeClearAction() {
+ if (scopeClearActionInitialized || !modelDropdownMenu || !modelDropdownItems) {
+ return;
+ }
+
+ const actionContainer = document.createElement('div');
+ actionContainer.classList.add('d-none');
+ actionContainer.setAttribute('data-model-scope-action-container', 'true');
+
+ const divider = document.createElement('div');
+ divider.classList.add('dropdown-divider');
+
+ const actionButton = document.createElement('button');
+ actionButton.type = 'button';
+ actionButton.classList.add('dropdown-item', 'text-muted', 'small');
+ actionButton.textContent = 'Use all available workspaces';
+ actionButton.addEventListener('click', async event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ await setEffectiveScopes(getBroadScopes(), {
+ source: 'model-clear',
+ });
+ });
+
+ actionContainer.appendChild(divider);
+ actionContainer.appendChild(actionButton);
+ modelDropdownItems.before(actionContainer);
+ scopeClearActionInitialized = true;
+}
+
+function updateScopeClearAction(scopes, filteringContext) {
+ ensureScopeClearAction();
+
+ const actionContainer = modelDropdownMenu?.querySelector('[data-model-scope-action-container="true"]');
+ if (!actionContainer) {
+ return;
+ }
+
+ const shouldShowAction = filteringContext.isNewConversation && !areScopesBroad(scopes);
+ actionContainer.classList.toggle('d-none', !shouldShowAction);
+}
+
+function initializeDropdownHideListener() {
+ if (dropdownHideListenerInitialized || !modelDropdown) {
+ return;
+ }
+
+ modelDropdown.addEventListener('hidden.bs.dropdown', async () => {
+ if (!pendingScopeNarrowingModel) {
+ return;
+ }
+
+ const pendingPayload = pendingScopeNarrowingModel;
+ pendingScopeNarrowingModel = null;
+ await maybeNarrowScopeForSelectedModel(pendingPayload);
+ });
+
+ dropdownHideListenerInitialized = true;
+}
+
+function initializeScopeChangeListener() {
+ if (scopeChangeListenerInitialized) {
+ return;
+ }
+
+ window.addEventListener('chat:scope-changed', async () => {
+ await populateModelDropdown();
+ });
+
+ scopeChangeListenerInitialized = true;
+}
+
+function initializeModelChangeHandler() {
+ if (!modelSelect || modelSelect.dataset.scopeHandlerInitialized === 'true') {
+ return;
+ }
+
+ modelSelect.addEventListener('change', async () => {
+ if (suppressScopeNarrowing) {
+ return;
+ }
+
+ const selectedOption = modelSelect.options[modelSelect.selectedIndex];
+ if (!selectedOption || selectedOption.disabled) {
+ pendingScopeNarrowingModel = null;
+ return;
+ }
+
+ pendingScopeNarrowingModel = {
+ scopeType: selectedOption.dataset.scopeType || '',
+ scopeId: selectedOption.dataset.scopeId || null,
+ };
+ });
+
+ modelSelect.dataset.scopeHandlerInitialized = 'true';
+}
+
+export function initializeModelSelector() {
+ if (!modelSelectorController && modelSelect) {
+ modelSelectorController = createSearchableSingleSelect({
+ selectEl: modelSelect,
+ dropdownEl: modelDropdown,
+ buttonEl: modelDropdownButton,
+ buttonTextEl: modelDropdownText,
+ menuEl: modelDropdownMenu,
+ searchInputEl: modelSearchInput,
+ itemsContainerEl: modelDropdownItems,
+ placeholderText: 'Select a Model',
+ emptyMessage: 'No models available',
+ emptySearchMessage: 'No matching models found',
+ getOptionSearchText: option => option.dataset.searchText || option.textContent.trim(),
+ });
+ }
+
+ initializeScopeChangeListener();
+ initializeModelChangeHandler();
+ initializeDropdownHideListener();
+ ensureScopeClearAction();
+
+ return modelSelectorController;
+}
+
+export async function populateModelDropdown(restoreOptions = {}) {
+ initializeModelSelector();
+
+ if (!modelSelect) {
+ return;
+ }
+
+ if (!window.appSettings?.enable_multi_model_endpoints) {
+ restoreLegacyPreferredModelSelection(restoreOptions.preferredModelDeployment || null);
+ modelSelectorController?.refresh();
+ return;
+ }
+
+ const scopes = getEffectiveScopes();
+ const filteringContext = getConversationFilteringContext();
+ const sections = buildModelSections(scopes, filteringContext);
+ const currentSelection = getSelectionSnapshot();
+
+ suppressScopeNarrowing = true;
+ rebuildModelOptions(sections, {
+ currentSelection,
+ preserveCurrentSelection: restoreOptions.preserveCurrentSelection !== false,
+ preferredModelId: restoreOptions.preferredModelId || null,
+ preferredModelDeployment: restoreOptions.preferredModelDeployment || null,
+ filteringContext,
+ });
+ updateScopeClearAction(scopes, filteringContext);
+ modelSelectorController?.refresh();
+ modelSelect.dispatchEvent(new Event('change'));
+ suppressScopeNarrowing = false;
+}
+
+export function refreshModelSelector() {
+ if (!modelSelectorController) {
+ initializeModelSelector();
+ }
+
+ modelSelectorController?.refresh();
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js
index 43e1eba3..d4ed6777 100644
--- a/application/single_app/static/js/chat/chat-onload.js
+++ b/application/single_app/static/js/chat/chat-onload.js
@@ -1,16 +1,17 @@
// chat-onload.js
-import { loadConversations, selectConversation, ensureConversationPresent } from "./chat-conversations.js";
+import { loadConversations, selectConversation, ensureConversationPresent, createNewConversation } from "./chat-conversations.js";
// Import handleDocumentSelectChange
import { loadAllDocs, populateDocumentSelectScope, handleDocumentSelectChange, loadTagsForScope, filterDocumentsBySelectedTags, setScopeFromUrlParam } from "./chat-documents.js";
import { getUrlParameter } from "./chat-utils.js"; // Assuming getUrlParameter is in chat-utils.js now
import { loadUserPrompts, loadGroupPrompts, initializePromptInteractions } from "./chat-prompts.js";
+import { initializeModelSelector, populateModelDropdown } from "./chat-model-selector.js";
import { loadUserSettings } from "./chat-layout.js";
import { showToast } from "./chat-toast.js";
import { initConversationInfoButton } from "./chat-conversation-info-button.js";
-import { initializeStreamingToggle } from "./chat-streaming.js";
import { initializeReasoningToggle } from "./chat-reasoning.js";
import { initializeSpeechInput } from "./chat-speech-input.js";
+import { initChatTutorial } from "./chat-tutorial.js";
window.addEventListener('DOMContentLoaded', async () => {
console.log("DOM Content Loaded. Starting initializations."); // Log start
@@ -21,12 +22,6 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize the conversation info button
initConversationInfoButton();
- // Initialize streaming toggle
- initializeStreamingToggle();
-
- // Initialize reasoning toggle
- initializeReasoningToggle();
-
// Initialize speech input
try {
initializeSpeechInput();
@@ -44,7 +39,7 @@ window.addEventListener('DOMContentLoaded', async () => {
if (userInput && newConversationBtn) {
userInput.addEventListener("focus", () => {
if (!currentConversationId) {
- newConversationBtn.click();
+ createNewConversation(null, { preserveSelections: true });
}
});
}
@@ -55,7 +50,7 @@ window.addEventListener('DOMContentLoaded', async () => {
if (!currentConversationId) {
// Optionally prevent the default action if it does something immediately
// event.preventDefault();
- newConversationBtn.click();
+ createNewConversation(null, { preserveSelections: true });
// (Optional) If you need the prompt UI to appear *after* the conversation is created,
// you can open the prompt UI programmatically in a small setTimeout or callback.
@@ -69,7 +64,7 @@ window.addEventListener('DOMContentLoaded', async () => {
fileBtn.addEventListener("click", (event) => {
if (!currentConversationId) {
// event.preventDefault(); // If file dialog should only open once conversation is created
- newConversationBtn.click();
+ createNewConversation(null, { preserveSelections: true });
// (Optional) If you want the file dialog to appear *after* the conversation is created,
// do it in a short setTimeout or callback:
@@ -79,23 +74,31 @@ window.addEventListener('DOMContentLoaded', async () => {
}
// Load documents, prompts, and user settings
+ const docsPromise = loadAllDocs();
+ const userPromptsPromise = loadUserPrompts();
+ const groupPromptsPromise = loadGroupPrompts();
+ const userSettingsPromise = loadUserSettings();
+
try {
- const [docsResult, userPromptsResult, groupPromptsResult, userSettings] = await Promise.all([
- loadAllDocs(),
- loadUserPrompts(),
- loadGroupPrompts(),
- loadUserSettings()
+ const userSettings = await userSettingsPromise;
+
+ const preferredModelId = userSettings?.preferredModelId;
+ const preferredModelDeployment = userSettings?.preferredModelDeployment;
+
+ initializeModelSelector();
+ await populateModelDropdown({
+ preferredModelId,
+ preferredModelDeployment,
+ preserveCurrentSelection: false,
+ });
+ initializeReasoningToggle(userSettings);
+
+ const [docsResult, userPromptsResult, groupPromptsResult] = await Promise.all([
+ docsPromise,
+ userPromptsPromise,
+ groupPromptsPromise
]);
console.log("Initial data (Docs, Prompts, Settings) loaded successfully."); // Log success
-
- // Set the preferred model if available
- if (userSettings && userSettings.preferredModelDeployment) {
- const modelSelect = document.getElementById("model-select");
- if (modelSelect) {
- console.log(`Setting preferred model: ${userSettings.preferredModelDeployment}`);
- modelSelect.value = userSettings.preferredModelDeployment;
- }
- }
// --- Initialize Document-related UI ---
// This part handles URL params for documents - KEEP IT
@@ -304,5 +307,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Maybe try to initialize prompts even if doc loading fails? Depends on requirements.
// console.log("Attempting to initialize prompts despite data load error...");
// initializePromptInteractions();
+ } finally {
+ initChatTutorial();
}
});
diff --git a/application/single_app/static/js/chat/chat-prompts.js b/application/single_app/static/js/chat/chat-prompts.js
index 01521098..828a5dbd 100644
--- a/application/single_app/static/js/chat/chat-prompts.js
+++ b/application/single_app/static/js/chat/chat-prompts.js
@@ -1,114 +1,195 @@
// chat-prompts.js
-import { userInput} from "./chat-messages.js";
+import { userInput } from "./chat-messages.js";
import { updateSendButtonVisibility } from "./chat-messages.js";
-import { docScopeSelect, getEffectiveScopes } from "./chat-documents.js";
+import { getEffectiveScopes } from "./chat-documents.js";
+import { createSearchableSingleSelect } from "./chat-searchable-select.js";
const promptSelectionContainer = document.getElementById("prompt-selection-container");
export const promptSelect = document.getElementById("prompt-select"); // Keep export if needed elsewhere
const searchPromptsBtn = document.getElementById("search-prompts-btn");
+const promptDropdown = document.getElementById("prompt-dropdown");
+const promptDropdownButton = document.getElementById("prompt-dropdown-button");
+const promptDropdownMenu = document.getElementById("prompt-dropdown-menu");
+const promptDropdownText = promptDropdownButton
+ ? promptDropdownButton.querySelector(".chat-searchable-select-text")
+ : null;
+const promptDropdownItems = document.getElementById("prompt-dropdown-items");
+const promptSearchInput = document.getElementById("prompt-search-input");
+
+let promptSelectorController = null;
+let loadAllPromptsPromise = null;
+let scopeChangeListenerInitialized = false;
+let userPrompts = [];
+let groupPrompts = [];
+let publicPrompts = [];
+
+function getPreloadedPromptOptions() {
+ return Array.isArray(window.chatPromptOptions) ? window.chatPromptOptions : [];
+}
+
+function getPromptLabel(prompt) {
+ return (prompt.name || "Untitled Prompt").trim() || "Untitled Prompt";
+}
+
+function comparePromptNames(leftPrompt, rightPrompt) {
+ return getPromptLabel(leftPrompt).localeCompare(getPromptLabel(rightPrompt), undefined, {
+ sensitivity: "base",
+ });
+}
+
+function getSortedSelectedGroups(groupIds) {
+ const selectedGroupIdSet = new Set((groupIds || []).map(String));
+ return (window.userGroups || [])
+ .filter(group => selectedGroupIdSet.has(String(group.id)))
+ .sort((leftGroup, rightGroup) => (leftGroup.name || "").localeCompare(rightGroup.name || "", undefined, {
+ sensitivity: "base",
+ }));
+}
+
+function getSortedSelectedPublicWorkspaces(workspaceIds) {
+ const selectedWorkspaceIdSet = new Set((workspaceIds || []).map(String));
+ return (window.userVisiblePublicWorkspaces || [])
+ .filter(workspace => selectedWorkspaceIdSet.has(String(workspace.id)))
+ .sort((leftWorkspace, rightWorkspace) => (leftWorkspace.name || "").localeCompare(rightWorkspace.name || "", undefined, {
+ sensitivity: "base",
+ }));
+}
+
+function appendPromptOption(container, prompt) {
+ const option = document.createElement("option");
+ const promptName = getPromptLabel(prompt);
+ const scopeLabel = prompt.scope_type === "group"
+ ? `[Group] ${prompt.scope_name || "Unknown Group"}`
+ : prompt.scope_type === "public"
+ ? `[Public] ${prompt.scope_name || "Unknown Workspace"}`
+ : "Personal";
+
+ option.value = prompt.id || "";
+ option.textContent = promptName;
+ option.dataset.promptContent = prompt.content || "";
+ option.dataset.scopeType = prompt.scope_type || "";
+ option.dataset.scopeId = prompt.scope_id || "";
+ option.dataset.scopeName = prompt.scope_name || "";
+ option.dataset.searchText = `${promptName} ${scopeLabel}`.trim();
+ container.appendChild(option);
+}
+
+function buildPromptSections(scopes) {
+ const sections = [];
+
+ if (scopes.personal) {
+ const personalSectionPrompts = userPrompts.slice().sort(comparePromptNames);
+ if (personalSectionPrompts.length > 0) {
+ sections.push({
+ label: "Personal",
+ prompts: personalSectionPrompts,
+ });
+ }
+ }
+
+ getSortedSelectedGroups(scopes.groupIds).forEach(group => {
+ const sectionPrompts = groupPrompts
+ .filter(prompt => String(prompt.scope_id || "") === String(group.id))
+ .slice()
+ .sort(comparePromptNames);
+
+ if (sectionPrompts.length > 0) {
+ sections.push({
+ label: `[Group] ${group.name || "Unnamed Group"}`,
+ prompts: sectionPrompts,
+ });
+ }
+ });
+
+ getSortedSelectedPublicWorkspaces(scopes.publicWorkspaceIds).forEach(workspace => {
+ const sectionPrompts = publicPrompts
+ .filter(prompt => String(prompt.scope_id || "") === String(workspace.id))
+ .slice()
+ .sort(comparePromptNames);
+
+ if (sectionPrompts.length > 0) {
+ sections.push({
+ label: `[Public] ${workspace.name || "Unnamed Workspace"}`,
+ prompts: sectionPrompts,
+ });
+ }
+ });
+
+ return sections;
+}
+
+function initializePromptSelector() {
+ if (promptSelectorController || !promptSelect) {
+ return promptSelectorController;
+ }
+
+ promptSelectorController = createSearchableSingleSelect({
+ selectEl: promptSelect,
+ dropdownEl: promptDropdown,
+ buttonEl: promptDropdownButton,
+ buttonTextEl: promptDropdownText,
+ menuEl: promptDropdownMenu,
+ searchInputEl: promptSearchInput,
+ itemsContainerEl: promptDropdownItems,
+ placeholderText: "Select a Prompt...",
+ emptyMessage: "No prompts available",
+ emptySearchMessage: "No matching prompts found",
+ getOptionSearchText: option => option.dataset.searchText || option.textContent.trim(),
+ });
+
+ return promptSelectorController;
+}
export function loadUserPrompts() {
- return fetch("/api/prompts")
- .then(r => r.json())
- .then(data => {
- if (data.prompts) {
- userPrompts = data.prompts;
- }
- })
- .catch(err => console.error("Error loading user prompts:", err));
+ userPrompts = getPreloadedPromptOptions().filter(prompt => prompt.scope_type === "personal");
+ return Promise.resolve(userPrompts);
}
export function loadGroupPrompts() {
- return fetch("/api/group_prompts")
- .then(r => {
- if (!r.ok) {
- // Handle 400 errors gracefully (e.g., no active group selected)
- if (r.status === 400) {
- console.log("No active group selected for group prompts");
- groupPrompts = [];
- return { prompts: [] }; // Return empty result to avoid further errors
- }
- throw new Error(`HTTP ${r.status}: ${r.statusText}`);
- }
- return r.json();
- })
- .then(data => {
- if (data.prompts) {
- groupPrompts = data.prompts;
- }
- })
- .catch(err => console.error("Error loading group prompts:", err));
+ groupPrompts = getPreloadedPromptOptions().filter(prompt => prompt.scope_type === "group");
+ return Promise.resolve(groupPrompts);
}
export function loadPublicPrompts() {
- return fetch("/api/public_prompts")
- .then(r => {
- if (!r.ok) {
- // Handle 400 errors gracefully
- if (r.status === 400) {
- console.log("No public prompts available");
- publicPrompts = [];
- return { prompts: [] }; // Return empty result to avoid further errors
- }
- throw new Error(`HTTP ${r.status}: ${r.statusText}`);
- }
- return r.json();
- })
- .then(data => {
- if (data.prompts) {
- publicPrompts = data.prompts;
- }
- })
- .catch(err => console.error("Error loading public prompts:", err));
+ publicPrompts = getPreloadedPromptOptions().filter(prompt => prompt.scope_type === "public");
+ return Promise.resolve(publicPrompts);
}
export function populatePromptSelectScope() {
- if (!promptSelect) return;
+ if (!promptSelect) return;
- // Determine effective scope from multi-select dropdown
- const scopes = getEffectiveScopes();
- console.log("Populating prompt dropdown with scopes:", scopes);
- console.log("User prompts:", userPrompts.length);
- console.log("Group prompts:", groupPrompts.length);
- console.log("Public prompts:", publicPrompts.length);
+ initializePromptSelector();
- const previousValue = promptSelect.value; // Store previous selection if needed
- promptSelect.innerHTML = "";
+ const scopes = getEffectiveScopes();
+ const previousValue = promptSelect.value;
+ promptSelect.innerHTML = "";
- const defaultOpt = document.createElement("option");
- defaultOpt.value = "";
- defaultOpt.textContent = "Select a Prompt...";
- promptSelect.appendChild(defaultOpt);
+ const defaultOpt = document.createElement("option");
+ defaultOpt.value = "";
+ defaultOpt.textContent = "Select a Prompt...";
+ promptSelect.appendChild(defaultOpt);
- let finalPrompts = [];
+ buildPromptSections(scopes).forEach(section => {
+ const optGroup = document.createElement("optgroup");
+ optGroup.label = section.label;
- // Include prompts based on which scopes are selected
- if (scopes.personal) {
- finalPrompts = finalPrompts.concat(userPrompts.map((p) => ({...p, scope: "Personal"})));
- }
- if (scopes.groupIds.length > 0) {
- finalPrompts = finalPrompts.concat(groupPrompts.map((p) => ({...p, scope: "Group"})));
- }
- if (scopes.publicWorkspaceIds.length > 0) {
- finalPrompts = finalPrompts.concat(publicPrompts.map((p) => ({...p, scope: "Public"})));
- }
+ section.prompts.forEach(prompt => {
+ appendPromptOption(optGroup, prompt);
+ });
- // Add prompt options
- finalPrompts.forEach((promptObj) => {
- const opt = document.createElement("option");
- opt.value = promptObj.id;
- opt.textContent = `[${promptObj.scope}] ${promptObj.name}`;
- opt.dataset.promptContent = promptObj.content;
- promptSelect.appendChild(opt);
- });
+ promptSelect.appendChild(optGroup);
+ });
- // Try to restore previous selection if it still exists, otherwise default to "Select a Prompt..."
- if (finalPrompts.some(prompt => prompt.id === previousValue)) {
- promptSelect.value = previousValue;
- } else {
- promptSelect.value = ""; // Default to "Select a Prompt..."
- }
+ const availableOptions = Array.from(promptSelect.options);
+ if (availableOptions.some(option => option.value === previousValue)) {
+ promptSelect.value = previousValue;
+ } else {
+ promptSelect.value = "";
+ }
+
+ promptSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ promptSelectorController?.refresh();
}
// Keep the old function for backward compatibility, but have it call the scope-aware version
@@ -117,61 +198,68 @@ export function populatePromptSelect() {
}
export function loadAllPrompts() {
- return Promise.all([loadUserPrompts(), loadGroupPrompts(), loadPublicPrompts()])
- .then(() => {
- console.log("All prompts loaded, populating scope-based select...");
- populatePromptSelectScope();
- })
- .catch(err => console.error("Error loading all prompts:", err));
+ if (loadAllPromptsPromise) {
+ return loadAllPromptsPromise;
+ }
+
+ loadAllPromptsPromise = Promise.all([loadUserPrompts(), loadGroupPrompts(), loadPublicPrompts()])
+ .then(() => {
+ populatePromptSelectScope();
+ })
+ .catch(err => {
+ console.error("Error loading all prompts:", err);
+ })
+ .finally(() => {
+ loadAllPromptsPromise = null;
+ });
+
+ return loadAllPromptsPromise;
+}
+
+function initializeScopeChangeListener() {
+ if (scopeChangeListenerInitialized) {
+ return;
+ }
+
+ window.addEventListener("chat:scope-changed", () => {
+ if (!promptSelectionContainer || promptSelectionContainer.style.display !== "block") {
+ return;
+ }
+
+ populatePromptSelectScope();
+ });
+
+ scopeChangeListenerInitialized = true;
}
export function initializePromptInteractions() {
- console.log("Attempting to initialize prompt interactions..."); // Debug log
-
- // Check for elements *inside* the function that runs later
- if (searchPromptsBtn && promptSelectionContainer && userInput) {
- console.log("Elements found, adding prompt button listener."); // Debug log
-
- searchPromptsBtn.addEventListener("click", function() {
- const isActive = this.classList.toggle("active");
-
- if (isActive) {
- promptSelectionContainer.style.display = "block";
- // Load all prompts and populate with scope filtering
- loadAllPrompts();
- userInput.classList.add("with-prompt-active");
- userInput.focus();
- // Update send button visibility when prompts are shown
- updateSendButtonVisibility();
- } else {
- promptSelectionContainer.style.display = "none";
- if (promptSelect) {
- promptSelect.selectedIndex = 0;
- }
- userInput.classList.remove("with-prompt-active");
- userInput.focus();
- // Update send button visibility when prompts are hidden
- updateSendButtonVisibility();
- }
- });
-
- // Add event listener for scope changes to update prompts
- if (docScopeSelect) {
- // Add event listener that will repopulate prompts when scope changes
- docScopeSelect.addEventListener("change", function() {
- // Only repopulate if prompts are currently visible
- if (promptSelectionContainer && promptSelectionContainer.style.display === "block") {
- console.log("Scope changed, repopulating prompts...");
- populatePromptSelectScope();
- }
- });
- }
-
- } else {
- // Log detailed errors if elements are missing WHEN this function runs
- if (!searchPromptsBtn) console.error("Prompt Init Error: search-prompts-btn not found.");
- if (!promptSelectionContainer) console.error("Prompt Init Error: prompt-selection-container not found.");
- // This check is crucial: is userInput null/undefined when this function executes?
- if (!userInput) console.error("Prompt Init Error: userInput (imported from chat-messages) is not available.");
- }
+ if (searchPromptsBtn && promptSelectionContainer && userInput) {
+ initializePromptSelector();
+ initializeScopeChangeListener();
+
+ searchPromptsBtn.addEventListener("click", function() {
+ const isActive = this.classList.toggle("active");
+
+ if (isActive) {
+ promptSelectionContainer.style.display = "block";
+ loadAllPrompts();
+ userInput.classList.add("with-prompt-active");
+ userInput.focus();
+ updateSendButtonVisibility();
+ } else {
+ promptSelectionContainer.style.display = "none";
+ if (promptSelect) {
+ promptSelect.selectedIndex = 0;
+ promptSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ userInput.classList.remove("with-prompt-active");
+ userInput.focus();
+ updateSendButtonVisibility();
+ }
+ });
+ } else {
+ if (!searchPromptsBtn) console.error("Prompt Init Error: search-prompts-btn not found.");
+ if (!promptSelectionContainer) console.error("Prompt Init Error: prompt-selection-container not found.");
+ if (!userInput) console.error("Prompt Init Error: userInput (imported from chat-messages) is not available.");
+ }
}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-reasoning.js b/application/single_app/static/js/chat/chat-reasoning.js
index 252fba91..545aa6cf 100644
--- a/application/single_app/static/js/chat/chat-reasoning.js
+++ b/application/single_app/static/js/chat/chat-reasoning.js
@@ -4,10 +4,46 @@ import { showToast } from './chat-toast.js';
let reasoningEffortSettings = {}; // Per-model settings: {modelName: 'low', ...}
+function setTooltipText(element, text, options = {}) {
+ if (!element) {
+ return;
+ }
+
+ if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) {
+ element.title = text;
+ return;
+ }
+
+ element.setAttribute('data-bs-toggle', 'tooltip');
+ element.setAttribute('data-bs-title', text);
+ element.setAttribute('data-bs-original-title', text);
+ element.removeAttribute('title');
+
+ if (options.placement) {
+ element.setAttribute('data-bs-placement', options.placement);
+ }
+
+ if (options.trigger) {
+ element.setAttribute('data-bs-trigger', options.trigger);
+ }
+
+ const tooltip = bootstrap.Tooltip.getOrCreateInstance(element, options);
+ if (typeof tooltip.setContent === 'function') {
+ tooltip.setContent({ '.tooltip-inner': text });
+ }
+}
+
+function applyReasoningSettings(settings = {}) {
+ console.log('Loaded reasoning settings:', settings);
+ reasoningEffortSettings = settings.reasoningEffortSettings || {};
+ console.log('Reasoning effort settings:', reasoningEffortSettings);
+ syncReasoningStateForCurrentModel();
+}
+
/**
* Initialize the reasoning effort toggle button
*/
-export function initializeReasoningToggle() {
+export function initializeReasoningToggle(initialSettings = null) {
const reasoningToggleBtn = document.getElementById('reasoning-toggle-btn');
if (!reasoningToggleBtn) {
console.warn('Reasoning toggle button not found');
@@ -17,16 +53,16 @@ export function initializeReasoningToggle() {
console.log('Initializing reasoning toggle...');
// Load initial state from user settings
- loadUserSettings().then(settings => {
- console.log('Loaded reasoning settings:', settings);
- reasoningEffortSettings = settings.reasoningEffortSettings || {};
- console.log('Reasoning effort settings:', reasoningEffortSettings);
-
- // Update icon based on current model
- updateReasoningIconForCurrentModel();
- }).catch(error => {
- console.error('Error loading reasoning settings:', error);
- });
+ if (initialSettings) {
+ applyReasoningSettings(initialSettings);
+ } else {
+ loadUserSettings().then(settings => {
+ applyReasoningSettings(settings);
+ }).catch(error => {
+ console.error('Error loading reasoning settings:', error);
+ syncReasoningStateForCurrentModel();
+ });
+ }
// Handle toggle click - show slider modal
reasoningToggleBtn.addEventListener('click', () => {
@@ -37,8 +73,7 @@ export function initializeReasoningToggle() {
const modelSelect = document.getElementById('model-select');
if (modelSelect) {
modelSelect.addEventListener('change', () => {
- updateReasoningIconForCurrentModel();
- updateReasoningButtonVisibility();
+ syncReasoningStateForCurrentModel();
});
}
@@ -63,6 +98,11 @@ export function initializeReasoningToggle() {
updateReasoningButtonVisibility();
}
+export function syncReasoningStateForCurrentModel() {
+ updateReasoningIconForCurrentModel();
+ updateReasoningButtonVisibility();
+}
+
/**
* Update reasoning button visibility based on image generation state, agent state, and model support
*/
@@ -108,7 +148,9 @@ function getCurrentModelName() {
if (!modelSelect || !modelSelect.value) {
return null;
}
- return modelSelect.value;
+
+ const selectedOption = modelSelect.options[modelSelect.selectedIndex];
+ return selectedOption?.dataset?.modelId || selectedOption?.dataset?.deploymentName || modelSelect.value;
}
/**
@@ -230,7 +272,7 @@ export function updateReasoningIcon(level) {
'medium': 'Medium reasoning effort',
'high': 'High reasoning effort'
};
- reasoningToggleBtn.title = labelMap[level] || 'Configure reasoning effort';
+ setTooltipText(reasoningToggleBtn, labelMap[level] || 'Configure reasoning effort');
}
/**
@@ -292,7 +334,6 @@ export function showReasoningSlider() {
const levelDiv = document.createElement('div');
levelDiv.className = `reasoning-level ${isActive ? 'active' : ''} ${!isSupported ? 'disabled' : ''}`;
levelDiv.dataset.level = level;
- levelDiv.title = levelDescriptions[level];
levelDiv.innerHTML = `
@@ -300,6 +341,8 @@ export function showReasoningSlider() {
${levelLabels[level]}
`;
+
+ setTooltipText(levelDiv, levelDescriptions[level], { placement: 'right' });
if (isSupported) {
levelDiv.addEventListener('click', () => {
diff --git a/application/single_app/static/js/chat/chat-retry.js b/application/single_app/static/js/chat/chat-retry.js
index 55cfbf8e..3f357c07 100644
--- a/application/single_app/static/js/chat/chat-retry.js
+++ b/application/single_app/static/js/chat/chat-retry.js
@@ -3,6 +3,7 @@
import { showToast } from './chat-toast.js';
import { showLoadingIndicatorInChatbox, hideLoadingIndicatorInChatbox } from './chat-loading-indicator.js';
+import { sendMessageWithStreaming } from './chat-streaming.js';
/**
* Populate retry agent dropdown with available agents
@@ -14,21 +15,40 @@ async function populateRetryAgentDropdown() {
try {
// Import agent functions dynamically
const agentsModule = await import('../agents_common.js');
- const { fetchUserAgents, fetchGroupAgentsForActiveGroup, fetchSelectedAgent, populateAgentSelect } = agentsModule;
+ const { fetchUserAgents, fetchGroupAgentsForActiveGroup, fetchSelectedAgent, populateAgentSelect, getUserSetting } = agentsModule;
// Fetch available agents
+ const activeItem = document.querySelector('.conversation-item.active');
+ const chatType = activeItem?.getAttribute('data-chat-type') || '';
+ const chatState = activeItem?.getAttribute('data-chat-state') || '';
+ const conversationScope = (chatState === 'new' || chatType === 'new')
+ ? null
+ : (chatType ? (chatType.startsWith('group') ? 'group' : 'personal') : 'personal');
+ const itemGroupId = activeItem?.getAttribute('data-group-id') || null;
+ const userActiveGroupId = await getUserSetting('activeGroupOid');
+ const rawGroupId = conversationScope === 'group'
+ ? (itemGroupId || window.groupWorkspaceContext?.activeGroupId || userActiveGroupId || window.activeGroupId || null)
+ : (chatState === 'new' ? (itemGroupId || userActiveGroupId || window.activeGroupId || null) : null);
+ const activeGroupId = rawGroupId && !['none', 'null', 'undefined'].includes(String(rawGroupId).toLowerCase())
+ ? rawGroupId
+ : null;
const [userAgents, selectedAgent] = await Promise.all([
fetchUserAgents(),
fetchSelectedAgent()
]);
- const groupAgents = await fetchGroupAgentsForActiveGroup();
+ const groupAgents = activeGroupId ? await fetchGroupAgentsForActiveGroup(activeGroupId) : [];
// Combine and order agents
- const combinedAgents = [...userAgents, ...groupAgents];
- const personalAgents = combinedAgents.filter(agent => !agent.is_global && !agent.is_group);
- const activeGroupAgents = combinedAgents.filter(agent => agent.is_group);
- const globalAgents = combinedAgents.filter(agent => agent.is_global);
- const orderedAgents = [...personalAgents, ...activeGroupAgents, ...globalAgents];
+ const personalAgents = userAgents.filter(agent => !agent.is_global && !agent.is_group);
+ const globalAgents = userAgents.filter(agent => agent.is_global);
+ let orderedAgents = [];
+ if (!conversationScope) {
+ orderedAgents = [...personalAgents, ...groupAgents, ...globalAgents];
+ } else if (conversationScope === 'group') {
+ orderedAgents = [...groupAgents, ...globalAgents];
+ } else {
+ orderedAgents = [...personalAgents, ...globalAgents];
+ }
// Populate retry agent select using shared function
populateAgentSelect(retryAgentSelect, orderedAgents, selectedAgent);
@@ -119,7 +139,8 @@ export async function handleRetryButtonClick(messageDiv, messageId, messageType)
let showReasoning = false;
if (retryModeModel && retryModeModel.checked) {
- const selectedModel = retryModelSelect ? retryModelSelect.value : null;
+ const selectedOption = retryModelSelect ? retryModelSelect.options[retryModelSelect.selectedIndex] : null;
+ const selectedModel = selectedOption?.dataset?.modelId || selectedOption?.dataset?.deploymentName || (retryModelSelect ? retryModelSelect.value : null);
showReasoning = selectedModel && selectedModel.includes('o1');
} else if (retryModeAgent && retryModeAgent.checked) {
// Check if agent uses o1 model (you could enhance this by checking agent config)
@@ -218,7 +239,8 @@ window.executeMessageRetry = function() {
} else {
// Model mode - get model and reasoning effort
const retryModelSelect = document.getElementById('retry-model-select');
- const selectedModel = retryModelSelect ? retryModelSelect.value : null;
+ const selectedOption = retryModelSelect ? retryModelSelect.options[retryModelSelect.selectedIndex] : null;
+ const selectedModel = selectedOption?.dataset?.deploymentName || (retryModelSelect ? retryModelSelect.value : null);
requestBody.model = selectedModel;
let reasoningEffort = null;
@@ -274,70 +296,44 @@ window.executeMessageRetry = function() {
console.log(' retry_thread_id:', data.chat_request.retry_thread_id);
console.log(' retry_thread_attempt:', data.chat_request.retry_thread_attempt);
console.log(' Full chat_request:', data.chat_request);
-
- // Call chat API with the retry parameters
- return fetch('/api/chat', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- credentials: 'same-origin',
- body: JSON.stringify(data.chat_request)
- });
+
+ sendMessageWithStreaming(
+ data.chat_request,
+ null,
+ data.chat_request.conversation_id,
+ {
+ onDone: () => {
+ const conversationId = window.chatConversations?.getCurrentConversationId() || data.chat_request.conversation_id;
+ if (conversationId) {
+ import('./chat-messages.js').then(module => {
+ module.loadMessages(conversationId);
+ }).catch(err => {
+ console.error('❌ Error loading chat-messages module:', err);
+ showToast('Failed to reload messages', 'error');
+ });
+ }
+ },
+ onError: (errorMessage) => {
+ showToast(`Retry failed: ${errorMessage}`, 'error');
+ },
+ onFinally: () => {
+ hideLoadingIndicatorInChatbox();
+ }
+ }
+ );
+
+ return null;
} else {
throw new Error('Retry response missing chat_request');
}
})
- .then(response => {
- if (!response.ok) {
- return response.json().then(data => {
- throw new Error(data.error || 'Chat API failed');
- });
- }
- return response.json();
- })
- .then(chatData => {
- console.log('✅ Chat API response:', chatData);
-
- // Hide typing indicator
- hideLoadingIndicatorInChatbox();
- console.log('🧹 Typing indicator removed');
-
- // Get current conversation ID using the proper API
- const conversationId = window.chatConversations?.getCurrentConversationId();
-
- console.log(`🔍 Current conversation ID: ${conversationId}`);
-
- // Reload messages to show new attempt (which will automatically hide old attempts)
- if (conversationId) {
- console.log('🔄 Reloading messages for conversation:', conversationId);
-
- // Import loadMessages dynamically
- import('./chat-messages.js').then(module => {
- console.log('📦 chat-messages.js module loaded, calling loadMessages...');
- module.loadMessages(conversationId);
- // No toast - the reloaded messages are enough feedback
- }).catch(err => {
- console.error('❌ Error loading chat-messages module:', err);
- showToast('error', 'Failed to reload messages');
- });
- } else {
- console.error('❌ No currentConversationId found!');
-
- // Try to force a page refresh as fallback
- console.log('🔄 Attempting page refresh as fallback...');
- setTimeout(() => {
- window.location.reload();
- }, 1000);
- }
- })
.catch(error => {
console.error('❌ Retry error:', error);
// Hide typing indicator on error
hideLoadingIndicatorInChatbox();
- showToast('error', `Retry failed: ${error.message}`);
+ showToast(`Retry failed: ${error.message}`, 'error');
})
.finally(() => {
// Clean up pending retry
diff --git a/application/single_app/static/js/chat/chat-searchable-select.js b/application/single_app/static/js/chat/chat-searchable-select.js
new file mode 100644
index 00000000..c8acbcfd
--- /dev/null
+++ b/application/single_app/static/js/chat/chat-searchable-select.js
@@ -0,0 +1,424 @@
+// chat-searchable-select.js
+
+function createNoMatchesElement(message) {
+ const noMatchesEl = document.createElement('div');
+ noMatchesEl.className = 'no-matches text-center text-muted py-2';
+ noMatchesEl.textContent = message;
+ return noMatchesEl;
+}
+
+function removeNoMatchesElement(itemsContainerEl) {
+ const noMatchesEl = itemsContainerEl.querySelector('.no-matches');
+ if (noMatchesEl) {
+ noMatchesEl.remove();
+ }
+}
+
+function isVisibleItem(el) {
+ return Boolean(
+ el &&
+ !el.classList.contains('d-none') &&
+ !el.classList.contains('dropdown-divider')
+ );
+}
+
+function updateDropdownStructure(itemsContainerEl) {
+ if (!itemsContainerEl) {
+ return;
+ }
+
+ const children = Array.from(itemsContainerEl.children).filter(child => !child.classList.contains('no-matches'));
+
+ children.forEach(child => {
+ if (!child.classList.contains('dropdown-header')) {
+ return;
+ }
+
+ let hasVisibleItem = false;
+ let next = child.nextElementSibling;
+
+ while (next && !next.classList.contains('dropdown-header')) {
+ if (next.classList.contains('dropdown-item') && isVisibleItem(next)) {
+ hasVisibleItem = true;
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+
+ child.classList.toggle('d-none', !hasVisibleItem);
+ });
+
+ children.forEach(child => {
+ if (!child.classList.contains('dropdown-divider')) {
+ return;
+ }
+
+ let previousVisible = null;
+ let previous = child.previousElementSibling;
+ while (previous) {
+ if (!previous.classList.contains('no-matches') && isVisibleItem(previous)) {
+ previousVisible = previous;
+ break;
+ }
+ previous = previous.previousElementSibling;
+ }
+
+ let nextVisible = null;
+ let next = child.nextElementSibling;
+ while (next) {
+ if (!next.classList.contains('no-matches') && isVisibleItem(next)) {
+ nextVisible = next;
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+
+ child.classList.toggle('d-none', !(previousVisible && nextVisible));
+ });
+}
+
+function createDropdownHeader(label) {
+ const header = document.createElement('div');
+ header.classList.add('dropdown-header', 'small', 'text-muted', 'px-2', 'pt-2', 'pb-1');
+ header.textContent = label;
+ return header;
+}
+
+function createDropdownDivider() {
+ const divider = document.createElement('div');
+ divider.classList.add('dropdown-divider');
+ return divider;
+}
+
+export function initializeFilterableDropdownSearch({
+ dropdownEl,
+ buttonEl,
+ menuEl,
+ searchInputEl,
+ itemsContainerEl,
+ emptyMessage,
+ getItemSearchText,
+ isAlwaysVisibleItem,
+ itemSelector = '.dropdown-item',
+ clearSearchOnHide = true,
+}) {
+ if (!menuEl || !searchInputEl || !itemsContainerEl) {
+ return null;
+ }
+
+ const readSearchText = getItemSearchText || (item => item.dataset.searchLabel || item.textContent || '');
+ const isAlwaysVisible = isAlwaysVisibleItem || (() => false);
+
+ const applyFilter = (rawSearchTerm = '') => {
+ const searchTerm = rawSearchTerm.toLowerCase().trim();
+ const items = Array.from(itemsContainerEl.querySelectorAll(itemSelector));
+ let visibleMatchCount = 0;
+
+ items.forEach(item => {
+ const keepVisible = isAlwaysVisible(item);
+ const searchText = String(readSearchText(item) || '').toLowerCase();
+ const matches = !searchTerm || keepVisible || searchText.includes(searchTerm);
+
+ item.classList.toggle('d-none', !matches);
+
+ if (matches && !keepVisible) {
+ visibleMatchCount += 1;
+ }
+ });
+
+ removeNoMatchesElement(itemsContainerEl);
+ updateDropdownStructure(itemsContainerEl);
+
+ if (searchTerm && visibleMatchCount === 0) {
+ itemsContainerEl.appendChild(createNoMatchesElement(emptyMessage));
+ }
+ };
+
+ const resetFilter = () => {
+ searchInputEl.value = '';
+ applyFilter('');
+ };
+
+ menuEl.addEventListener('click', event => {
+ event.stopPropagation();
+ });
+
+ menuEl.addEventListener('keydown', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('click', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('keydown', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('input', () => {
+ applyFilter(searchInputEl.value);
+ });
+
+ if (dropdownEl) {
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ searchInputEl.focus();
+ searchInputEl.select();
+ });
+
+ if (clearSearchOnHide) {
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ resetFilter();
+ });
+ }
+ }
+
+ if (buttonEl) {
+ try {
+ bootstrap.Dropdown.getOrCreateInstance(buttonEl, {
+ autoClose: 'outside'
+ });
+ } catch (error) {
+ console.error('Error initializing dropdown search helper:', error);
+ }
+ }
+
+ applyFilter('');
+
+ return {
+ applyFilter,
+ resetFilter,
+ };
+}
+
+export function createSearchableSingleSelect({
+ selectEl,
+ dropdownEl,
+ buttonEl,
+ buttonTextEl,
+ menuEl,
+ searchInputEl,
+ itemsContainerEl,
+ placeholderText,
+ emptyMessage,
+ emptySearchMessage,
+ getOptionLabel,
+ getOptionSearchText,
+}) {
+ if (!selectEl || !dropdownEl || !buttonEl || !buttonTextEl || !menuEl || !searchInputEl || !itemsContainerEl) {
+ return null;
+ }
+
+ const readOptionLabel = getOptionLabel || (option => option.textContent.trim());
+ const readOptionSearchText = getOptionSearchText || (option => option.textContent.trim());
+
+ const getTopLevelEntries = () => Array.from(selectEl.children).filter(child => {
+ const tagName = child.tagName;
+ return tagName === 'OPTION' || tagName === 'OPTGROUP';
+ });
+
+ const getSelectedOption = () => {
+ if (selectEl.selectedIndex < 0) {
+ return null;
+ }
+
+ return selectEl.options[selectEl.selectedIndex] || null;
+ };
+
+ const syncButtonText = () => {
+ const selectedOption = getSelectedOption();
+ const label = selectedOption ? readOptionLabel(selectedOption) : '';
+ buttonTextEl.textContent = label || placeholderText;
+ };
+
+ const renderOptions = () => {
+ const searchTerm = searchInputEl.value.toLowerCase().trim();
+ const options = Array.from(selectEl.options);
+ const optionIndexMap = new Map(options.map((option, index) => [option, index]));
+ const selectedIndex = selectEl.selectedIndex;
+ const hasEnabledOption = options.some(option => !option.disabled);
+
+ itemsContainerEl.innerHTML = '';
+
+ if (!options.length) {
+ buttonEl.disabled = true;
+ searchInputEl.disabled = true;
+ buttonTextEl.textContent = emptyMessage;
+ itemsContainerEl.appendChild(createNoMatchesElement(emptyMessage));
+ return;
+ }
+
+ let matchedCount = 0;
+
+ const appendOptionItem = option => {
+ const index = optionIndexMap.get(option);
+ const optionLabel = readOptionLabel(option);
+ const optionSearchText = String(readOptionSearchText(option) || optionLabel).toLowerCase();
+ const matches = !searchTerm || optionSearchText.includes(searchTerm);
+
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.classList.add('dropdown-item', 'chat-searchable-select-item');
+ item.dataset.optionIndex = String(index);
+ item.dataset.optionValue = option.value;
+ item.dataset.searchLabel = optionSearchText;
+ item.title = optionLabel;
+
+ if (!matches) {
+ item.classList.add('d-none');
+ } else {
+ matchedCount += 1;
+ }
+
+ if (index === selectedIndex) {
+ item.classList.add('active');
+ }
+
+ if (option.disabled) {
+ item.classList.add('disabled');
+ item.disabled = true;
+ }
+
+ const textEl = document.createElement('span');
+ textEl.className = 'chat-searchable-select-item-text';
+ textEl.textContent = optionLabel;
+ item.appendChild(textEl);
+
+ itemsContainerEl.appendChild(item);
+ };
+
+ let renderedGroupCount = 0;
+ getTopLevelEntries().forEach(entry => {
+ if (entry.tagName === 'OPTGROUP') {
+ const groupOptions = Array.from(entry.children).filter(child => child.tagName === 'OPTION');
+ if (!groupOptions.length) {
+ return;
+ }
+
+ if (itemsContainerEl.children.length > 0) {
+ itemsContainerEl.appendChild(createDropdownDivider());
+ }
+
+ itemsContainerEl.appendChild(createDropdownHeader(entry.label || ''));
+ groupOptions.forEach(option => {
+ appendOptionItem(option);
+ });
+ renderedGroupCount += 1;
+ return;
+ }
+
+ appendOptionItem(entry);
+ });
+
+ buttonEl.disabled = !hasEnabledOption;
+ searchInputEl.disabled = !hasEnabledOption;
+ syncButtonText();
+
+ if (renderedGroupCount > 0) {
+ updateDropdownStructure(itemsContainerEl);
+ }
+
+ if (matchedCount === 0) {
+ itemsContainerEl.appendChild(createNoMatchesElement(searchTerm ? emptySearchMessage : emptyMessage));
+ }
+ };
+
+ const syncFromSelect = () => {
+ renderOptions();
+ };
+
+ const selectOption = optionIndex => {
+ const normalizedIndex = Number(optionIndex);
+ const option = selectEl.options[normalizedIndex];
+
+ if (!option || option.disabled) {
+ return;
+ }
+
+ selectEl.selectedIndex = normalizedIndex;
+ renderOptions();
+ selectEl.dispatchEvent(new Event('change', { bubbles: true }));
+
+ try {
+ bootstrap.Dropdown.getOrCreateInstance(buttonEl, {
+ autoClose: 'outside'
+ }).hide();
+ } catch (error) {
+ console.error('Error hiding dropdown after selection:', error);
+ }
+ };
+
+ itemsContainerEl.addEventListener('click', event => {
+ const item = event.target.closest('.chat-searchable-select-item[data-option-index]');
+ if (!item) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ selectOption(item.dataset.optionIndex);
+ });
+
+ menuEl.addEventListener('click', event => {
+ event.stopPropagation();
+ });
+
+ menuEl.addEventListener('keydown', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('click', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('keydown', event => {
+ event.stopPropagation();
+ });
+
+ searchInputEl.addEventListener('input', () => {
+ renderOptions();
+ });
+
+ dropdownEl.addEventListener('show.bs.dropdown', () => {
+ searchInputEl.value = '';
+ renderOptions();
+ });
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ searchInputEl.focus();
+ });
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ searchInputEl.value = '';
+ renderOptions();
+ });
+
+ selectEl.addEventListener('change', syncFromSelect);
+
+ const observer = new MutationObserver(() => {
+ renderOptions();
+ });
+ observer.observe(selectEl, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['disabled', 'label', 'selected', 'value']
+ });
+
+ try {
+ bootstrap.Dropdown.getOrCreateInstance(buttonEl, {
+ autoClose: 'outside'
+ });
+ } catch (error) {
+ console.error('Error initializing searchable select:', error);
+ }
+
+ renderOptions();
+
+ return {
+ refresh: renderOptions,
+ syncFromSelect,
+ destroy() {
+ observer.disconnect();
+ }
+ };
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js
index 4e89144f..02d40164 100644
--- a/application/single_app/static/js/chat/chat-sidebar-conversations.js
+++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js
@@ -6,11 +6,190 @@ import { showToast } from "./chat-toast.js";
const sidebarConversationsList = document.getElementById("sidebar-conversations-list");
const sidebarNewChatBtn = document.getElementById("sidebar-new-chat-btn");
+function dispatchSidebarConversationsLoaded(details = {}) {
+ document.dispatchEvent(new CustomEvent('chat:sidebar-conversations-loaded', {
+ detail: details
+ }));
+}
+
let currentActiveConversationId = null;
let sidebarShowHiddenConversations = false; // Track if hidden conversations should be shown in sidebar
let isLoadingSidebarConversations = false; // Prevent concurrent sidebar loads
let pendingSidebarReload = false; // Track if a reload is pending
+function getShortGroupLabel(name) {
+ const normalizedName = (name || '').trim();
+ if (!normalizedName) {
+ return 'group';
+ }
+ return normalizedName.slice(0, 8);
+}
+
+function normalizeSidebarChatType(chatType, context = []) {
+ if (chatType === 'personal') {
+ return 'personal_single_user';
+ }
+
+ if (chatType) {
+ return chatType;
+ }
+
+ const primaryContext = Array.isArray(context)
+ ? context.find(item => item?.type === 'primary')
+ : null;
+
+ if (primaryContext?.scope === 'group') {
+ return 'group-single-user';
+ }
+
+ if (primaryContext?.scope === 'public') {
+ return 'public';
+ }
+
+ return 'personal_single_user';
+}
+
+function applySidebarConversationContextAttributes(sidebarItem, chatType, context = []) {
+ if (!sidebarItem) {
+ return;
+ }
+
+ const normalizedChatType = normalizeSidebarChatType(chatType, context);
+ sidebarItem.setAttribute('data-chat-type', normalizedChatType);
+ sidebarItem.removeAttribute('data-group-name');
+ sidebarItem.removeAttribute('data-group-id');
+ sidebarItem.removeAttribute('data-public-workspace-id');
+
+ if (normalizedChatType.startsWith('group')) {
+ const primaryGroupContext = Array.isArray(context)
+ ? context.find(ctx => ctx?.type === 'primary' && ctx?.scope === 'group')
+ : null;
+
+ if (primaryGroupContext) {
+ sidebarItem.setAttribute('data-group-name', primaryGroupContext.name || 'Group');
+ if (primaryGroupContext.id) {
+ sidebarItem.setAttribute('data-group-id', primaryGroupContext.id);
+ }
+ }
+ return;
+ }
+
+ if (normalizedChatType.startsWith('public')) {
+ const primaryPublicContext = Array.isArray(context)
+ ? context.find(ctx => ctx?.type === 'primary' && ctx?.scope === 'public')
+ : null;
+
+ if (primaryPublicContext) {
+ sidebarItem.setAttribute('data-group-name', primaryPublicContext.name || 'Workspace');
+ if (primaryPublicContext.id) {
+ sidebarItem.setAttribute('data-public-workspace-id', primaryPublicContext.id);
+ }
+ }
+ }
+}
+
+function renderSidebarConversationScopeBadge(sidebarItem, chatType, context = []) {
+ const titleWrapper = sidebarItem?.querySelector('.sidebar-conversation-header');
+ if (!titleWrapper) {
+ return;
+ }
+
+ titleWrapper.querySelectorAll('.sidebar-conversation-group-badge').forEach(badge => badge.remove());
+
+ const normalizedChatType = normalizeSidebarChatType(chatType, context);
+ if (!normalizedChatType.startsWith('group')) {
+ return;
+ }
+
+ const primaryGroupContext = Array.isArray(context)
+ ? context.find(ctx => ctx?.type === 'primary' && ctx?.scope === 'group')
+ : null;
+
+ const badge = document.createElement('span');
+ badge.classList.add('badge', 'bg-info', 'sidebar-conversation-group-badge');
+ badge.textContent = getShortGroupLabel(primaryGroupContext?.name);
+ badge.title = primaryGroupContext?.name
+ ? `Group conversation: ${primaryGroupContext.name}`
+ : 'Group conversation';
+ titleWrapper.appendChild(badge);
+}
+
+function createUnreadDotElement() {
+ const unreadDot = document.createElement('span');
+ unreadDot.classList.add('conversation-unread-dot', 'sidebar-conversation-unread-dot');
+ unreadDot.setAttribute('aria-hidden', 'true');
+ return unreadDot;
+}
+
+function getSidebarConversationDropdownInstance(dropdownBtn) {
+ if (!dropdownBtn || !window.bootstrap || !bootstrap.Dropdown) {
+ return null;
+ }
+
+ return bootstrap.Dropdown.getOrCreateInstance(dropdownBtn, {
+ popperConfig(defaultConfig) {
+ const existingModifiers = Array.isArray(defaultConfig?.modifiers) ? defaultConfig.modifiers : [];
+ const hasFlipModifier = existingModifiers.some(modifier => modifier?.name === 'flip');
+
+ return {
+ ...defaultConfig,
+ strategy: 'fixed',
+ modifiers: hasFlipModifier
+ ? existingModifiers
+ : [
+ ...existingModifiers,
+ {
+ name: 'flip',
+ options: {
+ fallbackPlacements: ['top-end', 'bottom-end']
+ }
+ }
+ ]
+ };
+ }
+ });
+}
+
+function closeSidebarConversationDropdown(dropdownBtn, dropdownInstance) {
+ if (dropdownInstance) {
+ dropdownInstance.hide();
+ return;
+ }
+
+ if (!dropdownBtn || !window.bootstrap || !bootstrap.Dropdown) {
+ return;
+ }
+
+ const fallbackDropdownInstance = bootstrap.Dropdown.getInstance(dropdownBtn);
+ if (fallbackDropdownInstance) {
+ fallbackDropdownInstance.hide();
+ }
+}
+
+export function setConversationUnreadState(conversationId, hasUnread) {
+ const sidebarItem = document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`);
+ if (!sidebarItem) {
+ return;
+ }
+
+ sidebarItem.dataset.hasUnreadAssistantResponse = hasUnread ? 'true' : 'false';
+
+ const titleWrapper = sidebarItem.querySelector('.sidebar-conversation-header');
+ const titleElement = sidebarItem.querySelector('.sidebar-conversation-title');
+ const existingDot = sidebarItem.querySelector('.sidebar-conversation-unread-dot');
+
+ if (!hasUnread) {
+ if (existingDot) {
+ existingDot.remove();
+ }
+ return;
+ }
+
+ if (!existingDot && titleWrapper && titleElement) {
+ titleWrapper.insertBefore(createUnreadDotElement(), titleElement);
+ }
+}
+
// Load conversations for the sidebar
export function loadSidebarConversations() {
if (!sidebarConversationsList) return;
@@ -32,6 +211,7 @@ export function loadSidebarConversations() {
sidebarConversationsList.innerHTML = "";
if (!data.conversations || data.conversations.length === 0) {
sidebarConversationsList.innerHTML = '
No conversations yet.
';
+ dispatchSidebarConversationsLoaded({ loaded: true, count: 0, hasVisibleConversations: false, isError: false });
// Reset loading flag even when no conversations
isLoadingSidebarConversations = false;
@@ -83,6 +263,13 @@ export function loadSidebarConversations() {
visibleConversations.forEach(convo => {
sidebarConversationsList.appendChild(createSidebarConversationItem(convo));
});
+
+ dispatchSidebarConversationsLoaded({
+ loaded: true,
+ count: data.conversations.length,
+ hasVisibleConversations: visibleConversations.length > 0,
+ isError: false
+ });
// Restore selection mode hints if selection mode is active
if (window.chatConversations && window.chatConversations.isSelectionModeActive && window.chatConversations.isSelectionModeActive()) {
@@ -109,6 +296,7 @@ export function loadSidebarConversations() {
.catch(error => {
console.error("Error loading sidebar conversations:", error);
sidebarConversationsList.innerHTML = `
Error loading conversations: ${error.error || 'Unknown error'}
`;
+ dispatchSidebarConversationsLoaded({ loaded: true, count: 0, hasVisibleConversations: false, isError: true });
isLoadingSidebarConversations = false; // Reset flag on error too
// If a reload was requested while we were loading, reload now even after error
@@ -124,19 +312,8 @@ function createSidebarConversationItem(convo) {
const convoItem = document.createElement("div");
convoItem.classList.add("sidebar-conversation-item");
convoItem.setAttribute("data-conversation-id", convo.id);
- if (convo.chat_type) {
- convoItem.setAttribute("data-chat-type", convo.chat_type);
- }
- let groupName = null;
- if (Array.isArray(convo.context)) {
- const primaryGroupContext = convo.context.find(ctx => ctx.type === "primary" && ctx.scope === "group");
- if (primaryGroupContext) {
- groupName = primaryGroupContext.name || null;
- }
- }
- if (groupName) {
- convoItem.setAttribute("data-group-name", groupName);
- }
+ convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? 'true' : 'false';
+ applySidebarConversationContextAttributes(convoItem, convo.chat_type || '', convo.context || []);
const isPinned = convo.is_pinned || false;
const isHidden = convo.is_hidden || false;
@@ -147,7 +324,7 @@ function createSidebarConversationItem(convo) {
-
+
`;
}
+ actionButtons = `
${viewButton}${editDeleteButtons}
`;
tr.innerHTML = `
-
${safeDisplayName} ${globalBadge}
-
${safeDesc}
+
${safeDisplayName} ${globalBadge}
+
${truncatedDesc}
${actionButtons}
`;
tbody.appendChild(tr);
@@ -109,6 +116,34 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, en
tbody.querySelectorAll('.delete-plugin-btn').forEach(btn => {
btn.onclick = () => onDelete(btn.getAttribute('data-plugin-name'));
});
+ tbody.querySelectorAll('.view-plugin-btn').forEach(btn => {
+ btn.onclick = () => {
+ if (onView) {
+ onView(btn.getAttribute('data-plugin-name'));
+ }
+ };
+ });
+}
+
+// Render plugins grid (card-based view)
+export function renderPluginsGrid({plugins, containerSelector, onEdit, onDelete, onView, isAdmin = false}) {
+ const container = document.querySelector(containerSelector);
+ if (!container) return;
+ container.innerHTML = '';
+ if (!plugins.length) {
+ container.innerHTML = '
No actions found.
';
+ return;
+ }
+ plugins.forEach(plugin => {
+ const card = createActionCard(plugin, {
+ onView: (p) => { if (onView) onView(p.name); },
+ onEdit: (p) => onEdit(p.name),
+ onDelete: (p) => onDelete(p.name),
+ canManage: isAdmin || !plugin.is_global,
+ isAdmin
+ });
+ container.appendChild(card);
+ });
}
// Toggle auth fields and labels (parameterized for DOM elements)
@@ -296,6 +331,20 @@ export async function validatePluginManifest(pluginManifest) {
return await validatePluginManifestServerSide(pluginManifest);
}
+export async function getErrorMessageFromResponse(response, fallbackMessage = 'Request failed') {
+ const responseText = await response.text();
+ if (!responseText) {
+ return fallbackMessage;
+ }
+
+ try {
+ const errorData = JSON.parse(responseText);
+ return errorData.error || responseText;
+ } catch (error) {
+ return responseText;
+ }
+}
+
// Server-side validation fallback
async function validatePluginManifestServerSide(pluginManifest) {
try {
diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js
index 89076076..2e619fcf 100644
--- a/application/single_app/static/js/plugin_modal_stepper.js
+++ b/application/single_app/static/js/plugin_modal_stepper.js
@@ -1,6 +1,10 @@
// plugin_modal_stepper.js
// Multi-step modal functionality for action/plugin creation
import { showToast } from "./chat/chat-toast.js";
+import { getTypeIcon } from "./workspace/view-utils.js";
+
+// Action types hidden from the creation UI (backend plugins remain intact)
+const HIDDEN_ACTION_TYPES = ['sql_schema', 'ui_test', 'queue_storage', 'blob_storage', 'embedding_model'];
export class PluginModalStepper {
@@ -129,6 +133,12 @@ export class PluginModalStepper {
document.getElementById('sql-auth-type').addEventListener('change', () => this.handleSqlAuthTypeChange());
+ // Test SQL connection button
+ const testConnBtn = document.getElementById('sql-test-connection-btn');
+ if (testConnBtn) {
+ testConnBtn.addEventListener('click', () => this.testSqlConnection());
+ }
+
// Set up display name to generated name conversion
this.setupNameGeneration();
@@ -193,6 +203,8 @@ export class PluginModalStepper {
if (!res.ok) throw new Error('Failed to load action types');
this.availableTypes = await res.json();
+ // Hide deprecated/internal action types from the creation UI
+ this.availableTypes = this.availableTypes.filter(t => !HIDDEN_ACTION_TYPES.includes(t.type));
// Sort action types alphabetically by display name
this.availableTypes.sort((a, b) => {
const nameA = (a.display || a.displayName || a.type || a.name || '').toLowerCase();
@@ -271,10 +283,15 @@ export class PluginModalStepper {
description.substring(0, maxLength) + '...' : description;
const needsTruncation = description.length > maxLength;
+ const iconClass = getTypeIcon(type.type || type.name);
+
col.innerHTML = `
-
${this.escapeHtml(displayName)}
+
+
+
${this.escapeHtml(displayName)}
+
${this.escapeHtml(truncatedDescription)}
${needsTruncation ? `
@@ -538,43 +555,52 @@ export class PluginModalStepper {
}
if (stepNumber === 4) {
- // Load additional settings schema for selected type
- let options = {forceReload: true};
- this.getAdditionalSettingsSchema(this.selectedType, options);
+ const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema';
const additionalFieldsDiv = document.getElementById('plugin-additional-fields-div');
- if (additionalFieldsDiv) {
- // Only clear and rebuild if type changes
- if (this.selectedType !== this.lastAdditionalFieldsType) {
- additionalFieldsDiv.innerHTML = '';
- additionalFieldsDiv.classList.remove('d-none');
- if (this.selectedType) {
- this.getAdditionalSettingsSchema(this.selectedType)
- .then(schema => {
- if (schema) {
- this.buildAdditionalFieldsUI(schema, additionalFieldsDiv);
- try {
- if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) {
- this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields);
+
+ // For SQL types, hide additional fields entirely since Step 3 covers all SQL config
+ if (isSqlType && additionalFieldsDiv) {
+ additionalFieldsDiv.innerHTML = '';
+ additionalFieldsDiv.classList.add('d-none');
+ this.lastAdditionalFieldsType = this.selectedType;
+ } else {
+ // Load additional settings schema for selected type
+ let options = {forceReload: true};
+ this.getAdditionalSettingsSchema(this.selectedType, options);
+ if (additionalFieldsDiv) {
+ // Only clear and rebuild if type changes
+ if (this.selectedType !== this.lastAdditionalFieldsType) {
+ additionalFieldsDiv.innerHTML = '';
+ additionalFieldsDiv.classList.remove('d-none');
+ if (this.selectedType) {
+ this.getAdditionalSettingsSchema(this.selectedType)
+ .then(schema => {
+ if (schema) {
+ this.buildAdditionalFieldsUI(schema, additionalFieldsDiv);
+ try {
+ if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) {
+ this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields);
+ }
+ } catch (error) {
+ console.error('Error populating dynamic additional fields:', error);
}
- } catch (error) {
- console.error('Error populating dynamic additional fields:', error);
+ } else {
+ console.log('No additional settings schema found');
+ additionalFieldsDiv.classList.add('d-none');
}
- } else {
- console.log('No additional settings schema found');
+ })
+ .catch(error => {
+ console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`);
additionalFieldsDiv.classList.add('d-none');
- }
- })
- .catch(error => {
- console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`);
- additionalFieldsDiv.classList.add('d-none');
- });
- } else {
- console.warn('No plugin type selected');
- additionalFieldsDiv.classList.add('d-none');
+ });
+ } else {
+ console.warn('No plugin type selected');
+ additionalFieldsDiv.classList.add('d-none');
+ }
+ this.lastAdditionalFieldsType = this.selectedType;
}
- this.lastAdditionalFieldsType = this.selectedType;
+ // Otherwise, preserve user data and do not redraw
}
- // Otherwise, preserve user data and do not redraw
}
if (!this.isEditMode) {
@@ -1230,6 +1256,110 @@ export class PluginModalStepper {
this.updateSqlAuthInfo();
}
+ getSqlTestPluginContext() {
+ if (!this.isEditMode || !this.originalPlugin) {
+ return null;
+ }
+
+ const originalPlugin = this.originalPlugin;
+ let scope = originalPlugin.scope;
+
+ if (!scope) {
+ if (originalPlugin.is_group) {
+ scope = 'group';
+ } else if (originalPlugin.is_global || window.location.pathname.includes('admin')) {
+ scope = 'global';
+ } else {
+ scope = 'user';
+ }
+ }
+
+ return {
+ id: originalPlugin.id || '',
+ name: originalPlugin.name || '',
+ scope
+ };
+ }
+
+ async testSqlConnection() {
+ const btn = document.getElementById('sql-test-connection-btn');
+ const resultDiv = document.getElementById('sql-test-connection-result');
+ const alertDiv = document.getElementById('sql-test-connection-alert');
+ if (!btn || !resultDiv || !alertDiv) return;
+
+ // Collect current SQL config from Step 3
+ const databaseType = document.querySelector('input[name="sql-database-type"]:checked')?.value;
+ const connectionMethod = document.querySelector('input[name="sql-connection-method"]:checked')?.value || 'parameters';
+ const authType = document.getElementById('sql-auth-type')?.value || 'username_password';
+
+ if (!databaseType) {
+ resultDiv.classList.remove('d-none');
+ alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small';
+ alertDiv.textContent = 'Please select a database type first.';
+ return;
+ }
+
+ const payload = {
+ database_type: databaseType,
+ connection_method: connectionMethod,
+ auth_type: authType
+ };
+
+ if (connectionMethod === 'connection_string') {
+ payload.connection_string = document.getElementById('sql-connection-string')?.value?.trim() || '';
+ } else {
+ payload.server = document.getElementById('sql-server')?.value?.trim() || '';
+ payload.database = document.getElementById('sql-database')?.value?.trim() || '';
+ payload.port = document.getElementById('sql-port')?.value?.trim() || '';
+ if (databaseType === 'sqlserver' || databaseType === 'azure_sql') {
+ payload.driver = document.getElementById('sql-driver')?.value || '';
+ }
+ }
+
+ if (authType === 'username_password') {
+ payload.username = document.getElementById('sql-username')?.value?.trim() || '';
+ payload.password = document.getElementById('sql-password')?.value?.trim() || '';
+ }
+
+ payload.timeout = parseInt(document.getElementById('sql-timeout')?.value) || 10;
+
+ const existingPluginContext = this.getSqlTestPluginContext();
+ if (existingPluginContext) {
+ payload.existing_plugin = existingPluginContext;
+ }
+
+ // Show loading state
+ const originalText = btn.innerHTML;
+ btn.innerHTML = ' Testing...';
+ btn.disabled = true;
+ resultDiv.classList.add('d-none');
+
+ try {
+ const response = await fetch('/api/plugins/test-sql-connection', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ const data = await response.json();
+
+ resultDiv.classList.remove('d-none');
+ if (data.success) {
+ alertDiv.className = 'alert alert-success mb-0 py-2 px-3 small';
+ alertDiv.innerHTML = ' ' + (data.message || 'Connection successful!');
+ } else {
+ alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small';
+ alertDiv.innerHTML = ' ' + (data.error || 'Connection failed.');
+ }
+ } catch (error) {
+ resultDiv.classList.remove('d-none');
+ alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small';
+ alertDiv.innerHTML = ' Test failed: ' + (error.message || 'Network error');
+ } finally {
+ btn.innerHTML = originalText;
+ btn.disabled = false;
+ }
+ }
+
updateSqlConnectionExamples() {
const selectedType = document.querySelector('input[name="sql-database-type"]:checked')?.value;
const examplesDiv = document.getElementById('sql-connection-examples');
@@ -1432,6 +1562,13 @@ export class PluginModalStepper {
} else if (plugin.type && (plugin.type.toLowerCase().includes('sql') || plugin.type.toLowerCase() === 'sql_schema' || plugin.type.toLowerCase() === 'sql_query')) {
// Populate SQL fields
const additionalFields = plugin.additionalFields || {};
+ const auth = plugin.auth || {};
+
+ const pluginVariant = plugin.type.toLowerCase() === 'sql_schema' ? 'schema' : 'query';
+ const pluginTypeRadio = document.querySelector(`input[name="sql-plugin-type"][value="${pluginVariant}"]`);
+ if (pluginTypeRadio) {
+ pluginTypeRadio.checked = true;
+ }
// Database type - select the appropriate radio button
const databaseType = additionalFields.database_type || 'sqlserver';
@@ -1440,56 +1577,40 @@ export class PluginModalStepper {
dbTypeRadio.checked = true;
}
- // Connection method (default to connection string)
- // Note: The connection method might not be saved in the data, so we'll default to connection_string
- const connectionMethodRadio = document.querySelector('input[name="sql-connection-method"][value="connection_string"]');
+ const hasConnectionString = typeof additionalFields.connection_string === 'string' && additionalFields.connection_string.length > 0;
+ const connectionMethodValue = hasConnectionString ? 'connection_string' : 'parameters';
+ const connectionMethodRadio = document.querySelector(`input[name="sql-connection-method"][value="${connectionMethodValue}"]`);
if (connectionMethodRadio) {
connectionMethodRadio.checked = true;
}
-
- // Build connection string from individual parameters if needed
- let connectionString = plugin.endpoint || '';
- if (!connectionString && additionalFields.server) {
- // Build connection string from components
- const server = additionalFields.server;
- const database = additionalFields.database;
- const driver = additionalFields.driver || 'ODBC Driver 17 for SQL Server';
-
- if (databaseType === 'azure_sql' || databaseType === 'sqlserver') {
- connectionString = `Server=${server};Database=${database};Driver={${driver}};`;
- if (additionalFields.username && additionalFields.password) {
- connectionString += `Uid=${additionalFields.username};Pwd=${additionalFields.password};`;
- }
- } else if (databaseType === 'postgresql') {
- connectionString = `Host=${server};Database=${database};`;
- if (additionalFields.username && additionalFields.password) {
- connectionString += `Username=${additionalFields.username};Password=${additionalFields.password};`;
- }
- } else if (databaseType === 'mysql') {
- connectionString = `Server=${server};Database=${database};`;
- if (additionalFields.username && additionalFields.password) {
- connectionString += `Uid=${additionalFields.username};Pwd=${additionalFields.password};`;
- }
- }
- }
-
- document.getElementById('sql-connection-string').value = connectionString;
-
- // Authentication
- const auth = plugin.auth || {};
- let sqlAuthType = 'username_password'; // Default for SQL plugins
-
- if (auth.type === 'user' || auth.type === 'username_password') {
+
+ document.getElementById('sql-connection-string').value = additionalFields.connection_string || '';
+ document.getElementById('sql-server').value = additionalFields.server || '';
+ document.getElementById('sql-database').value = additionalFields.database || '';
+ document.getElementById('sql-port').value = additionalFields.port || '';
+ document.getElementById('sql-driver').value = additionalFields.driver || 'ODBC Driver 17 for SQL Server';
+
+ let sqlAuthType = hasConnectionString ? 'connection_string_only' : 'username_password';
+
+ if (auth.type === 'servicePrincipal') {
+ sqlAuthType = 'service_principal';
+ document.getElementById('sql-client-id').value = auth.identity || auth.client_id || '';
+ document.getElementById('sql-client-secret').value = auth.key || auth.client_secret || '';
+ document.getElementById('sql-tenant-id').value = auth.tenantId || auth.tenant_id || '';
+ } else if (auth.type === 'user' || auth.type === 'username_password' || additionalFields.username || additionalFields.password) {
sqlAuthType = 'username_password';
document.getElementById('sql-username').value = additionalFields.username || '';
document.getElementById('sql-password').value = additionalFields.password || '';
} else if (auth.type === 'integrated' || auth.type === 'windows') {
sqlAuthType = 'integrated';
- } else if (auth.type === 'connection_string') {
- sqlAuthType = 'connection_string';
+ } else if (auth.type === 'identity') {
+ sqlAuthType = databaseType === 'azure_sql' ? 'managed_identity' : 'integrated';
}
document.getElementById('sql-auth-type').value = sqlAuthType;
+ this.handleSqlDatabaseTypeChange();
+ this.handleSqlConnectionMethodChange();
+ this.handleSqlAuthTypeChange();
} else {
// Populate generic fields
document.getElementById('plugin-endpoint-generic').value = plugin.endpoint || '';
@@ -1672,9 +1793,9 @@ export class PluginModalStepper {
if (!clientId || !clientSecret || !tenantId) {
throw new Error('Please enter client ID, client secret, and tenant ID');
}
- auth.client_id = clientId;
- auth.client_secret = clientSecret;
- auth.tenant_id = tenantId;
+ auth.identity = clientId;
+ auth.key = clientSecret;
+ auth.tenantId = tenantId;
break;
case 'integrated':
@@ -1720,12 +1841,17 @@ export class PluginModalStepper {
// Collect additional fields from the dynamic UI and MERGE with existing additionalFields
// This preserves OpenAPI spec content and other auto-populated fields
- try {
- const dynamicFields = this.collectAdditionalFields();
- // Merge dynamicFields into additionalFields (preserving existing values)
- additionalFields = { ...additionalFields, ...dynamicFields };
- } catch (e) {
- throw new Error('Invalid additional fields input');
+ // For SQL types, Step 3 already provides all necessary config — skip dynamic field merge
+ // to prevent empty Step 4 fields from overwriting populated Step 3 values
+ const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema';
+ if (!isSqlType) {
+ try {
+ const dynamicFields = this.collectAdditionalFields();
+ // Merge dynamicFields into additionalFields (preserving existing values)
+ additionalFields = { ...additionalFields, ...dynamicFields };
+ } catch (e) {
+ throw new Error('Invalid additional fields input');
+ }
}
let metadata = {};
@@ -2106,6 +2232,7 @@ export class PluginModalStepper {
populateAdvancedSummary() {
const advancedSection = document.getElementById('summary-advanced-section');
+ const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema';
// Check if there's any metadata or additional fields
const metadata = document.getElementById('plugin-metadata').value.trim();
@@ -2123,9 +2250,33 @@ export class PluginModalStepper {
hasMetadata = metadata.length > 0 && metadata !== '{}';
}
- // DRY: Use private helper to collect additional fields
- let additionalFieldsObj = this.collectAdditionalFields();
- hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0;
+ // For SQL types, additional fields are already shown in the SQL Database Configuration
+ // summary section, so skip showing them again in Advanced to avoid redundancy
+ if (!isSqlType) {
+ // DRY: Use private helper to collect additional fields
+ let additionalFieldsObj = this.collectAdditionalFields();
+ hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0;
+
+ // Show/hide additional fields preview
+ const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview');
+ if (hasAdditionalFields) {
+ let previewContent = '';
+ if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) {
+ previewContent = JSON.stringify(additionalFieldsObj, null, 2);
+ } else {
+ previewContent = '';
+ }
+ document.getElementById('summary-additional-fields-content').textContent = previewContent;
+ additionalFieldsPreview.style.display = '';
+ } else {
+ additionalFieldsPreview.style.display = 'none';
+ }
+ } else {
+ // Hide additional fields for SQL types
+ const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview');
+ if (additionalFieldsPreview) additionalFieldsPreview.style.display = 'none';
+ hasAdditionalFields = false;
+ }
// Update has metadata/additional fields indicators
document.getElementById('summary-has-metadata').textContent = hasMetadata ? 'Yes' : 'No';
@@ -2140,21 +2291,6 @@ export class PluginModalStepper {
metadataPreview.style.display = 'none';
}
- // Show/hide additional fields preview
- const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview');
- if (hasAdditionalFields) {
- let previewContent = '';
- if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) {
- previewContent = JSON.stringify(additionalFieldsObj, null, 2);
- } else {
- previewContent = '';
- }
- document.getElementById('summary-additional-fields-content').textContent = previewContent;
- additionalFieldsPreview.style.display = '';
- } else {
- additionalFieldsPreview.style.display = 'none';
- }
-
// Show advanced section if there's any advanced content
if (hasMetadata || hasAdditionalFields) {
advancedSection.style.display = '';
diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js
index 995fb51c..c24dfe2c 100644
--- a/application/single_app/static/js/public/public_workspace.js
+++ b/application/single_app/static/js/public/public_workspace.js
@@ -48,6 +48,178 @@ const publicPromptModal = new bootstrap.Modal(document.getElementById('publicPro
const publicDocMetadataModal = new bootstrap.Modal(document.getElementById('publicDocMetadataModal'));
const publicTagManagementModal = new bootstrap.Modal(document.getElementById('publicTagManagementModal'));
const publicTagSelectionModal = new bootstrap.Modal(document.getElementById('publicTagSelectionModal'));
+const publicDocumentDeleteModalElement = document.getElementById('publicDocumentDeleteModal');
+const publicDocumentDeleteModal = publicDocumentDeleteModalElement ? new bootstrap.Modal(publicDocumentDeleteModalElement) : null;
+const publicDocumentDeleteModalTitle = document.getElementById('publicDocumentDeleteModalLabel');
+const publicDocumentDeleteModalBody = document.getElementById('publicDocumentDeleteModalBody');
+const publicDeleteCurrentBtn = document.getElementById('publicDeleteCurrentBtn');
+const publicDeleteAllBtn = document.getElementById('publicDeleteAllBtn');
+
+function getPublicDeleteModalContent(documentCount) {
+ if (documentCount === 1) {
+ return {
+ title: 'Delete Public Document',
+ body: `
+
Choose how to delete this public document revision.
+
Delete Current Version removes the visible revision and keeps older revisions for future comparison.
+
Delete All Versions permanently removes every stored revision for this document.
+ `,
+ };
+ }
+
+ return {
+ title: 'Delete Selected Public Documents',
+ body: `
+
Choose how to delete ${documentCount} selected current public document revision(s).
+
Delete Current Version removes only the visible revision for each selected document and keeps older revisions.
+
Delete All Versions permanently removes every stored revision for each selected document.
+ `,
+ };
+}
+
+function showPublicDocumentDeleteFeedback(message, variant = 'danger') {
+ if (typeof window.showToast === 'function') {
+ window.showToast(message, variant);
+ return;
+ }
+
+ let container = document.getElementById('publicDocumentDeleteFeedbackContainer');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'publicDocumentDeleteFeedbackContainer';
+ container.className = 'toast-container position-fixed top-0 end-0 p-3';
+ document.body.appendChild(container);
+ }
+
+ if (window.bootstrap && typeof window.bootstrap.Toast === 'function') {
+ const toastElement = document.createElement('div');
+ toastElement.className = `toast align-items-center text-white bg-${variant} border-0`;
+ toastElement.setAttribute('role', 'alert');
+ toastElement.setAttribute('aria-live', 'assertive');
+ toastElement.setAttribute('aria-atomic', 'true');
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'd-flex';
+
+ const body = document.createElement('div');
+ body.className = 'toast-body';
+ body.textContent = message;
+
+ const closeButton = document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.className = 'btn-close btn-close-white me-2 m-auto';
+ closeButton.setAttribute('data-bs-dismiss', 'toast');
+ closeButton.setAttribute('aria-label', 'Close');
+
+ wrapper.appendChild(body);
+ wrapper.appendChild(closeButton);
+ toastElement.appendChild(wrapper);
+ container.appendChild(toastElement);
+
+ const toast = new window.bootstrap.Toast(toastElement);
+ toast.show();
+ toastElement.addEventListener('hidden.bs.toast', () => {
+ toastElement.remove();
+ });
+ return;
+ }
+
+ const alertElement = document.createElement('div');
+ alertElement.className = `alert alert-${variant} alert-dismissible fade show mb-2`;
+ alertElement.setAttribute('role', 'alert');
+
+ const body = document.createElement('span');
+ body.textContent = message;
+
+ const closeButton = document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.className = 'btn-close';
+ closeButton.setAttribute('data-bs-dismiss', 'alert');
+ closeButton.setAttribute('aria-label', 'Close');
+
+ alertElement.appendChild(body);
+ alertElement.appendChild(closeButton);
+ container.appendChild(alertElement);
+}
+
+function isPublicDocumentDeleteModalReady() {
+ return Boolean(
+ publicDocumentDeleteModal &&
+ publicDocumentDeleteModalElement &&
+ publicDocumentDeleteModalElement.isConnected &&
+ publicDocumentDeleteModalBody &&
+ publicDocumentDeleteModalBody.isConnected &&
+ publicDeleteCurrentBtn &&
+ publicDeleteCurrentBtn.isConnected &&
+ publicDeleteAllBtn &&
+ publicDeleteAllBtn.isConnected
+ );
+}
+
+function promptPublicDeleteMode(documentCount = 1) {
+ if (!isPublicDocumentDeleteModalReady()) {
+ showPublicDocumentDeleteFeedback('Delete confirmation dialog is unavailable. Refresh the page and try again.');
+ return Promise.resolve(null);
+ }
+
+ const modalContent = getPublicDeleteModalContent(documentCount);
+ if (publicDocumentDeleteModalTitle) {
+ publicDocumentDeleteModalTitle.textContent = modalContent.title;
+ }
+ publicDocumentDeleteModalBody.innerHTML = modalContent.body;
+
+ return new Promise((resolve) => {
+ let settled = false;
+
+ const cleanup = () => {
+ publicDocumentDeleteModalElement.removeEventListener('hidden.bs.modal', handleHidden);
+ publicDeleteCurrentBtn.removeEventListener('click', handleCurrentOnly);
+ publicDeleteAllBtn.removeEventListener('click', handleAllVersions);
+ };
+
+ const finalize = (value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ resolve(value);
+ };
+
+ const handleHidden = () => finalize(null);
+ const handleCurrentOnly = () => {
+ publicDocumentDeleteModal.hide();
+ finalize('current_only');
+ };
+ const handleAllVersions = () => {
+ publicDocumentDeleteModal.hide();
+ finalize('all_versions');
+ };
+
+ publicDocumentDeleteModalElement.addEventListener('hidden.bs.modal', handleHidden);
+ publicDeleteCurrentBtn.addEventListener('click', handleCurrentOnly);
+ publicDeleteAllBtn.addEventListener('click', handleAllVersions);
+ publicDocumentDeleteModal.show();
+ });
+}
+
+async function requestPublicDocumentDeletion(documentId, deleteMode) {
+ const query = new URLSearchParams({ delete_mode: deleteMode });
+ const response = await fetch(`/api/public_documents/${documentId}?${query.toString()}`, { method: 'DELETE' });
+
+ let responseData = {};
+ try {
+ responseData = await response.json();
+ } catch (error) {
+ responseData = {};
+ }
+
+ if (!response.ok) {
+ throw responseData.error ? responseData : { error: `Server responded with status ${response.status}` };
+ }
+
+ return responseData;
+}
// Editors
let publicSimplemde = null;
@@ -55,6 +227,12 @@ const publicPromptContentEl = document.getElementById('public-prompt-content');
if (publicPromptContentEl && window.SimpleMDE) {
publicSimplemde = new SimpleMDE({ element: publicPromptContentEl, spellChecker:false, autoDownloadFontAwesome: false });
}
+document.getElementById('publicPromptModal')?.addEventListener('shown.bs.modal', () => {
+ if (publicSimplemde?.codemirror) {
+ publicSimplemde.codemirror.refresh();
+ publicSimplemde.codemirror.focus();
+ }
+});
// DOM elements
const publicSelect = document.getElementById('public-select');
@@ -199,8 +377,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (activePublicId) fetchPublicDocs();
});
- Array.from(publicDropdownItems.children).forEach(()=>{}); // placeholder
-
// --- Document selection event listeners ---
// Event delegation for document checkboxes
document.addEventListener('change', function(event) {
@@ -266,8 +442,6 @@ function updatePublicRoleDisplay(){
if (nameRoleEl) nameRoleEl.textContent = activePublicName;
if (display) display.style.display = 'block';
if (uploadSection) uploadSection.style.display = ['Owner','Admin','DocumentManager'].includes(userRoleInActivePublic) ? 'block' : 'none';
- // uploadHr was removed from template, so skip
-
// Control visibility of Settings tab (only for Owners and Admins)
const settingsTabNav = document.getElementById('public-settings-tab-nav');
const canManageSettings = ['Owner', 'Admin'].includes(userRoleInActivePublic);
@@ -491,6 +665,7 @@ function renderPublicDocumentRow(doc) {
Citations: ${getCitationBadge(doc.enhanced_citations)}
Publication Date: ${escapeHtml(doc.publication_date || 'N/A')}
Keywords: ${escapeHtml(doc.keywords || 'N/A')}
+
Tags: ${renderPublicTagBadges(doc.tags || [])}
Abstract: ${escapeHtml(doc.abstract || 'N/A')}
@@ -791,7 +966,32 @@ async function onPublicUploadClick() {
xhr.send(formData);
});
}
-window.deletePublicDocument=async function(id, event){ if(!confirm('Delete?')) return; try{ await fetch(`/api/public_documents/${id}`,{method:'DELETE'}); fetchPublicDocs(); }catch(e){ alert(`Error deleting: ${e.error||e.message}`);} };
+window.deletePublicDocument = async function(id, event) {
+ const deleteMode = await promptPublicDeleteMode(1);
+ if (!deleteMode) {
+ return;
+ }
+
+ const deleteTrigger = event ? event.target.closest('a, button') : null;
+ const originalDeleteTriggerHtml = deleteTrigger ? deleteTrigger.innerHTML : null;
+ if (deleteTrigger) {
+ deleteTrigger.classList.add('disabled');
+ deleteTrigger.setAttribute('aria-disabled', 'true');
+ deleteTrigger.innerHTML = '
';
+ }
+
+ try {
+ await requestPublicDocumentDeletion(id, deleteMode);
+ fetchPublicDocs();
+ } catch (e) {
+ showPublicWorkspaceToast(`Error deleting: ${e.error || e.message}`, 'danger');
+ if (deleteTrigger && document.body.contains(deleteTrigger)) {
+ deleteTrigger.classList.remove('disabled');
+ deleteTrigger.removeAttribute('aria-disabled');
+ deleteTrigger.innerHTML = originalDeleteTriggerHtml;
+ }
+ }
+};
window.searchPublicDocumentInChat = function(docId) {
window.location.href = `/chats?search_documents=true&doc_scope=public&document_id=${docId}&workspace_id=${activePublicId}`;
@@ -851,34 +1051,45 @@ function clearPublicSelection() {
function deletePublicSelectedDocuments() {
if (publicSelectedDocuments.size === 0) return;
- if (!confirm(`Are you sure you want to delete ${publicSelectedDocuments.size} selected document(s)? This action cannot be undone.`)) return;
- const deleteBtn = document.getElementById('public-delete-selected-btn');
- if (deleteBtn) {
- deleteBtn.disabled = true;
- deleteBtn.innerHTML = '
Deleting...';
- }
+ promptPublicDeleteMode(publicSelectedDocuments.size).then((deleteMode) => {
+ if (!deleteMode) {
+ return;
+ }
- const deletePromises = Array.from(publicSelectedDocuments).map(docId =>
- fetch(`/api/public_documents/${docId}`, { method: 'DELETE' })
- .then(r => r.ok ? r.json() : Promise.reject(r))
- );
+ const deleteBtn = document.getElementById('public-delete-selected-btn');
+ if (deleteBtn) {
+ deleteBtn.disabled = true;
+ deleteBtn.innerHTML = '
Deleting...';
+ }
- Promise.allSettled(deletePromises)
- .then(results => {
- const successful = results.filter(r => r.status === 'fulfilled').length;
- const failed = results.filter(r => r.status === 'rejected').length;
- if (failed > 0) alert(`Deleted ${successful} document(s). ${failed} failed to delete.`);
- publicSelectedDocuments.clear();
- updatePublicBulkActionButtons();
- fetchPublicDocs();
- })
- .finally(() => {
- if (deleteBtn) {
- deleteBtn.disabled = false;
- deleteBtn.innerHTML = '
Delete Selected';
- }
- });
+ const deletePromises = Array.from(publicSelectedDocuments).map((docId) => requestPublicDocumentDeletion(docId, deleteMode));
+
+ Promise.allSettled(deletePromises)
+ .then((results) => {
+ const successful = results.filter((result) => result.status === 'fulfilled').length;
+ const failed = results.filter((result) => result.status === 'rejected').length;
+ if (failed > 0) {
+ const toastType = successful === 0 ? 'danger' : 'warning';
+ showPublicWorkspaceToast(`Deleted ${successful} document(s). ${failed} failed to delete.`, toastType);
+ }
+
+ if (publicSelectionMode) {
+ togglePublicSelectionMode();
+ } else {
+ publicSelectedDocuments.clear();
+ updatePublicBulkActionButtons();
+ }
+
+ fetchPublicDocs();
+ })
+ .finally(() => {
+ if (deleteBtn) {
+ deleteBtn.disabled = false;
+ deleteBtn.innerHTML = '
Delete Selected';
+ }
+ });
+ });
}
function chatWithPublicSelected() {
@@ -1708,7 +1919,7 @@ window.loadPublicWorkspaceTags = loadPublicWorkspaceTags;
function isPublicColorLight(hex) {
if (!hex) return true;
hex = hex.replace('#', '');
- const r = parseInt(hex.substr(0,2),16), g = parseInt(hex.substr(2,2),16), b = parseInt(hex.substr(4,2),16);
+ const r = parseInt(hex.substring(0, 2), 16), g = parseInt(hex.substring(2, 4), 16), b = parseInt(hex.substring(4, 6), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
}
@@ -1718,6 +1929,29 @@ function escapePublicHtml(text) {
return d.innerHTML;
}
+function renderPublicTagBadges(tags, maxDisplay = 3) {
+ if (!Array.isArray(tags) || tags.length === 0) {
+ return '
No tags ';
+ }
+
+ let html = '';
+ const displayTags = tags.slice(0, maxDisplay);
+
+ displayTags.forEach(tagName => {
+ const tag = publicWorkspaceTags.find(t => t.name === tagName);
+ const color = tag && tag.color ? tag.color : '#6c757d';
+ const textClass = isPublicColorLight(color) ? 'text-dark' : 'text-light';
+
+ html += `
${escapePublicHtml(tagName)} `;
+ });
+
+ if (tags.length > maxDisplay) {
+ html += `
+${tags.length - maxDisplay} `;
+ }
+
+ return html;
+}
+
// --- Tag Management Modal ---
function showPublicTagManagementModal() {
loadPublicWorkspaceTags().then(() => {
diff --git a/application/single_app/static/js/public/public_workspace_utility.js b/application/single_app/static/js/public/public_workspace_utility.js
index d55959c3..85316737 100644
--- a/application/single_app/static/js/public/public_workspace_utility.js
+++ b/application/single_app/static/js/public/public_workspace_utility.js
@@ -13,6 +13,53 @@ function escapeHtml(unsafe) {
return div.innerHTML;
}
+/**
+ * Shows a Bootstrap toast for public workspace actions.
+ * @param {string} message - The message to display
+ * @param {string} [type='info'] - Bootstrap contextual color
+ * @param {number} [duration=5000] - Toast delay in milliseconds
+ */
+function showPublicWorkspaceToast(message, type = 'info', duration = 5000) {
+ const safeMessage = escapeHtml(message || '');
+ let toastContainer = document.getElementById('toast-container');
+
+ if (!toastContainer) {
+ toastContainer = document.createElement('div');
+ toastContainer.id = 'toast-container';
+ toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
+ toastContainer.style.zIndex = '1100';
+ document.body.appendChild(toastContainer);
+ }
+
+ const toastId = `public-workspace-toast-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
+ toastContainer.insertAdjacentHTML('beforeend', `
+
+ `);
+
+ const toastElement = document.getElementById(toastId);
+ if (!toastElement) {
+ return;
+ }
+
+ if (!window.bootstrap || !window.bootstrap.Toast) {
+ toastElement.classList.add('show');
+ return;
+ }
+
+ const toast = new bootstrap.Toast(toastElement, { delay: duration });
+ toast.show();
+ toastElement.addEventListener('hidden.bs.toast', () => {
+ toastElement.remove();
+ });
+}
+
+window.showPublicWorkspaceToast = showPublicWorkspaceToast;
+
/**
* Updates the workspace status alert display based on workspace status
* @param {Object} options - Configuration options
diff --git a/application/single_app/static/js/sidebar-resize.js b/application/single_app/static/js/sidebar-resize.js
new file mode 100644
index 00000000..e858dd92
--- /dev/null
+++ b/application/single_app/static/js/sidebar-resize.js
@@ -0,0 +1,80 @@
+// Adds a draggable resize handle for the sidebar
+document.addEventListener('DOMContentLoaded', () => {
+ const sidebar = document.getElementById('sidebar-nav');
+ const handle = document.getElementById('sidebar-resize-handle');
+ if (!sidebar || !handle) return;
+
+ const root = document.documentElement;
+ const userAccount = document.getElementById('sidebar-user-account');
+
+ const parsePx = (value, fallback) => {
+ const parsed = parseInt(String(value).replace('px', '').trim(), 10);
+ return Number.isFinite(parsed) ? parsed : fallback;
+ };
+
+ const getVar = (name, fallback) => {
+ const fromStyle = getComputedStyle(root).getPropertyValue(name);
+ if (fromStyle && fromStyle.trim()) return parsePx(fromStyle, fallback);
+ return fallback;
+ };
+
+ const minWidth = getVar('--sidebar-min-width', 220);
+ const maxWidth = getVar('--sidebar-max-width', 420);
+
+ const applyWidth = (widthPx) => {
+ const clamped = Math.min(Math.max(widthPx, minWidth), maxWidth);
+ root.style.setProperty('--sidebar-width', `${clamped}px`);
+ sidebar.style.width = `${clamped}px`;
+ if (userAccount) {
+ userAccount.style.width = `${clamped}px`;
+ }
+ };
+
+ // Restore stored width if available
+ const stored = localStorage.getItem('sidebarWidth');
+ if (stored) {
+ const parsed = parseInt(stored, 10);
+ if (Number.isFinite(parsed)) {
+ applyWidth(parsed);
+ }
+ }
+
+ let isDragging = false;
+
+ const onMouseMove = (e) => {
+ if (!isDragging) return;
+ const newWidth = e.clientX;
+ applyWidth(newWidth);
+ };
+
+ const stopDrag = () => {
+ if (!isDragging) return;
+ isDragging = false;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', stopDrag);
+ const currentWidth = parsePx(getComputedStyle(sidebar).width, minWidth);
+ localStorage.setItem('sidebarWidth', String(currentWidth));
+ };
+
+ handle.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ isDragging = true;
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', stopDrag);
+ });
+
+ handle.addEventListener('keydown', (e) => {
+ const step = 10;
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ const current = parsePx(getComputedStyle(sidebar).width, minWidth);
+ applyWidth(current - step);
+ localStorage.setItem('sidebarWidth', String(parsePx(getComputedStyle(sidebar).width, minWidth)));
+ } else if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ const current = parsePx(getComputedStyle(sidebar).width, minWidth);
+ applyWidth(current + step);
+ localStorage.setItem('sidebarWidth', String(parsePx(getComputedStyle(sidebar).width, minWidth)));
+ }
+ });
+});
diff --git a/application/single_app/static/js/support/latest_features.js b/application/single_app/static/js/support/latest_features.js
new file mode 100644
index 00000000..aae17bfb
--- /dev/null
+++ b/application/single_app/static/js/support/latest_features.js
@@ -0,0 +1,44 @@
+// latest_features.js
+
+function setupLatestFeatureImageModal() {
+ const modalElement = document.getElementById('latestFeatureImageModal');
+ const modalImage = document.getElementById('latestFeatureImageModalImage');
+ const modalTitle = document.getElementById('latestFeatureImageModalLabel');
+ const modalCaption = document.getElementById('latestFeatureImageModalCaption');
+ const imageTriggers = document.querySelectorAll('[data-latest-feature-image-src]');
+
+ if (!modalElement || !modalImage || !modalTitle || !modalCaption || imageTriggers.length === 0) {
+ return;
+ }
+
+ const imageModal = bootstrap.Modal.getOrCreateInstance(modalElement);
+
+ imageTriggers.forEach(trigger => {
+ trigger.addEventListener('click', () => {
+ const imageSrc = trigger.dataset.latestFeatureImageSrc;
+ const imageTitle = trigger.dataset.latestFeatureImageTitle || 'Latest Feature Preview';
+ const imageCaption = trigger.dataset.latestFeatureImageCaption || 'Click outside the popup to close it.';
+ const imageAlt = trigger.querySelector('img')?.getAttribute('alt') || imageTitle;
+
+ if (!imageSrc) {
+ return;
+ }
+
+ modalImage.src = imageSrc;
+ modalImage.alt = imageAlt;
+ modalTitle.textContent = imageTitle;
+ modalCaption.textContent = imageCaption;
+ imageModal.show();
+ });
+ });
+
+ modalElement.addEventListener('hidden.bs.modal', () => {
+ modalImage.src = '';
+ modalImage.alt = 'Latest feature preview';
+ });
+}
+
+
+document.addEventListener('DOMContentLoaded', () => {
+ setupLatestFeatureImageModal();
+});
\ No newline at end of file
diff --git a/application/single_app/static/js/support/support_feedback.js b/application/single_app/static/js/support/support_feedback.js
new file mode 100644
index 00000000..f5dfbe2d
--- /dev/null
+++ b/application/single_app/static/js/support/support_feedback.js
@@ -0,0 +1,138 @@
+// support_feedback.js
+import { showToast } from "../chat/chat-toast.js";
+
+
+document.addEventListener('DOMContentLoaded', () => {
+ const feedbackForms = document.querySelectorAll('.support-send-feedback-form');
+ feedbackForms.forEach(form => {
+ const submitButton = form.querySelector('.support-send-feedback-submit');
+ if (!submitButton) {
+ return;
+ }
+
+ submitButton.addEventListener('click', event => {
+ event.preventDefault();
+ submitSupportFeedbackForm(form);
+ });
+ });
+});
+
+
+async function submitSupportFeedbackForm(form) {
+ const feedbackType = form.dataset.feedbackType;
+ const feedbackLabel = form.dataset.feedbackLabel || 'Feedback';
+ const nameInput = form.querySelector('[data-feedback-field="name"]');
+ const emailInput = form.querySelector('[data-feedback-field="email"]');
+ const organizationInput = form.querySelector('[data-feedback-field="organization"]');
+ const detailsInput = form.querySelector('[data-feedback-field="details"]');
+ const statusAlert = form.querySelector('.support-send-feedback-status');
+ const submitButton = form.querySelector('.support-send-feedback-submit');
+
+ if (!nameInput || !emailInput || !organizationInput || !detailsInput) {
+ setStatusAlert(statusAlert, 'Unable to load the Send Feedback form fields.', 'danger');
+ showToast('Unable to load the Send Feedback form fields.', 'danger');
+ return;
+ }
+
+ const reporterName = nameInput?.value.trim() || '';
+ const reporterEmail = emailInput?.value.trim() || '';
+ const organization = organizationInput?.value.trim() || '';
+ const details = detailsInput?.value.trim() || '';
+
+ if (!reporterName || !reporterEmail || !organization || !details) {
+ setStatusAlert(statusAlert, 'Please complete name, email, organization, and details before opening the email draft.', 'danger');
+ showToast('Please complete the Send Feedback form first.', 'warning');
+ return;
+ }
+
+ if (!reporterEmail.includes('@')) {
+ setStatusAlert(statusAlert, 'Please enter a valid email address.', 'danger');
+ showToast('Please enter a valid email address.', 'warning');
+ return;
+ }
+
+ submitButton.disabled = true;
+
+ try {
+ const response = await fetch('/api/support/send_feedback_email', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ feedbackType,
+ reporterName,
+ reporterEmail,
+ organization,
+ details
+ })
+ });
+
+ const result = await response.json();
+ if (!response.ok) {
+ throw new Error(result.error || 'Unable to prepare the feedback email draft.');
+ }
+
+ const mailtoUrl = buildSupportFeedbackMailtoUrl({
+ recipientEmail: result.recipientEmail,
+ subjectLine: result.subjectLine,
+ feedbackLabel,
+ reporterName,
+ reporterEmail,
+ organization,
+ details
+ });
+
+ setStatusAlert(
+ statusAlert,
+ 'Email draft prepared. Your local email client should open next.',
+ 'success'
+ );
+ showToast(`${feedbackLabel} email draft prepared.`, 'success');
+ window.location.href = mailtoUrl;
+ } catch (error) {
+ setStatusAlert(statusAlert, error.message || 'Unable to prepare the feedback email draft.', 'danger');
+ showToast(error.message || 'Unable to prepare the feedback email draft.', 'danger');
+ } finally {
+ submitButton.disabled = false;
+ }
+}
+
+
+function buildSupportFeedbackMailtoUrl({
+ recipientEmail,
+ subjectLine,
+ feedbackLabel,
+ reporterName,
+ reporterEmail,
+ organization,
+ details
+}) {
+ const sendFeedbackPane = document.getElementById('support-send-feedback-pane');
+ const appVersion = sendFeedbackPane?.dataset.appVersion || '';
+ const bodyLines = [
+ `Feedback Type: ${feedbackLabel}`,
+ `Name: ${reporterName}`,
+ `Email: ${reporterEmail}`,
+ `Organization: ${organization}`,
+ `App Version: ${appVersion || 'Unknown'}`,
+ ''
+ ];
+
+ bodyLines.push('Details:');
+ bodyLines.push(details);
+
+ return `mailto:${recipientEmail}?subject=${encodeURIComponent(subjectLine)}&body=${encodeURIComponent(bodyLines.join('\n'))}`;
+}
+
+
+function setStatusAlert(statusAlert, message, variant) {
+ if (!statusAlert) {
+ return;
+ }
+
+ statusAlert.className = `alert alert-${variant} support-send-feedback-status`;
+ statusAlert.textContent = message;
+ statusAlert.classList.remove('d-none');
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js
index f97dbd07..03a52f7c 100644
--- a/application/single_app/static/js/workspace/group_agents.js
+++ b/application/single_app/static/js/workspace/group_agents.js
@@ -4,16 +4,23 @@
import { showToast } from "../chat/chat-toast.js";
import * as agentsCommon from "../agents_common.js";
import { AgentModalStepper } from "../agent_modal_stepper.js";
+import {
+ humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil,
+ setupViewToggle, switchViewContainers, openViewModal, createAgentCard
+} from './view-utils.js';
const tableBody = document.getElementById("group-agents-table-body");
const errorContainer = document.getElementById("group-agents-error");
const searchInput = document.getElementById("group-agents-search");
const createButton = document.getElementById("create-group-agent-btn");
const permissionWarning = document.getElementById("group-agents-permission-warning");
+const agentsListView = document.getElementById("group-agents-list-view");
+const agentsGridView = document.getElementById("group-agents-grid-view");
let agents = [];
let filteredAgents = [];
let agentStepper = null;
+let currentViewMode = 'list';
let currentContext = window.groupWorkspaceContext || {
activeGroupId: null,
activeGroupName: "",
@@ -21,14 +28,7 @@ let currentContext = window.groupWorkspaceContext || {
};
function escapeHtml(value) {
- if (!value) return "";
- return value.replace(/[&<>"']/g, (char) => ({
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'"
- }[char] || char));
+ return escapeHtmlUtil(value);
}
function canManageAgents() {
@@ -46,6 +46,7 @@ function groupAllowsModifications() {
}
function truncateName(name, maxLength = 18) {
+ // Kept for backward compat; prefer humanizeName for display
if (!name || name.length <= maxLength) return name || "";
return `${name.substring(0, maxLength)}…`;
}
@@ -114,29 +115,62 @@ function renderAgentsTable(list) {
list.forEach((agent) => {
const tr = document.createElement("tr");
- const displayName = truncateName(agent.display_name || agent.displayName || agent.name || "");
- const description = escapeHtml(agent.description || "No description available.");
-
- let actionsHtml = "
— ";
+ const rawName = agent.display_name || agent.displayName || agent.name || "";
+ const displayName = humanizeName(rawName);
+ const fullDesc = agent.description || "No description available.";
+ const shortDesc = truncateDescription(fullDesc, 90);
+
+ let actionsHtml = `
+
+
+
+
+ Chat
+ `;
if (canManage) {
- actionsHtml = `
-
+ actionsHtml += `
+
-
+
`;
}
tr.innerHTML = `
- ${escapeHtml(displayName)}
- ${description}
+ ${escapeHtml(displayName)}
+ ${escapeHtml(shortDesc)}
${actionsHtml} `;
tableBody.appendChild(tr);
});
}
+function renderAgentsGrid(list) {
+ if (!agentsGridView) return;
+ agentsGridView.innerHTML = '';
+
+ if (!list.length) {
+ agentsGridView.innerHTML = 'No group agents found.
';
+ return;
+ }
+
+ const canManage = canManageAgents() && groupAllowsModifications();
+ list.forEach(agent => {
+ const col = createAgentCard(agent, {
+ onChat: a => chatWithGroupAgent(a.name || a),
+ onView: a => openGroupAgentViewModal(a),
+ onEdit: canManage ? a => {
+ const found = agents.find(x => x.id === (a.id || a.name || a) || x.name === (a.name || a));
+ openAgentModal(found || null);
+ } : null,
+ onDelete: canManage ? a => deleteGroupAgent(a.id || a.name || a) : null,
+ canManage
+ });
+ agentsGridView.appendChild(col);
+ });
+}
+
function filterAgents(term) {
if (!term) {
filteredAgents = agents.slice();
@@ -149,6 +183,23 @@ function filterAgents(term) {
});
}
renderAgentsTable(filteredAgents);
+ renderAgentsGrid(filteredAgents);
+}
+
+// Open the view modal for a group agent with Chat/Edit/Delete actions
+function openGroupAgentViewModal(agent) {
+ const canManage = canManageAgents() && groupAllowsModifications();
+ const callbacks = {
+ onChat: (a) => chatWithGroupAgent(a.name)
+ };
+ if (canManage) {
+ callbacks.onEdit = (a) => {
+ const found = agents.find(x => x.id === a.id || x.name === a.name);
+ openAgentModal(found || a);
+ };
+ callbacks.onDelete = (a) => deleteGroupAgent(a.id || a.name);
+ }
+ openViewModal(agent, 'agent', callbacks);
}
function overrideAgentStepper(stepper) {
@@ -258,7 +309,7 @@ function overrideAgentStepper(stepper) {
function getAgentStepper() {
if (!agentStepper) {
- agentStepper = overrideAgentStepper(new AgentModalStepper(false));
+ agentStepper = overrideAgentStepper(new AgentModalStepper(false, { settingsEndpoint: '/api/group/agent/settings', workspaceScope: 'group' }));
window.agentModalStepper = agentStepper;
}
return agentStepper;
@@ -278,7 +329,7 @@ async function openAgentModal(agent = null) {
document.getElementById("agent-apim-fields"),
document.getElementById("agent-gpt-fields"),
() => agentsCommon.loadGlobalModelsForModal({
- endpoint: "/api/user/agent/settings",
+ endpoint: "/api/group/agent/settings",
agent,
globalModelSelect: document.getElementById("agent-global-model-select"),
isGlobal: false,
@@ -343,7 +394,58 @@ async function fetchGroupAgents() {
}
}
+async function chatWithGroupAgent(agentName) {
+ try {
+ const agent = agents.find(a => a.name === agentName);
+ if (!agent) {
+ throw new Error("Agent not found");
+ }
+
+ const payloadData = {
+ selected_agent: {
+ id: agent.id || null,
+ name: agentName,
+ display_name: agent.display_name || agent.displayName || agentName,
+ is_global: !!agent.is_global,
+ is_group: true,
+ group_id: currentContext.activeGroupId,
+ group_name: currentContext.activeGroupName
+ }
+ };
+
+ const resp = await fetch("/api/user/settings/selected_agent", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payloadData)
+ });
+
+ if (!resp.ok) {
+ throw new Error("Failed to select agent");
+ }
+
+ window.location.href = "/chats";
+ } catch (err) {
+ console.error("Error selecting group agent for chat:", err);
+ showToast("Error selecting agent for chat. Please try again.", "danger");
+ }
+}
+
function handleTableClick(event) {
+ const viewBtn = event.target.closest(".view-group-agent-btn");
+ if (viewBtn) {
+ const agentName = viewBtn.dataset.agentName;
+ const agent = agents.find(a => a.name === agentName);
+ if (agent) openGroupAgentViewModal(agent);
+ return;
+ }
+
+ const chatBtn = event.target.closest(".chat-group-agent-btn");
+ if (chatBtn) {
+ const agentName = chatBtn.dataset.agentName;
+ chatWithGroupAgent(agentName);
+ return;
+ }
+
const editBtn = event.target.closest(".edit-group-agent-btn");
if (editBtn) {
const agentId = editBtn.dataset.agentId;
@@ -384,6 +486,11 @@ function initialize() {
updatePermissionUI();
bindEventHandlers();
+ setupViewToggle('groupAgents', 'groupAgentsViewPreference', (mode) => {
+ currentViewMode = mode;
+ switchViewContainers(mode, agentsListView, agentsGridView);
+ });
+
if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) {
fetchGroupAgents();
}
diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js
index 60a7f42e..8acdf5bd 100644
--- a/application/single_app/static/js/workspace/group_plugins.js
+++ b/application/single_app/static/js/workspace/group_plugins.js
@@ -3,6 +3,10 @@
import { ensurePluginsTableInRoot, validatePluginManifest } from "../plugin_common.js";
import { showToast } from "../chat/chat-toast.js";
+import {
+ humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil,
+ setupViewToggle, switchViewContainers, openViewModal, createActionCard
+} from './view-utils.js';
const root = document.getElementById("group-plugins-root");
const permissionWarning = document.getElementById("group-plugins-permission-warning");
@@ -11,6 +15,7 @@ let plugins = [];
let filteredPlugins = [];
let templateReady = false;
let listenersBound = false;
+let currentViewMode = 'list';
let currentContext = window.groupWorkspaceContext || {
activeGroupId: null,
activeGroupName: "",
@@ -18,14 +23,7 @@ let currentContext = window.groupWorkspaceContext || {
};
function escapeHtml(value) {
- if (!value) return "";
- return value.replace(/[&<>"']/g, (char) => ({
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'"
- }[char] || char));
+ return escapeHtmlUtil(value);
}
function canManagePlugins() {
@@ -66,6 +64,14 @@ function bindRootEvents() {
});
root.addEventListener("click", async (event) => {
+ const viewBtn = event.target.closest(".view-group-plugin-btn");
+ if (viewBtn) {
+ const pluginId = viewBtn.dataset.pluginId;
+ const plugin = plugins.find(x => x.id === pluginId || x.name === pluginId);
+ if (plugin) openGroupPluginViewModal(plugin);
+ return;
+ }
+
const createBtn = event.target.closest("#create-group-plugin-btn");
if (createBtn) {
event.preventDefault();
@@ -148,23 +154,28 @@ function renderPluginsTable(list) {
const canManage = canManagePlugins() && groupAllowsModifications();
list.forEach((plugin) => {
const tr = document.createElement("tr");
- const displayName = plugin.displayName || plugin.display_name || plugin.name || "";
- const description = plugin.description || "No description available.";
+ const rawName = plugin.displayName || plugin.display_name || plugin.name || "";
+ const displayName = humanizeName(rawName);
+ const fullDesc = plugin.description || "No description available.";
+ const shortDesc = truncateDescription(fullDesc, 90);
const isGlobal = Boolean(plugin.is_global);
- let actionsHtml = "— ";
+ // View button always visible
+ let actionsHtml = `
+
+
+ `;
+
if (canManage && !isGlobal) {
- actionsHtml = `
-
-
-
-
-
-
-
-
`;
+ actionsHtml += `
+
+
+
+
+
+ `;
} else if (canManage && isGlobal) {
- actionsHtml = "Managed globally ";
+ actionsHtml += `Managed globally `;
}
const titleHtml = isGlobal
@@ -172,14 +183,36 @@ function renderPluginsTable(list) {
: escapeHtml(displayName);
tr.innerHTML = `
- ${titleHtml}
- ${escapeHtml(description)}
+ ${titleHtml}
+ ${escapeHtml(shortDesc)}
${actionsHtml} `;
tbody.appendChild(tr);
});
}
+function renderPluginsGrid(list) {
+ const gridView = document.getElementById('group-plugins-grid-view');
+ if (!gridView) return;
+ gridView.innerHTML = '';
+
+ if (!list.length) {
+ gridView.innerHTML = 'No group actions found.
';
+ return;
+ }
+
+ const canManage = canManagePlugins() && groupAllowsModifications();
+ list.forEach(plugin => {
+ const isGlobal = Boolean(plugin.is_global);
+ const col = createActionCard(plugin, {
+ onView: p => openGroupPluginViewModal(p),
+ onEdit: (canManage && !isGlobal) ? p => openPluginModal(p.id || p.name) : null,
+ onDelete: (canManage && !isGlobal) ? p => deleteGroupPlugin(p.id || p.name) : null
+ });
+ gridView.appendChild(col);
+ });
+}
+
function filterPlugins(term) {
if (!term) {
filteredPlugins = plugins.slice();
@@ -192,6 +225,19 @@ function filterPlugins(term) {
});
}
renderPluginsTable(filteredPlugins);
+ renderPluginsGrid(filteredPlugins);
+}
+
+// Open the view modal for a group action with Edit/Delete actions
+function openGroupPluginViewModal(plugin) {
+ const canManage = canManagePlugins() && groupAllowsModifications();
+ const isGlobal = Boolean(plugin.is_global);
+ const callbacks = {};
+ if (canManage && !isGlobal) {
+ callbacks.onEdit = (p) => openPluginModal(p.id || p.name);
+ callbacks.onDelete = (p) => deleteGroupPlugin(p.id || p.name);
+ }
+ openViewModal(plugin, 'action', callbacks);
}
async function fetchGroupPlugins() {
@@ -220,7 +266,17 @@ async function fetchGroupPlugins() {
filteredPlugins = plugins.slice();
renderPluginsTable(filteredPlugins);
+ renderPluginsGrid(filteredPlugins);
updatePermissionUI();
+
+ // Set up view toggle (only once after template is in DOM)
+ setupViewToggle('groupPlugins', 'groupPluginsViewPreference', (mode) => {
+ currentViewMode = mode;
+ switchViewContainers(mode,
+ document.getElementById('group-plugins-list-view'),
+ document.getElementById('group-plugins-grid-view')
+ );
+ });
} catch (error) {
console.error("Error loading group actions:", error);
renderError(error.message || "Unable to load group actions.");
diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js
new file mode 100644
index 00000000..216a8ffe
--- /dev/null
+++ b/application/single_app/static/js/workspace/view-utils.js
@@ -0,0 +1,527 @@
+// view-utils.js
+// Shared utilities for list/grid view toggle, name humanization, and view modal
+// Used by personal and group agents/actions workspace modules
+
+/**
+ * Convert a technical name to a human-readable display name.
+ * Handles underscores, camelCase, PascalCase, and consecutive uppercase.
+ * Examples:
+ * "sql_query" → "Sql Query"
+ * "myAgentName" → "My Agent Name"
+ * "OpenAPIPlugin" → "Open API Plugin"
+ * "log_analytics" → "Log Analytics"
+ */
+export function humanizeName(name) {
+ if (!name) return "";
+ // Replace underscores and hyphens with spaces
+ let result = name.replace(/[_-]/g, " ");
+ // Insert space before uppercase letters that follow lowercase letters (camelCase)
+ result = result.replace(/([a-z])([A-Z])/g, "$1 $2");
+ // Insert space between consecutive uppercase followed by lowercase (e.g., "APIPlugin" → "API Plugin")
+ result = result.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
+ // Capitalize first letter of each word
+ result = result.replace(/\b\w/g, (c) => c.toUpperCase());
+ // Collapse multiple spaces
+ result = result.replace(/\s+/g, " ").trim();
+ return result;
+}
+
+/**
+ * Truncate a description string to maxLen characters, appending "…" if truncated.
+ */
+export function truncateDescription(text, maxLen = 100) {
+ if (!text) return "";
+ if (text.length <= maxLen) return text;
+ return text.substring(0, maxLen).trimEnd() + "…";
+}
+
+/**
+ * Escape HTML entities to prevent XSS.
+ */
+export function escapeHtml(str) {
+ if (!str) return "";
+ return str.replace(/[&<>"']/g, (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
+ );
+}
+
+/**
+ * Get an appropriate Bootstrap icon class for an action/plugin type.
+ */
+export function getTypeIcon(type) {
+ if (!type) return "bi-lightning-charge";
+ const t = type.toLowerCase();
+ if (t.includes("sql")) return "bi-database";
+ if (t.includes("openapi")) return "bi-globe";
+ if (t.includes("log_analytics")) return "bi-graph-up";
+ if (t.includes("msgraph")) return "bi-microsoft";
+ if (t.includes("databricks")) return "bi-bricks";
+ if (t.includes("http") || t.includes("smart_http")) return "bi-cloud-arrow-up";
+ if (t.includes("azure_function")) return "bi-lightning";
+ if (t.includes("blob")) return "bi-file-earmark";
+ if (t.includes("queue")) return "bi-inbox";
+ if (t.includes("embedding")) return "bi-vector-pen";
+ if (t.includes("fact_memory")) return "bi-brain";
+ if (t.includes("math")) return "bi-calculator";
+ if (t.includes("text")) return "bi-fonts";
+ if (t.includes("time")) return "bi-clock";
+ return "bi-lightning-charge";
+}
+
+/**
+ * Create the HTML string for a list/grid view toggle button group.
+ * @param {string} prefix - Unique prefix for element IDs (e.g., "agents", "plugins", "group-agents")
+ * @returns {string} HTML string
+ */
+export function createViewToggleHtml(prefix) {
+ return `
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+/**
+ * Set up view toggle event listeners and restore saved preference.
+ * @param {string} prefix - Unique prefix matching createViewToggleHtml
+ * @param {string} storageKey - localStorage key for persistence
+ * @param {function} onSwitch - Callback receiving 'list' or 'grid'
+ */
+export function setupViewToggle(prefix, storageKey, onSwitch) {
+ const listRadio = document.getElementById(`${prefix}-view-list`);
+ const gridRadio = document.getElementById(`${prefix}-view-grid`);
+ if (!listRadio || !gridRadio) return;
+
+ listRadio.addEventListener("change", () => {
+ if (listRadio.checked) {
+ localStorage.setItem(storageKey, "list");
+ onSwitch("list");
+ }
+ });
+
+ gridRadio.addEventListener("change", () => {
+ if (gridRadio.checked) {
+ localStorage.setItem(storageKey, "grid");
+ onSwitch("grid");
+ }
+ });
+
+ // Restore saved preference
+ const saved = localStorage.getItem(storageKey);
+ if (saved === "grid") {
+ gridRadio.checked = true;
+ listRadio.checked = false;
+ onSwitch("grid");
+ } else {
+ onSwitch("list");
+ }
+}
+
+/**
+ * Toggle visibility of list and grid containers.
+ * @param {string} mode - 'list' or 'grid'
+ * @param {HTMLElement} listContainer - The list/table container element
+ * @param {HTMLElement} gridContainer - The grid container element
+ */
+export function switchViewContainers(mode, listContainer, gridContainer) {
+ if (listContainer) {
+ listContainer.classList.toggle("d-none", mode !== "list");
+ }
+ if (gridContainer) {
+ gridContainer.classList.toggle("d-none", mode !== "grid");
+ }
+}
+
+// ============================================================================
+// VIEW MODAL — Lightweight read-only detail view
+// ============================================================================
+
+/**
+ * Open a read-only view modal for an agent or action.
+ * @param {object} item - The agent or action data object
+ * @param {'agent'|'action'} type - What kind of item this is
+ * @param {object} [callbacks] - Optional action callbacks { onChat, onEdit, onDelete }
+ */
+export function openViewModal(item, type, callbacks = {}) {
+ const modalEl = document.getElementById("item-view-modal");
+ if (!modalEl) return;
+
+ const titleEl = modalEl.querySelector(".modal-title");
+ const bodyEl = modalEl.querySelector(".modal-body");
+ const footerEl = modalEl.querySelector(".modal-footer");
+ if (!titleEl || !bodyEl || !footerEl) return;
+
+ if (type === "agent") {
+ titleEl.textContent = "Agent Details";
+ bodyEl.innerHTML = buildAgentViewHtml(item);
+ } else {
+ titleEl.textContent = "Action Details";
+ bodyEl.innerHTML = buildActionViewHtml(item);
+ }
+
+ // Build footer buttons dynamically
+ footerEl.innerHTML = '';
+ const { onChat, onEdit, onDelete } = callbacks;
+
+ if (onChat && typeof onChat === 'function') {
+ const chatBtn = document.createElement('button');
+ chatBtn.type = 'button';
+ chatBtn.className = 'btn btn-primary';
+ chatBtn.innerHTML = ' Chat';
+ chatBtn.addEventListener('click', () => {
+ bootstrap.Modal.getInstance(modalEl)?.hide();
+ onChat(item);
+ });
+ footerEl.appendChild(chatBtn);
+ }
+
+ if (onEdit && typeof onEdit === 'function') {
+ const editBtn = document.createElement('button');
+ editBtn.type = 'button';
+ editBtn.className = 'btn btn-outline-secondary';
+ editBtn.innerHTML = ' Edit';
+ editBtn.addEventListener('click', () => {
+ bootstrap.Modal.getInstance(modalEl)?.hide();
+ onEdit(item);
+ });
+ footerEl.appendChild(editBtn);
+ }
+
+ if (onDelete && typeof onDelete === 'function') {
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.className = 'btn btn-outline-danger';
+ delBtn.innerHTML = ' Delete';
+ delBtn.addEventListener('click', () => {
+ bootstrap.Modal.getInstance(modalEl)?.hide();
+ onDelete(item);
+ });
+ footerEl.appendChild(delBtn);
+ }
+
+ const closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.className = 'btn btn-secondary';
+ closeBtn.textContent = 'Close';
+ closeBtn.setAttribute('data-bs-dismiss', 'modal');
+ footerEl.appendChild(closeBtn);
+
+ const modal = new bootstrap.Modal(modalEl);
+ modal.show();
+}
+
+function buildAgentViewHtml(agent) {
+ const displayName = escapeHtml(agent.display_name || agent.displayName || agent.name || "");
+ const name = escapeHtml(agent.name || "");
+ const description = escapeHtml(agent.description || "No description available.");
+ const model = escapeHtml(agent.azure_openai_gpt_deployment || agent.model || "Default");
+ const agentType = agent.agent_type === "new_foundry"
+ ? "New Foundry"
+ : agent.agent_type === "aifoundry"
+ ? "Foundry (classic)"
+ : "Local (Semantic Kernel)";
+ const rawInstructions = agent.instructions || "No instructions defined.";
+ // Render instructions as Markdown (marked + DOMPurify are loaded globally in base.html)
+ const renderedInstructions = (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined')
+ ? DOMPurify.sanitize(marked.parse(rawInstructions))
+ : escapeHtml(rawInstructions);
+ const isGlobal = agent.is_global;
+ const scopeBadge = isGlobal
+ ? 'Global '
+ : 'Personal ';
+
+ return `
+
+
+
+
+
+ Display Name
+ ${displayName}
+
+
+ Generated Name
+ ${name}
+
+
+ Scope
+ ${scopeBadge}
+
+
+ Agent Type
+ ${escapeHtml(agentType)}
+
+
+ Description
+ ${description}
+
+
+
+
+
+
+
+
+
+ Model / Deployment
+ ${model}
+
+
+
+
+
+
+
+
+${renderedInstructions}
+
+
+
`;
+}
+
+function buildActionViewHtml(action) {
+ const displayName = escapeHtml(action.display_name || action.displayName || action.name || "");
+ const name = escapeHtml(action.name || "");
+ const description = escapeHtml(action.description || "No description available.");
+ const type = escapeHtml(action.type || "unknown");
+ const typeIcon = getTypeIcon(action.type);
+ const authType = escapeHtml(formatAuthType(action.auth?.type || action.auth_type || ""));
+ const endpoint = escapeHtml(action.endpoint || action.base_url || "");
+ const isGlobal = action.is_global;
+ const scopeBadge = isGlobal
+ ? 'Global '
+ : 'Personal ';
+
+ let configHtml = "";
+ if (endpoint) {
+ configHtml = `
+
+
+
+
+
+ Endpoint
+ ${endpoint}
+
+
+ Authentication
+ ${authType || "None"}
+
+
+
+
`;
+ }
+
+ return `
+
+
+
+
+
+ Display Name
+ ${displayName}
+
+
+ Generated Name
+ ${name}
+
+
+ Type
+ ${humanizeName(type)}
+
+
+ Scope
+ ${scopeBadge}
+
+
+ Description
+ ${description}
+
+
+
+
+ ${configHtml}`;
+}
+
+function formatAuthType(type) {
+ if (!type) return "";
+ const map = {
+ "key": "API Key",
+ "identity": "Managed Identity",
+ "user": "User (Delegated)",
+ "servicePrincipal": "Service Principal",
+ "connection_string": "Connection String",
+ "basic": "Basic Auth",
+ "username_password": "Username / Password",
+ "NoAuth": "No Authentication"
+ };
+ return map[type] || type;
+}
+
+// ============================================================================
+// GRID CARD RENDERERS
+// ============================================================================
+
+/**
+ * Create a grid card element for an agent.
+ * @param {object} agent - Agent data object
+ * @param {object} options - { onChat, onView, onEdit, onDelete, canManage, isGroup }
+ * @returns {HTMLElement}
+ */
+export function createAgentCard(agent, options = {}) {
+ const { onChat, onView, onEdit, onDelete, canManage = false, isGroup = false } = options;
+ const col = document.createElement("div");
+ col.className = "col-sm-6 col-md-4 col-lg-3";
+
+ const displayName = humanizeName(agent.display_name || agent.displayName || agent.name || "");
+ const description = agent.description || "No description available.";
+ const isGlobal = agent.is_global;
+
+ let badgeHtml = "";
+ if (isGlobal) {
+ badgeHtml = 'Global ';
+ }
+
+ let buttonsHtml = `
+
+ Chat
+
+
+
+ `;
+
+ if (canManage && !isGlobal) {
+ buttonsHtml += `
+
+
+
+
+
+ `;
+ }
+
+ col.innerHTML = `
+
+
+
+
+
+
${escapeHtml(displayName)}${badgeHtml}
+
${escapeHtml(truncateDescription(description, 120))}
+
+ ${buttonsHtml}
+
+
+
`;
+
+ // Bind button events
+ const chatBtn = col.querySelector(".item-card-chat-btn");
+ const viewBtn = col.querySelector(".item-card-view-btn");
+ const editBtn = col.querySelector(".item-card-edit-btn");
+ const deleteBtn = col.querySelector(".item-card-delete-btn");
+
+ if (chatBtn && onChat) chatBtn.addEventListener("click", (e) => { e.stopPropagation(); onChat(agent); });
+ if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(agent); });
+ if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(agent); });
+ if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(agent); });
+
+ // Clicking anywhere on the card opens the detail view
+ const cardEl = col.querySelector(".item-card");
+ if (cardEl && onView) {
+ cardEl.style.cursor = "pointer";
+ cardEl.addEventListener("click", () => onView(agent));
+ }
+
+ return col;
+}
+
+/**
+ * Create a grid card element for an action/plugin.
+ * @param {object} plugin - Action/plugin data object
+ * @param {object} options - { onView, onEdit, onDelete, canManage, isAdmin }
+ * @returns {HTMLElement}
+ */
+export function createActionCard(plugin, options = {}) {
+ const { onView, onEdit, onDelete, canManage = true, isAdmin = false } = options;
+ const col = document.createElement("div");
+ col.className = "col-sm-6 col-md-4 col-lg-3";
+
+ const displayName = humanizeName(plugin.display_name || plugin.displayName || plugin.name || "");
+ const description = plugin.description || "No description available.";
+ const type = plugin.type || "";
+ const typeIcon = getTypeIcon(type);
+ const isGlobal = plugin.is_global;
+
+ let badgeHtml = "";
+ if (isGlobal) {
+ badgeHtml = 'Global ';
+ }
+
+ const typeBadge = type
+ ? ` ${escapeHtml(humanizeName(type))} `
+ : "";
+
+ let buttonsHtml = `
+
+
+ `;
+
+ if ((isAdmin || (canManage && !isGlobal))) {
+ buttonsHtml += `
+
+
+
+
+
+ `;
+ }
+
+ col.innerHTML = `
+
+
+
+
+
+
${escapeHtml(displayName)}${badgeHtml}
+
${typeBadge}
+
${escapeHtml(truncateDescription(description, 120))}
+
+ ${buttonsHtml}
+
+
+
`;
+
+ // Bind button events
+ const viewBtn = col.querySelector(".item-card-view-btn");
+ const editBtn = col.querySelector(".item-card-edit-btn");
+ const deleteBtn = col.querySelector(".item-card-delete-btn");
+
+ if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(plugin); });
+ if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(plugin); });
+ if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(plugin); });
+
+ // Clicking anywhere on the card opens the detail view
+ const cardEl = col.querySelector(".item-card");
+ if (cardEl && onView) {
+ cardEl.style.cursor = "pointer";
+ cardEl.addEventListener("click", () => onView(plugin));
+ }
+
+ return col;
+}
diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js
index 481fe310..eefd0237 100644
--- a/application/single_app/static/js/workspace/workspace-documents.js
+++ b/application/single_app/static/js/workspace/workspace-documents.js
@@ -29,6 +29,12 @@ const docMetadataForm = document.getElementById("doc-metadata-form");
const docsSharedOnlyFilter = document.getElementById("docs-shared-only-filter");
const deleteSelectedBtn = document.getElementById("delete-selected-btn");
const clearSelectionBtn = document.getElementById("clear-selection-btn");
+const documentDeleteModalElement = document.getElementById("documentDeleteModal");
+const documentDeleteModal = documentDeleteModalElement ? new bootstrap.Modal(documentDeleteModalElement) : null;
+const documentDeleteModalTitle = document.getElementById("documentDeleteModalLabel");
+const documentDeleteModalBody = document.getElementById("documentDeleteModalBody");
+const documentDeleteCurrentBtn = document.getElementById("documentDeleteCurrentBtn");
+const documentDeleteAllBtn = document.getElementById("documentDeleteAllBtn");
// Selection mode variables
let selectionModeActive = false;
@@ -84,6 +90,172 @@ function isColorLight(hexColor) {
return luminance > 0.5;
}
+function getDocumentDeleteModalContent(documentCount) {
+ if (documentCount === 1) {
+ return {
+ title: "Delete Document",
+ body: `
+ Choose how to delete this document revision.
+ Delete Current Version removes the visible revision and keeps older revisions for later comparison.
+ Delete All Versions permanently removes every stored revision for this document.
+ `,
+ };
+ }
+
+ return {
+ title: "Delete Selected Documents",
+ body: `
+ Choose how to delete ${documentCount} selected current document revision(s).
+ Delete Current Version removes only the visible revision for each selected document and keeps older revisions.
+ Delete All Versions permanently removes every stored revision for each selected document.
+ `,
+ };
+}
+
+function showDocumentDeleteFeedback(message, variant = "danger") {
+ if (typeof window.showToast === "function") {
+ window.showToast(message, variant);
+ return;
+ }
+
+ let container = document.getElementById("documentDeleteFeedbackContainer");
+ if (!container) {
+ container = document.createElement("div");
+ container.id = "documentDeleteFeedbackContainer";
+ container.className = "toast-container position-fixed top-0 end-0 p-3";
+ document.body.appendChild(container);
+ }
+
+ if (window.bootstrap && typeof window.bootstrap.Toast === "function") {
+ const toastElement = document.createElement("div");
+ toastElement.className = `toast align-items-center text-white bg-${variant} border-0`;
+ toastElement.setAttribute("role", "alert");
+ toastElement.setAttribute("aria-live", "assertive");
+ toastElement.setAttribute("aria-atomic", "true");
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "d-flex";
+
+ const body = document.createElement("div");
+ body.className = "toast-body";
+ body.textContent = message;
+
+ const closeButton = document.createElement("button");
+ closeButton.type = "button";
+ closeButton.className = "btn-close btn-close-white me-2 m-auto";
+ closeButton.setAttribute("data-bs-dismiss", "toast");
+ closeButton.setAttribute("aria-label", "Close");
+
+ wrapper.appendChild(body);
+ wrapper.appendChild(closeButton);
+ toastElement.appendChild(wrapper);
+ container.appendChild(toastElement);
+
+ const toast = new window.bootstrap.Toast(toastElement);
+ toast.show();
+ toastElement.addEventListener("hidden.bs.toast", () => {
+ toastElement.remove();
+ });
+ return;
+ }
+
+ const alertElement = document.createElement("div");
+ alertElement.className = `alert alert-${variant} alert-dismissible fade show mb-2`;
+ alertElement.setAttribute("role", "alert");
+
+ const body = document.createElement("span");
+ body.textContent = message;
+
+ const closeButton = document.createElement("button");
+ closeButton.type = "button";
+ closeButton.className = "btn-close";
+ closeButton.setAttribute("data-bs-dismiss", "alert");
+ closeButton.setAttribute("aria-label", "Close");
+
+ alertElement.appendChild(body);
+ alertElement.appendChild(closeButton);
+ container.appendChild(alertElement);
+}
+
+function isDocumentDeleteModalReady() {
+ return Boolean(
+ documentDeleteModal &&
+ documentDeleteModalElement &&
+ documentDeleteModalElement.isConnected &&
+ documentDeleteModalBody &&
+ documentDeleteModalBody.isConnected &&
+ documentDeleteCurrentBtn &&
+ documentDeleteCurrentBtn.isConnected &&
+ documentDeleteAllBtn &&
+ documentDeleteAllBtn.isConnected
+ );
+}
+
+function promptDocumentDeleteMode(documentCount = 1) {
+ if (!isDocumentDeleteModalReady()) {
+ showDocumentDeleteFeedback("Delete confirmation dialog is unavailable. Refresh the page and try again.");
+ return Promise.resolve(null);
+ }
+
+ const modalContent = getDocumentDeleteModalContent(documentCount);
+ if (documentDeleteModalTitle) {
+ documentDeleteModalTitle.textContent = modalContent.title;
+ }
+ documentDeleteModalBody.innerHTML = modalContent.body;
+
+ return new Promise((resolve) => {
+ let settled = false;
+
+ const cleanup = () => {
+ documentDeleteModalElement.removeEventListener("hidden.bs.modal", handleHidden);
+ documentDeleteCurrentBtn.removeEventListener("click", handleCurrentOnly);
+ documentDeleteAllBtn.removeEventListener("click", handleAllVersions);
+ };
+
+ const finalize = (value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ resolve(value);
+ };
+
+ const handleHidden = () => finalize(null);
+ const handleCurrentOnly = () => {
+ documentDeleteModal.hide();
+ finalize("current_only");
+ };
+ const handleAllVersions = () => {
+ documentDeleteModal.hide();
+ finalize("all_versions");
+ };
+
+ documentDeleteModalElement.addEventListener("hidden.bs.modal", handleHidden);
+ documentDeleteCurrentBtn.addEventListener("click", handleCurrentOnly);
+ documentDeleteAllBtn.addEventListener("click", handleAllVersions);
+ documentDeleteModal.show();
+ });
+}
+
+async function requestDocumentDeletion(documentId, deleteMode) {
+ const query = new URLSearchParams({ delete_mode: deleteMode });
+ const response = await fetch(`/api/documents/${documentId}?${query.toString()}`, { method: "DELETE" });
+
+ let responseData = {};
+ try {
+ responseData = await response.json();
+ } catch (error) {
+ responseData = {};
+ }
+
+ if (!response.ok) {
+ throw responseData.error ? responseData : { error: `Server responded with status ${response.status}` };
+ }
+
+ return responseData;
+}
+
// ------------- Event Listeners -------------
// Page Size
@@ -1252,59 +1424,37 @@ window.onExtractMetadata = function (docId, event) {
};
-window.deleteDocument = function(documentId, event) {
- if (!confirm("Are you sure you want to delete this document? This action cannot be undone.")) return;
+window.deleteDocument = async function(documentId, event) {
+ const deleteMode = await promptDocumentDeleteMode(1);
+ if (!deleteMode) {
+ return;
+ }
- const deleteBtn = event ? event.target.closest('button') : null;
- if (deleteBtn) {
- deleteBtn.disabled = true;
- deleteBtn.innerHTML = ` `;
+ const deleteTrigger = event ? event.target.closest('a, button') : null;
+ const originalDeleteTriggerHtml = deleteTrigger ? deleteTrigger.innerHTML : null;
+ if (deleteTrigger) {
+ deleteTrigger.classList.add('disabled');
+ deleteTrigger.setAttribute('aria-disabled', 'true');
+ deleteTrigger.innerHTML = ` `;
}
- // Stop polling if active for this document
if (activePolls.has(documentId)) {
- // Find the interval ID associated with this poll to clear it (more robust approach needed if storing interval IDs)
- // For now, just remove from the active set; the poll will eventually fail or stop when elements disappear
activePolls.delete(documentId);
- // Ideally, you'd store intervalId with the docId in a map to clear it here.
}
-
- fetch(`/api/documents/${documentId}`, { method: "DELETE" })
- .then(response => {
- if (!response.ok) {
- return response.json().then(data => Promise.reject(data)).catch(() => Promise.reject({ error: `Server responded with status ${response.status}` }));
- }
- return response.json();
- })
- .then(data => {
- console.log("Document deleted successfully:", data);
- const docRow = document.getElementById(`doc-row-${documentId}`);
- const detailsRow = document.getElementById(`details-row-${documentId}`);
- const statusRow = document.getElementById(`status-row-${documentId}`);
- if (docRow) docRow.remove();
- if (detailsRow) detailsRow.remove();
- if (statusRow) statusRow.remove();
-
- // Refresh if the table body becomes empty OR to update pagination total count
- if (documentsTableBody && documentsTableBody.childElementCount === 0) {
- fetchUserDocuments(); // Refresh to show 'No documents' message and correct pagination
- } else {
- // Maybe just decrement total count locally and re-render pagination?
- // For simplicity, a full refresh might be acceptable unless dealing with huge lists/slow API
- fetchUserDocuments(); // Refresh to update pagination potentially
- }
-
- })
- .catch(error => {
- console.error("Error deleting document:", error);
- alert("Error deleting document: " + (error.error || error.message || "Unknown error"));
- // Re-enable button only if it still exists
- if (deleteBtn && document.body.contains(deleteBtn)) {
- deleteBtn.disabled = false;
- deleteBtn.innerHTML = ' ';
- }
- });
+ try {
+ const responseData = await requestDocumentDeletion(documentId, deleteMode);
+ console.log("Document deleted successfully:", responseData);
+ fetchUserDocuments();
+ } catch (error) {
+ console.error("Error deleting document:", error);
+ alert("Error deleting document: " + (error.error || error.message || "Unknown error"));
+ if (deleteTrigger && document.body.contains(deleteTrigger)) {
+ deleteTrigger.classList.remove('disabled');
+ deleteTrigger.removeAttribute('aria-disabled');
+ deleteTrigger.innerHTML = originalDeleteTriggerHtml;
+ }
+ }
}
window.removeSelfFromDocument = function(documentId, event) {
@@ -1468,64 +1618,43 @@ function updateBulkActionButtons() {
}
// Delete selected documents
-window.deleteSelectedDocuments = function() {
+window.deleteSelectedDocuments = async function() {
if (selectedDocuments.size === 0) return;
-
- if (!confirm(`Are you sure you want to delete ${selectedDocuments.size} document(s)? This action cannot be undone.`)) {
+
+ const deleteMode = await promptDocumentDeleteMode(selectedDocuments.size);
+ if (!deleteMode) {
return;
}
-
+
const documentIds = Array.from(selectedDocuments);
- let completed = 0;
- let failed = 0;
-
- // Process each document deletion sequentially
- documentIds.forEach(docId => {
- fetch(`/api/documents/${docId}`, { method: "DELETE" })
- .then(response => {
- if (response.ok) {
- completed++;
- const docRow = document.getElementById(`doc-row-${docId}`);
- const detailsRow = document.getElementById(`details-row-${docId}`);
- const statusRow = document.getElementById(`status-row-${docId}`);
- if (docRow) docRow.remove();
- if (detailsRow) detailsRow.remove();
- if (statusRow) statusRow.remove();
- } else {
- failed++;
- }
-
- // Update status when all operations complete
- if (completed + failed === documentIds.length) {
- if (failed > 0) {
- alert(`Deleted ${completed} document(s), but failed to delete ${failed} document(s).`);
- } else {
- alert(`Successfully deleted ${completed} document(s).`);
- }
-
- // Refresh the documents list
- fetchUserDocuments();
-
- // Exit selection mode
- window.toggleSelectionMode();
- }
- })
- .catch(error => {
- failed++;
- console.error("Error deleting document:", error);
-
- // Update status when all operations complete
- if (completed + failed === documentIds.length) {
- alert(`Deleted ${completed} document(s), but failed to delete ${failed} document(s).`);
-
- // Refresh the documents list
- fetchUserDocuments();
-
- // Exit selection mode
- window.toggleSelectionMode();
- }
- });
- });
+ if (deleteSelectedBtn) {
+ deleteSelectedBtn.disabled = true;
+ deleteSelectedBtn.innerHTML = ` Deleting...`;
+ }
+
+ documentIds.forEach((docId) => activePolls.delete(docId));
+
+ const results = await Promise.allSettled(documentIds.map((docId) => requestDocumentDeletion(docId, deleteMode)));
+ const completed = results.filter((result) => result.status === 'fulfilled').length;
+ const failed = results.filter((result) => result.status === 'rejected').length;
+
+ if (failed > 0) {
+ alert(`Deleted ${completed} document(s), but failed to delete ${failed} document(s).`);
+ }
+
+ if (selectionModeActive) {
+ window.toggleSelectionMode();
+ } else {
+ selectedDocuments.clear();
+ updateBulkActionButtons();
+ }
+
+ fetchUserDocuments();
+
+ if (deleteSelectedBtn) {
+ deleteSelectedBtn.disabled = false;
+ deleteSelectedBtn.innerHTML = ' Delete Selected';
+ }
};
// Remove self from selected shared documents
diff --git a/application/single_app/static/js/workspace/workspace-tutorial.js b/application/single_app/static/js/workspace/workspace-tutorial.js
new file mode 100644
index 00000000..2248378e
--- /dev/null
+++ b/application/single_app/static/js/workspace/workspace-tutorial.js
@@ -0,0 +1,1228 @@
+// workspace-tutorial.js
+
+const EDGE_PADDING = 12;
+const HIGHLIGHT_PADDING = 10;
+const CARD_GAP = 14;
+
+let tutorialSteps = [];
+let currentStepIndex = -1;
+let layerEl = null;
+let highlightEl = null;
+let cardEl = null;
+let activeTarget = null;
+let temporaryStateRestorers = [];
+let originalActiveTabId = null;
+let pendingStepToken = 0;
+let scheduledPositionFrame = 0;
+let layoutResizeObserver = null;
+let layoutMutationObserver = null;
+let currentStep = null;
+let highlightedTargetEl = null;
+
+const EXAMPLE_TAGS = [
+ { name: "finance", color: "#0d6efd", count: 12 },
+ { name: "q1-review", color: "#198754", count: 7 },
+ { name: "urgent", color: "#dc3545", count: 3 },
+ { name: "needs-follow-up", color: "#fd7e14", count: 5 }
+];
+
+const EXAMPLE_SHARED_USERS = [
+ { name: "Adele Vance", email: "adele.vance@contoso.com" },
+ { name: "Diego Siciliani", email: "diego.siciliani@contoso.com" }
+];
+
+function wait(ms) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+function isFeatureEnabled(flagName) {
+ return window[flagName] === true || window[flagName] === "true";
+}
+
+function registerTemporaryStateRestorer(restorer) {
+ if (typeof restorer === "function") {
+ temporaryStateRestorers.push(restorer);
+ }
+}
+
+function restoreTemporaryState() {
+ while (temporaryStateRestorers.length) {
+ const restore = temporaryStateRestorers.pop();
+ try {
+ restore();
+ } catch (error) {
+ console.warn("workspace-tutorial: failed to restore temporary state", error);
+ }
+ }
+}
+
+function getWorkspaceTabButton(tabButtonId) {
+ return document.getElementById(tabButtonId);
+}
+
+function activateTab(tabButtonId) {
+ const tabButton = getWorkspaceTabButton(tabButtonId);
+ if (!tabButton) {
+ return;
+ }
+
+ const activeButton = document.querySelector("#workspaceTab .nav-link.active");
+ if (!originalActiveTabId && activeButton?.id) {
+ originalActiveTabId = activeButton.id;
+ }
+
+ bootstrap.Tab.getOrCreateInstance(tabButton).show();
+}
+
+function restoreActiveTab() {
+ if (!originalActiveTabId) {
+ return;
+ }
+
+ const originalTabButton = getWorkspaceTabButton(originalActiveTabId);
+ if (!originalTabButton) {
+ return;
+ }
+
+ bootstrap.Tab.getOrCreateInstance(originalTabButton).show();
+ originalActiveTabId = null;
+}
+
+function ensureCollapseVisible(toggleId, collapseId) {
+ const toggleButton = document.getElementById(toggleId);
+ const collapseEl = document.getElementById(collapseId);
+ if (!collapseEl) {
+ return;
+ }
+
+ if (!collapseEl.classList.contains("show")) {
+ if (toggleButton) {
+ toggleButton.click();
+ registerTemporaryStateRestorer(() => {
+ if (collapseEl.classList.contains("show")) {
+ toggleButton.click();
+ }
+ });
+ } else {
+ collapseEl.classList.add("show");
+ registerTemporaryStateRestorer(() => {
+ collapseEl.classList.remove("show");
+ });
+ }
+ }
+}
+
+async function ensurePluginsReady() {
+ activateTab("plugins-tab-btn");
+
+ if (typeof window.fetchPlugins === "function") {
+ await Promise.resolve(window.fetchPlugins());
+ }
+}
+
+function isElementVisible(el) {
+ if (!el) {
+ return false;
+ }
+
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+}
+
+function findVisibleTarget(step) {
+ const selectors = step.selectorList || (step.selector ? [step.selector] : []);
+ for (const selector of selectors) {
+ const el = document.querySelector(selector);
+ if (isElementVisible(el)) {
+ return el;
+ }
+ }
+
+ return null;
+}
+
+function isStepAvailable(step) {
+ if (typeof step.isAvailable === "function" && !step.isAvailable()) {
+ return false;
+ }
+
+ if (step.availabilitySelector && !document.querySelector(step.availabilitySelector)) {
+ return false;
+ }
+
+ if (step.selectorList) {
+ return step.selectorList.some((selector) => document.querySelector(selector))
+ || typeof step.prepare === "function";
+ }
+
+ return !step.selector
+ || Boolean(document.querySelector(step.selector))
+ || typeof step.prepare === "function"
+ || Boolean(step.availabilitySelector);
+}
+
+async function resolveStepTarget(step, token, attemptsRemaining = 12) {
+ if (!step) {
+ return null;
+ }
+
+ if (typeof step.prepare === "function") {
+ await Promise.resolve(step.prepare());
+ }
+
+ for (let attempt = 0; attempt < attemptsRemaining; attempt += 1) {
+ if (token !== pendingStepToken) {
+ return null;
+ }
+
+ const target = findVisibleTarget(step);
+ if (target) {
+ return target;
+ }
+
+ await wait(100);
+ }
+
+ return null;
+}
+
+function ensureDocumentsListView() {
+ activateTab("documents-tab-btn");
+
+ const listRadio = document.getElementById("docs-view-list");
+ if (listRadio && !listRadio.checked) {
+ listRadio.click();
+ }
+}
+
+function getVisibleDocumentRows() {
+ return Array.from(document.querySelectorAll("#documents-table tbody tr[id^='doc-row-']"))
+ .filter((row) => isElementVisible(row));
+}
+
+function getFirstVisibleDocumentRow() {
+ return getVisibleDocumentRows()[0] || null;
+}
+
+function getDocumentIdFromRow(row) {
+ return row?.id?.startsWith("doc-row-") ? row.id.replace("doc-row-", "") : null;
+}
+
+function getFirstDocumentId() {
+ return getDocumentIdFromRow(getFirstVisibleDocumentRow());
+}
+
+function hasVisibleDocuments() {
+ return Boolean(getFirstVisibleDocumentRow());
+}
+
+function getDetailsRow(docId) {
+ return docId ? document.getElementById(`details-row-${docId}`) : null;
+}
+
+function getDocumentDetailsPanel(docId) {
+ return docId ? document.querySelector(`#details-row-${docId} .bg-light`) : null;
+}
+
+function getFirstDocumentActionsButton() {
+ return getFirstVisibleDocumentRow()?.querySelector(".action-dropdown .dropdown-toggle") || null;
+}
+
+function ensureFirstDocumentDetailsVisible() {
+ ensureDocumentsListView();
+
+ const docId = getFirstDocumentId();
+ const detailsRow = getDetailsRow(docId);
+ if (!docId || !detailsRow) {
+ return null;
+ }
+
+ if (detailsRow.style.display === "none") {
+ window.toggleDetails?.(docId);
+ registerTemporaryStateRestorer(() => {
+ if (detailsRow.style.display !== "none") {
+ window.toggleDetails?.(docId);
+ }
+ });
+ }
+
+ return getDocumentDetailsPanel(docId);
+}
+
+function ensureSelectionModeEnabled() {
+ ensureDocumentsListView();
+
+ const documentsTable = document.getElementById("documents-table");
+ if (!documentsTable) {
+ return false;
+ }
+
+ if (!documentsTable.classList.contains("selection-mode")) {
+ window.toggleSelectionMode?.();
+ registerTemporaryStateRestorer(() => {
+ if (documentsTable.classList.contains("selection-mode")) {
+ window.toggleSelectionMode?.();
+ }
+ });
+ }
+
+ return true;
+}
+
+function ensureFirstDocumentSelected() {
+ if (!ensureSelectionModeEnabled()) {
+ return null;
+ }
+
+ const row = getFirstVisibleDocumentRow();
+ const docId = getDocumentIdFromRow(row);
+ const checkbox = row?.querySelector(".document-checkbox");
+ if (!row || !docId || !checkbox) {
+ return null;
+ }
+
+ if (!checkbox.checked) {
+ checkbox.checked = true;
+ window.updateSelectedDocuments?.(docId, true);
+ registerTemporaryStateRestorer(() => {
+ if (checkbox.checked) {
+ checkbox.checked = false;
+ window.updateSelectedDocuments?.(docId, false);
+ }
+ });
+ }
+
+ return checkbox;
+}
+
+function createTutorialSurfaceRoot(className, styles = {}) {
+ const surface = document.createElement("div");
+ surface.className = className;
+ Object.assign(surface.style, styles);
+ if (layerEl && cardEl && layerEl.contains(cardEl)) {
+ layerEl.insertBefore(surface, cardEl);
+ } else if (layerEl) {
+ layerEl.appendChild(surface);
+ } else {
+ document.body.appendChild(surface);
+ }
+ registerTemporaryStateRestorer(() => {
+ surface.remove();
+ });
+ return surface;
+}
+
+function stripCloneIds(root) {
+ root.querySelectorAll("[id]").forEach((el) => {
+ el.setAttribute("data-tutorial-source-id", el.id);
+ el.removeAttribute("id");
+ });
+ root.querySelectorAll("label[for]").forEach((el) => {
+ el.removeAttribute("for");
+ });
+}
+
+function addTutorialCloneBanner(container, text) {
+ const banner = document.createElement("div");
+ banner.className = "alert alert-info small mb-3";
+ banner.textContent = text;
+ container.prepend(banner);
+}
+
+function markTutorialCloneIgnoredByAutofill(root) {
+ root.setAttribute("data-bwignore", "true");
+ root.setAttribute("data-1p-ignore", "true");
+ root.setAttribute("data-lpignore", "true");
+ root.setAttribute("autocomplete", "off");
+
+ root.querySelectorAll("form, input, textarea, select, button").forEach((el) => {
+ el.setAttribute("data-bwignore", "true");
+ el.setAttribute("data-1p-ignore", "true");
+ el.setAttribute("data-lpignore", "true");
+ el.setAttribute("autocomplete", "off");
+ });
+}
+
+function createStaticFieldFromControl(control) {
+ const field = document.createElement("div");
+ field.className = control.className || "";
+ field.classList.add("workspace-tutorial-static-field");
+ field.setAttribute("data-tutorial-source-id", control.getAttribute("data-tutorial-source-id") || "");
+
+ if (control.tagName === "TEXTAREA") {
+ field.classList.add("is-textarea");
+ field.textContent = control.value || control.textContent || "";
+ return field;
+ }
+
+ if (control.tagName === "SELECT") {
+ field.classList.add("is-select");
+ const selectedOption = control.options[control.selectedIndex];
+ field.textContent = selectedOption ? selectedOption.textContent : "";
+ return field;
+ }
+
+ if (control.tagName === "INPUT" && control.type === "color") {
+ field.classList.add("is-color");
+ const swatch = document.createElement("div");
+ swatch.className = "workspace-tutorial-static-color-swatch";
+ swatch.style.backgroundColor = control.value || "#0d6efd";
+ field.appendChild(swatch);
+ return field;
+ }
+
+ field.textContent = control.value || control.getAttribute("value") || control.placeholder || "";
+ return field;
+}
+
+function convertTutorialCloneControlsToStatic(root) {
+ root.querySelectorAll("input, textarea, select").forEach((control) => {
+ const staticField = createStaticFieldFromControl(control);
+ control.replaceWith(staticField);
+ });
+}
+
+function createTutorialModalClone(sourceModalId, cloneClassName, customizeClone) {
+ const sourceContent = document.querySelector(`#${sourceModalId} .modal-content`);
+ if (!sourceContent) {
+ return null;
+ }
+
+ const surface = createTutorialSurfaceRoot(
+ `workspace-tutorial-surface ${cloneClassName}`,
+ {
+ position: "fixed",
+ inset: "0",
+ zIndex: "1103",
+ pointerEvents: "none"
+ }
+ );
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "workspace-tutorial-modal-backdrop";
+ Object.assign(backdrop.style, {
+ position: "absolute",
+ inset: "0",
+ background: "rgba(0, 0, 0, 0.12)"
+ });
+
+ const dialog = document.createElement("div");
+ dialog.className = "workspace-tutorial-modal-dialog";
+ Object.assign(dialog.style, {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ width: "min(560px, calc(100vw - 2rem))",
+ maxHeight: "calc(100vh - 2rem)",
+ overflow: "auto",
+ pointerEvents: "auto"
+ });
+
+ const clone = sourceContent.cloneNode(true);
+ stripCloneIds(clone);
+ markTutorialCloneIgnoredByAutofill(clone);
+ clone.querySelectorAll("input, textarea, select, button").forEach((el) => {
+ if (el.tagName === "BUTTON") {
+ el.disabled = true;
+ } else {
+ el.disabled = true;
+ el.readOnly = true;
+ }
+ });
+
+ if (typeof customizeClone === "function") {
+ customizeClone(clone);
+ }
+
+ convertTutorialCloneControlsToStatic(clone);
+
+ dialog.appendChild(clone);
+ surface.appendChild(backdrop);
+ surface.appendChild(dialog);
+ return clone;
+}
+
+function createTutorialAnchoredMenu(anchorEl, className, innerHtml) {
+ if (!anchorEl) {
+ return null;
+ }
+
+ const rect = anchorEl.getBoundingClientRect();
+ const surface = createTutorialSurfaceRoot(
+ `workspace-tutorial-menu-clone ${className}`,
+ {
+ position: "fixed",
+ top: `${Math.min(rect.bottom + 6, window.innerHeight - 260)}px`,
+ left: `${Math.max(EDGE_PADDING, Math.min(rect.right - 240, window.innerWidth - 260))}px`,
+ zIndex: "1103",
+ minWidth: "240px",
+ pointerEvents: "none"
+ }
+ );
+
+ const menu = document.createElement("div");
+ menu.className = "dropdown-menu show shadow-lg";
+ menu.style.position = "static";
+ menu.innerHTML = innerHtml;
+ surface.appendChild(menu);
+ return menu;
+}
+
+function ensureTutorialMetadataModal() {
+ const clone = createTutorialModalClone("docMetadataModal", "workspace-tutorial-metadata-modal", (modalContent) => {
+ addTutorialCloneBanner(modalContent.querySelector(".modal-body"), "Example only: this tutorial preview shows the metadata editor layout without changing your real document.");
+
+ const titleInput = modalContent.querySelector("input[data-tutorial-source-id='doc-title']");
+ const abstractInput = modalContent.querySelector("textarea[data-tutorial-source-id='doc-abstract']");
+ const keywordsInput = modalContent.querySelector("input[data-tutorial-source-id='doc-keywords']");
+ const pubDateInput = modalContent.querySelector("input[data-tutorial-source-id='doc-publication-date']");
+ const authorsInput = modalContent.querySelector("input[data-tutorial-source-id='doc-authors']");
+ const classificationSelect = modalContent.querySelector("select[data-tutorial-source-id='doc-classification']");
+ const tagsContainer = modalContent.querySelector("[data-tutorial-source-id='doc-selected-tags-container']");
+
+ if (titleInput) titleInput.value = "Milestone Tracking Data";
+ if (abstractInput) abstractInput.value = "Example metadata showing the document summary, business context, and why the file matters before saving your edits.";
+ if (keywordsInput) keywordsInput.value = "milestones, status tracking, ownership, project management";
+ if (pubDateInput) pubDateInput.value = "03/2026";
+ if (authorsInput) authorsInput.value = "Alex Wilber, Joni Sherman";
+ if (classificationSelect) {
+ classificationSelect.value = classificationSelect.querySelector("option[value='none']") ? "none" : classificationSelect.value;
+ }
+ if (tagsContainer) {
+ tagsContainer.innerHTML = EXAMPLE_TAGS.slice(0, 3).map((tag) => `
+ ${tag.name}
+ `).join("");
+ }
+ });
+
+ return clone?.querySelector(".modal-content") || null;
+}
+
+function ensureTutorialShareModal() {
+ const clone = createTutorialModalClone("shareDocumentModal", "workspace-tutorial-share-modal", (modalContent) => {
+ addTutorialCloneBanner(modalContent.querySelector(".modal-body"), "Example only: sharing is shown with sample users so the tutorial can explain the workflow without exposing your real sharing list.");
+
+ const documentName = modalContent.querySelector("[data-tutorial-source-id='shareDocumentName']");
+ const noSharedUsers = modalContent.querySelector("[data-tutorial-source-id='noSharedUsers']");
+ const sharedUsersList = modalContent.querySelector("[data-tutorial-source-id='sharedUsersList']");
+ const searchInput = modalContent.querySelector("input[data-tutorial-source-id='userSearchTerm']");
+ const searchStatus = modalContent.querySelector("[data-tutorial-source-id='searchStatus']");
+ const resultsBody = modalContent.querySelector("table[data-tutorial-source-id='userSearchResultsTable'] tbody");
+
+ if (documentName) documentName.textContent = "milestones-20260320.xlsx";
+ if (noSharedUsers) noSharedUsers.remove();
+ if (sharedUsersList) {
+ sharedUsersList.innerHTML = EXAMPLE_SHARED_USERS.map((user) => `
+
+
+
${user.name}
+
${user.email}
+
+
Viewer
+
+ `).join("");
+ }
+ if (searchInput) searchInput.value = "Megan";
+ if (searchStatus) searchStatus.textContent = "Example results shown";
+ if (resultsBody) {
+ resultsBody.innerHTML = `
+
+ Megan Bowen
+ megan.bowen@contoso.com
+ Share
+ `;
+ }
+ });
+
+ return clone?.querySelector(".modal-content") || null;
+}
+
+function ensureTutorialBulkTagModal() {
+ const clone = createTutorialModalClone("bulkTagModal", "workspace-tutorial-bulk-tag-modal", (modalContent) => {
+ addTutorialCloneBanner(modalContent.querySelector(".modal-body"), "Example only: this shows how bulk tag assignment works using sample tags, not your saved workspace data.");
+
+ const count = modalContent.querySelector("[data-tutorial-source-id='bulk-tag-doc-count']");
+ const actionSelect = modalContent.querySelector("select[data-tutorial-source-id='bulk-tag-action']");
+ const tagsList = modalContent.querySelector("[data-tutorial-source-id='bulk-tags-list']");
+
+ if (count) count.textContent = "2";
+ if (actionSelect) actionSelect.value = "add_tags";
+ if (tagsList) {
+ tagsList.innerHTML = EXAMPLE_TAGS.map((tag, index) => `
+
+ ${tag.name}
+
+ `).join("");
+ }
+ });
+
+ return clone?.querySelector(".modal-content") || null;
+}
+
+function createTutorialTagSelectionModal(cloneClassName, titleText, bannerText) {
+ const clone = createTutorialModalClone("tagSelectionModal", cloneClassName, (modalContent) => {
+ const modalTitle = modalContent.querySelector(".modal-title");
+ const list = modalContent.querySelector("[data-tutorial-source-id='tag-selection-list']");
+
+ if (modalTitle) {
+ modalTitle.textContent = titleText;
+ }
+
+ addTutorialCloneBanner(modalContent.querySelector(".modal-body"), bannerText);
+
+ if (list) {
+ list.innerHTML = EXAMPLE_TAGS.map((tag, index) => `
+
+
+
+ ${tag.name}
+
+ ${tag.count}
+
+ `).join("");
+ }
+ });
+
+ return clone?.querySelector(".modal-content") || null;
+}
+
+function ensureTutorialTagExampleModal() {
+ return createTutorialTagSelectionModal(
+ "workspace-tutorial-tag-example-modal",
+ "Example Tag Selection",
+ "Example only: these sample tags show how tags appear when you assign them to documents."
+ );
+}
+
+function ensureTutorialMetadataTagSelectionModal() {
+ return createTutorialTagSelectionModal(
+ "workspace-tutorial-metadata-tag-selection-modal",
+ "Select Tags For This Document",
+ "Example only: this shows the single-document tag picker that appears from Manage Tags inside Edit Metadata."
+ );
+}
+
+function ensureTutorialBulkTagSelectionModal() {
+ return createTutorialTagSelectionModal(
+ "workspace-tutorial-bulk-tag-selection-modal",
+ "Select Tags For Selected Documents",
+ "Example only: this shows the tag picker launched from the bulk Tag Assignment workflow for selected documents."
+ );
+}
+
+function ensureTutorialWorkspaceTagManagementModal() {
+ const clone = createTutorialModalClone("tagManagementModal", "workspace-tutorial-tag-management-modal", (modalContent) => {
+ addTutorialCloneBanner(modalContent.querySelector(".modal-body"), "Example only: this shows the workspace tag library so the tutorial can explain tag creation, colors, counts, and editing without changing your real tags.");
+
+ const nameInput = modalContent.querySelector("input[data-tutorial-source-id='new-tag-name']");
+ const colorInput = modalContent.querySelector("input[data-tutorial-source-id='new-tag-color']");
+ const tbody = modalContent.querySelector("[data-tutorial-source-id='existing-tags-tbody']");
+
+ if (nameInput) {
+ nameInput.value = "quarterly-review";
+ }
+ if (colorInput) {
+ colorInput.value = "#7b61ff";
+ }
+ if (tbody) {
+ tbody.innerHTML = EXAMPLE_TAGS.map((tag) => `
+
+
+ ${tag.name}
+ ${tag.count}
+
+ Edit
+ Delete
+
+
+ `).join("");
+ }
+ });
+
+ return clone?.querySelector(".modal-content") || null;
+}
+
+function ensureTutorialActionsMenu() {
+ const menu = createTutorialAnchoredMenu(getFirstDocumentActionsButton(), "workspace-tutorial-doc-actions-menu", `
+
+
+ Select
+
+
+ Edit Metadata
+
+
+ Chat
+
+ ${isFeatureEnabled("enable_file_sharing") ? `
+
+
+ Share
+ 2
+
+ ` : ""}
+ `);
+
+ return menu;
+}
+
+function buildSteps() {
+ return [
+ {
+ id: "documents-upload",
+ selector: "#upload-area",
+ title: "Upload documents",
+ body: "Drag files here or click to browse when you want to add new source material to your personal workspace.",
+ prepare: () => activateTab("documents-tab-btn")
+ },
+ {
+ id: "documents-filters-toggle",
+ selector: "#docs-filters-toggle-btn",
+ title: "Open document filters",
+ body: "Use Show Search/Filters to reveal the document search, classification, author, keyword, abstract, and tag filters.",
+ prepare: () => activateTab("documents-tab-btn")
+ },
+ {
+ id: "documents-search",
+ selector: "#docs-search-input",
+ title: "Search your documents",
+ body: "Search by file name or title here, then combine it with the metadata filters below when you need to narrow a large workspace quickly.",
+ prepare: () => {
+ activateTab("documents-tab-btn");
+ ensureCollapseVisible("docs-filters-toggle-btn", "docs-filters-collapse");
+ }
+ },
+ {
+ id: "documents-view-toggle",
+ selectorList: ["label[for='docs-view-grid']", "label[for='docs-view-list']"],
+ title: "Switch document views",
+ body: "Toggle between List and Grid to work either with document rows or tag-folder style organization.",
+ prepare: () => activateTab("documents-tab-btn")
+ },
+ {
+ id: "documents-open-details",
+ selector: "#documents-table tbody tr[id^='doc-row-'] .expand-collapse-container button",
+ title: "Open a document row",
+ body: "Use the chevron on a document row to expand an in-place summary with metadata, tags, and document-specific actions.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureDocumentsListView
+ },
+ {
+ id: "documents-details-panel",
+ selector: "#documents-table tbody tr[id^='details-row-'] .bg-light",
+ title: "Review document details",
+ body: "The expanded section shows the file's metadata summary, detected tags, citations mode, and quick actions before you open a full editor.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureFirstDocumentDetailsVisible
+ },
+ {
+ id: "documents-edit-metadata-button",
+ selector: "#documents-table tbody tr[id^='details-row-'] .btn-info",
+ title: "Edit metadata from the row",
+ body: "Edit Metadata opens the document editor so you can refine title, abstract, keywords, publication date, authors, classification, and tags.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureFirstDocumentDetailsVisible
+ },
+ {
+ id: "documents-edit-metadata-modal",
+ selector: ".workspace-tutorial-metadata-modal .modal-content",
+ title: "Metadata editor example",
+ body: "This example modal shows the metadata form layout. It is a tutorial preview, so the values are sample content and nothing here changes your real document.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialMetadataModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-edit-metadata-tag-selection",
+ selector: ".workspace-tutorial-metadata-tag-selection-modal .modal-content",
+ title: "Single-file tag picker example",
+ body: "Right after opening Edit Metadata, this example shows the single-file tag picker that appears from Manage Tags. It demonstrates how tags are listed, colored, and counted for one document.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialMetadataTagSelectionModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-chat-button",
+ selector: "#documents-table tbody tr[id^='doc-row-'] button[onclick*='redirectToChat']",
+ title: "Start chat from a document",
+ body: "The row Chat button jumps straight into the chat page with that document preselected so you can ask questions about just this file.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureDocumentsListView
+ },
+ {
+ id: "documents-actions-button",
+ selector: "#documents-table tbody tr[id^='doc-row-'] .action-dropdown .dropdown-toggle",
+ title: "Open document actions",
+ body: "The three-dot menu collects row actions such as select mode, metadata editing, chat, sharing, and destructive actions.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureDocumentsListView
+ },
+ {
+ id: "documents-actions-menu",
+ selector: ".workspace-tutorial-doc-actions-menu .dropdown-menu",
+ title: "Document actions overview",
+ body: "This tutorial version shows the row actions in one place so you can see what each option means without changing the real row state underneath.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialActionsMenu,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-actions-share",
+ selector: ".workspace-tutorial-doc-actions-menu [data-tutorial-item='share']",
+ title: "Share a document",
+ body: "If sharing is enabled, Share lets you grant access to other users and review who already has this document.",
+ isAvailable: () => hasVisibleDocuments() && isFeatureEnabled("enable_file_sharing"),
+ prepare: ensureTutorialActionsMenu,
+ targetHighlightClass: "workspace-tutorial-target-highlight",
+ suppressOverlayHighlight: true,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-share-modal",
+ selector: ".workspace-tutorial-share-modal .modal-content",
+ title: "Share dialog example",
+ body: "This example share dialog shows the current access list, user lookup, and how new people can be added. The names here are sample data for the tutorial only.",
+ isAvailable: () => hasVisibleDocuments() && isFeatureEnabled("enable_file_sharing"),
+ prepare: ensureTutorialShareModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-actions-chat",
+ selector: ".workspace-tutorial-doc-actions-menu [data-tutorial-item='chat']",
+ title: "Chat from the actions menu",
+ body: "The Chat menu item does the same handoff as the row Chat button, which is useful when you are already working inside the actions menu.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialActionsMenu,
+ targetHighlightClass: "workspace-tutorial-target-highlight",
+ suppressOverlayHighlight: true,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-actions-select",
+ selector: ".workspace-tutorial-doc-actions-menu [data-tutorial-item='select']",
+ title: "Enter selection mode",
+ body: "Select switches the list into bulk-selection mode so you can act on several documents at once instead of one row at a time.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialActionsMenu,
+ targetHighlightClass: "workspace-tutorial-target-highlight",
+ suppressOverlayHighlight: true,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-selection-checkbox",
+ selector: "#documents-table.selection-mode tbody tr[id^='doc-row-'] .document-checkbox",
+ title: "Choose documents for bulk actions",
+ body: "Selection mode replaces the expand chevrons with checkboxes. Tick the documents you want to tag, chat with, delete, or clear from the current selection.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureFirstDocumentSelected
+ },
+ {
+ id: "documents-selection-bar",
+ selector: "#bulkActionsBar",
+ title: "Bulk actions bar",
+ body: "Once a document is selected, the bulk bar appears with Tag Assignment, Chat with Selected, Delete Selected, and Clear Selection so you can work across multiple files quickly.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureFirstDocumentSelected
+ },
+ {
+ id: "documents-bulk-tag-modal",
+ selector: ".workspace-tutorial-bulk-tag-modal .modal-content",
+ title: "Bulk tag assignment example",
+ body: "After you enter selection mode and choose Tag Assignment, this is the bulk tagging window. Use it to add, remove, or replace tags across several selected documents at once.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialBulkTagModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-bulk-tag-selection",
+ selector: ".workspace-tutorial-bulk-tag-selection-modal .modal-content",
+ title: "Bulk tag picker example",
+ body: "This follow-up example shows the tag picker launched from the bulk tag window so you can see how available tags look before applying them to the selected documents.",
+ isAvailable: hasVisibleDocuments,
+ prepare: ensureTutorialBulkTagSelectionModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "documents-manage-tags",
+ selector: "#workspace-manage-tags-btn",
+ title: "Manage workspace tags",
+ body: "Use Manage Tags to maintain the workspace-wide tag library itself: create new tags, rename them, change colors, and keep document tagging consistent.",
+ prepare: () => activateTab("documents-tab-btn")
+ },
+ {
+ id: "documents-manage-tags-modal",
+ selector: ".workspace-tutorial-tag-management-modal .modal-content",
+ title: "Workspace tag library example",
+ body: "This example shows the Manage Workspace Tags popup, where you add new tags, set colors, review how many documents use each tag, and maintain the shared tag library.",
+ prepare: ensureTutorialWorkspaceTagManagementModal,
+ skipScrollIntoView: true
+ },
+ {
+ id: "prompts-create",
+ selector: "#create-prompt-btn",
+ availabilitySelector: "#prompts-tab-btn",
+ title: "Create saved prompts",
+ body: "Your Prompts stores reusable instructions and templates so you can start common tasks faster.",
+ prepare: () => activateTab("prompts-tab-btn")
+ },
+ {
+ id: "prompts-filters-toggle",
+ selector: "#prompts-filters-toggle-btn",
+ availabilitySelector: "#prompts-tab-btn",
+ title: "Open prompt search",
+ body: "Use Show Search/Filters in the prompts tab when you need to find a saved prompt by name instead of browsing the full list.",
+ prepare: () => activateTab("prompts-tab-btn")
+ },
+ {
+ id: "prompts-search",
+ selector: "#prompts-search-input",
+ availabilitySelector: "#prompts-tab-btn",
+ title: "Search saved prompts",
+ body: "Search prompts by name here and keep your prompt library manageable as it grows.",
+ prepare: () => {
+ activateTab("prompts-tab-btn");
+ ensureCollapseVisible("prompts-filters-toggle-btn", "prompts-filters-collapse");
+ }
+ },
+ {
+ id: "agents-templates",
+ selector: ".agent-examples-trigger",
+ availabilitySelector: "#agents-tab-btn",
+ title: "Start from agent templates",
+ body: "Agent Templates gives you prebuilt starting points so you can assemble a new personal agent faster.",
+ prepare: () => activateTab("agents-tab-btn")
+ },
+ {
+ id: "agents-create",
+ selector: "#create-agent-btn",
+ availabilitySelector: "#agents-tab-btn",
+ title: "Create personal agents",
+ body: "Use New Agent to build a reusable agent with its own instructions, model choices, and action connections.",
+ prepare: () => activateTab("agents-tab-btn")
+ },
+ {
+ id: "agents-search",
+ selector: "#agents-search",
+ availabilitySelector: "#agents-tab-btn",
+ title: "Search your agents",
+ body: "Search agents by name or description to jump directly to the one you want to chat with or edit.",
+ prepare: () => activateTab("agents-tab-btn")
+ },
+ {
+ id: "plugins-create",
+ selector: "#create-plugin-btn",
+ availabilitySelector: "#plugins-tab-btn",
+ title: "Create personal actions",
+ body: "Use New Action to add a reusable tool or API integration that your personal agents can call.",
+ prepare: ensurePluginsReady
+ },
+ {
+ id: "plugins-search",
+ selector: "#plugins-search",
+ availabilitySelector: "#plugins-tab-btn",
+ title: "Search your actions",
+ body: "Search your action library by name or description to find the right integration quickly.",
+ prepare: ensurePluginsReady
+ }
+ ];
+}
+
+function createLayer() {
+ layerEl = document.createElement("div");
+ layerEl.className = "workspace-tutorial-layer";
+
+ highlightEl = document.createElement("div");
+ highlightEl.className = "workspace-tutorial-highlight";
+ highlightEl.setAttribute("aria-hidden", "true");
+
+ cardEl = document.createElement("div");
+ cardEl.className = "workspace-tutorial-card card shadow";
+ cardEl.setAttribute("role", "dialog");
+ cardEl.setAttribute("aria-live", "polite");
+
+ layerEl.appendChild(highlightEl);
+ layerEl.appendChild(cardEl);
+ document.body.appendChild(layerEl);
+}
+
+function clearTargetHighlight() {
+ if (highlightedTargetEl) {
+ highlightedTargetEl.classList.remove("workspace-tutorial-target-highlight");
+ highlightedTargetEl = null;
+ }
+}
+
+function applyTargetHighlight(step, target) {
+ clearTargetHighlight();
+
+ if (step?.targetHighlightClass && target) {
+ target.classList.add(step.targetHighlightClass);
+ highlightedTargetEl = target;
+ }
+}
+
+function cancelScheduledPosition() {
+ if (scheduledPositionFrame) {
+ window.cancelAnimationFrame(scheduledPositionFrame);
+ scheduledPositionFrame = 0;
+ }
+}
+
+function schedulePositionElements() {
+ if (!layerEl) {
+ return;
+ }
+
+ cancelScheduledPosition();
+ scheduledPositionFrame = window.requestAnimationFrame(() => {
+ scheduledPositionFrame = 0;
+ window.requestAnimationFrame(() => {
+ if (layerEl) {
+ positionElements();
+ }
+ });
+ });
+}
+
+function handleLayoutShift() {
+ schedulePositionElements();
+}
+
+function startLayoutObservers() {
+ stopLayoutObservers();
+
+ const observedRoot = document.querySelector(".container") || document.body;
+ if (!observedRoot) {
+ return;
+ }
+
+ if (window.ResizeObserver) {
+ layoutResizeObserver = new ResizeObserver(() => {
+ schedulePositionElements();
+ });
+ layoutResizeObserver.observe(observedRoot);
+ }
+
+ if (window.MutationObserver) {
+ layoutMutationObserver = new MutationObserver((mutations) => {
+ const shouldReposition = mutations.some((mutation) => mutation.type === "childList"
+ || mutation.attributeName === "class"
+ || mutation.attributeName === "style"
+ || mutation.attributeName === "aria-expanded");
+
+ if (shouldReposition) {
+ schedulePositionElements();
+ }
+ });
+
+ layoutMutationObserver.observe(observedRoot, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["class", "style", "aria-expanded"]
+ });
+ }
+}
+
+function stopLayoutObservers() {
+ layoutResizeObserver?.disconnect();
+ layoutResizeObserver = null;
+
+ layoutMutationObserver?.disconnect();
+ layoutMutationObserver = null;
+
+ cancelScheduledPosition();
+}
+
+function positionElements() {
+ if (!activeTarget || !highlightEl || !cardEl) {
+ return;
+ }
+
+ highlightEl.style.opacity = currentStep?.suppressOverlayHighlight ? "0" : "1";
+
+ const rect = activeTarget.getBoundingClientRect();
+ const top = Math.max(rect.top - HIGHLIGHT_PADDING, EDGE_PADDING);
+ const left = Math.max(rect.left - HIGHLIGHT_PADDING, EDGE_PADDING);
+ const width = Math.min(rect.width + HIGHLIGHT_PADDING * 2, window.innerWidth - EDGE_PADDING * 2);
+ const height = Math.min(rect.height + HIGHLIGHT_PADDING * 2, window.innerHeight - EDGE_PADDING * 2);
+
+ highlightEl.style.top = `${top}px`;
+ highlightEl.style.left = `${left}px`;
+ highlightEl.style.width = `${width}px`;
+ highlightEl.style.height = `${height}px`;
+
+ cardEl.style.top = `${EDGE_PADDING}px`;
+ cardEl.style.left = `${EDGE_PADDING}px`;
+
+ const cardRect = cardEl.getBoundingClientRect();
+ let cardTop = rect.bottom + CARD_GAP;
+ let cardLeft = rect.left + rect.width / 2 - cardRect.width / 2;
+
+ if (cardTop + cardRect.height > window.innerHeight - EDGE_PADDING) {
+ cardTop = rect.top - cardRect.height - CARD_GAP;
+ }
+
+ if (cardTop < EDGE_PADDING) {
+ cardTop = Math.min(window.innerHeight - cardRect.height - EDGE_PADDING, Math.max(EDGE_PADDING, rect.top + CARD_GAP));
+ }
+
+ cardLeft = Math.max(EDGE_PADDING, Math.min(cardLeft, window.innerWidth - cardRect.width - EDGE_PADDING));
+
+ cardEl.style.top = `${cardTop}px`;
+ cardEl.style.left = `${cardLeft}px`;
+}
+
+function renderCard(step) {
+ const isLast = currentStepIndex === tutorialSteps.length - 1;
+ cardEl.innerHTML = `
+
+
+ ${currentStepIndex + 1}/${tutorialSteps.length}
+
+
+
${step.title}
+
${step.body}
+
+
+ Back
+ ${isLast ? "Finish" : "Next"}
+
+
Skip
+
+
`;
+
+ cardEl.querySelector(".btn-close")?.addEventListener("click", () => endTutorial());
+ cardEl.querySelectorAll("[data-action]").forEach((btn) => {
+ btn.addEventListener("click", async (event) => {
+ event.preventDefault();
+ const action = event.currentTarget.getAttribute("data-action");
+ if (action === "skip") {
+ endTutorial();
+ return;
+ }
+ if (action === "back") {
+ await goToStep(currentStepIndex - 1, -1);
+ return;
+ }
+ if (action === "next") {
+ await goToStep(currentStepIndex + 1, 1);
+ }
+ });
+ });
+}
+
+function handleKeydown(event) {
+ if (!layerEl) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ endTutorial();
+ }
+}
+
+function bindGlobalListeners() {
+ document.addEventListener("keydown", handleKeydown);
+ window.addEventListener("resize", handleLayoutShift, true);
+ window.addEventListener("scroll", handleLayoutShift, true);
+ document.addEventListener("shown.bs.collapse", handleLayoutShift, true);
+ document.addEventListener("hidden.bs.collapse", handleLayoutShift, true);
+ document.addEventListener("shown.bs.tab", handleLayoutShift, true);
+ startLayoutObservers();
+}
+
+function unbindGlobalListeners() {
+ document.removeEventListener("keydown", handleKeydown);
+ window.removeEventListener("resize", handleLayoutShift, true);
+ window.removeEventListener("scroll", handleLayoutShift, true);
+ document.removeEventListener("shown.bs.collapse", handleLayoutShift, true);
+ document.removeEventListener("hidden.bs.collapse", handleLayoutShift, true);
+ document.removeEventListener("shown.bs.tab", handleLayoutShift, true);
+ stopLayoutObservers();
+}
+
+function endTutorial() {
+ pendingStepToken += 1;
+ clearTargetHighlight();
+ restoreTemporaryState();
+ restoreActiveTab();
+ unbindGlobalListeners();
+
+ if (layerEl) {
+ layerEl.remove();
+ }
+
+ layerEl = null;
+ highlightEl = null;
+ cardEl = null;
+ activeTarget = null;
+ currentStep = null;
+ currentStepIndex = -1;
+}
+
+async function goToStep(startIndex, direction = 1) {
+ pendingStepToken += 1;
+ const token = pendingStepToken;
+ restoreTemporaryState();
+
+ let index = startIndex;
+ while (index >= 0 && index < tutorialSteps.length) {
+ const step = tutorialSteps[index];
+ const target = await resolveStepTarget(step, token);
+
+ if (token !== pendingStepToken) {
+ return;
+ }
+
+ if (target) {
+ currentStepIndex = index;
+ currentStep = step;
+ activeTarget = target;
+ applyTargetHighlight(step, target);
+ if (!step.skipScrollIntoView && !target.closest(".workspace-tutorial-surface") && target.scrollIntoView) {
+ target.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
+ }
+ renderCard(step);
+ positionElements();
+ schedulePositionElements();
+ return;
+ }
+
+ index += direction;
+ }
+
+ endTutorial();
+}
+
+async function startTutorial() {
+ endTutorial();
+ tutorialSteps = buildSteps().filter(isStepAvailable);
+ if (!tutorialSteps.length) {
+ console.warn("workspace-tutorial: no steps available");
+ return;
+ }
+
+ createLayer();
+ bindGlobalListeners();
+ await goToStep(0, 1);
+}
+
+function initPersonalWorkspaceTutorial() {
+ const launchButton = document.getElementById("workspace-tutorial-btn");
+ if (!launchButton) {
+ return;
+ }
+
+ launchButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ startTutorial();
+ });
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initPersonalWorkspaceTutorial);
+} else {
+ initPersonalWorkspaceTutorial();
+}
diff --git a/application/single_app/static/js/workspace/workspace_agents.js b/application/single_app/static/js/workspace/workspace_agents.js
index a0839b25..88404b86 100644
--- a/application/single_app/static/js/workspace/workspace_agents.js
+++ b/application/single_app/static/js/workspace/workspace_agents.js
@@ -4,14 +4,22 @@
import { showToast } from "../chat/chat-toast.js";
import * as agentsCommon from '../agents_common.js';
import { AgentModalStepper } from '../agent_modal_stepper.js';
+import {
+ humanizeName, truncateDescription, escapeHtml,
+ setupViewToggle, switchViewContainers,
+ openViewModal, createAgentCard
+} from './view-utils.js';
// --- DOM Elements & Globals ---
const agentsTbody = document.getElementById('agents-table-body');
const agentsErrorDiv = document.getElementById('workspace-agents-error');
const createAgentBtn = document.getElementById('create-agent-btn');
const agentsSearchInput = document.getElementById('agents-search');
+const agentsListView = document.getElementById('agents-list-view');
+const agentsGridView = document.getElementById('agents-grid-view');
let agents = [];
let filteredAgents = [];
+let currentViewMode = 'list';
// --- Function Definitions ---
@@ -43,104 +51,87 @@ function filterAgents(searchTerm) {
});
}
renderAgentsTable(filteredAgents);
+ renderAgentsGrid(filteredAgents);
}
-// --- Helper Functions ---
-
-function truncateDisplayName(displayName, maxLength = 12) {
- if (!displayName || displayName.length <= maxLength) {
- return displayName;
+// Open the view modal for an agent with Chat/Edit/Delete actions in the footer
+function openAgentViewModal(agent) {
+ const callbacks = {
+ onChat: (a) => chatWithAgent(a.name),
+ onDelete: !agent.is_global ? (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); } : null
+ };
+ if (!agent.is_global) {
+ callbacks.onEdit = (a) => openAgentModal(a);
}
- return displayName.substring(0, maxLength) + '...';
+ openViewModal(agent, 'agent', callbacks);
}
+// --- Rendering Functions ---
function renderAgentsTable(agentsList) {
if (!agentsTbody) return;
agentsTbody.innerHTML = '';
if (!agentsList.length) {
const tr = document.createElement('tr');
- tr.innerHTML = 'No agents found. ';
+ tr.innerHTML = 'No agents found. ';
agentsTbody.appendChild(tr);
return;
}
- // Fetch selected_agent from user settings (async)
- fetch('/api/user/settings').then(res => {
- if (!res.ok) throw new Error('Failed to load user settings');
- return res.json();
- }).then(settings => {
- let selectedAgentObj = settings.selected_agent;
- if (!selectedAgentObj && settings.settings && settings.settings.selected_agent) {
- selectedAgentObj = settings.settings.selected_agent;
- }
- let selectedAgentName = typeof selectedAgentObj === 'object' ? selectedAgentObj.name : selectedAgentObj;
- agentsTbody.innerHTML = '';
- for (const agent of agentsList) {
- const tr = document.createElement('tr');
-
- // Create action buttons
- let actionButtons = `
- Chat
- `;
-
- if (!agent.is_global) {
- actionButtons += `
-
-
-
-
-
-
- `;
- }
-
- const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || '');
-
- tr.innerHTML = `
-
- ${truncatedDisplayName}
- ${agent.is_global ? ' Global ' : ''}
-
- ${agent.description || 'No description available'}
- ${actionButtons}
- `;
- agentsTbody.appendChild(tr);
- }
- }).catch(e => {
- renderError('Could not load agent settings: ' + e.message);
- // Fallback: render table without settings
- agentsTbody.innerHTML = '';
- for (const agent of agentsList) {
- const tr = document.createElement('tr');
-
- // Create action buttons
- let actionButtons = `
+
+ for (const agent of agentsList) {
+ const tr = document.createElement('tr');
+ const displayName = humanizeName(agent.display_name || agent.name || '');
+ const description = agent.description || 'No description available';
+ const truncatedDesc = truncateDescription(description, 90);
+ const isGlobal = agent.is_global;
+
+ // Match group workspace ordering in table view: View, Chat, Edit, Delete.
+ let actionButtons = `
+
+
+
Chat
`;
-
- if (!agent.is_global) {
- actionButtons += `
-
-
-
-
-
-
- `;
- }
-
- const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || '');
-
- tr.innerHTML = `
-
- ${truncatedDisplayName}
- ${agent.is_global ? ' Global ' : ''}
-
- ${agent.description || 'No description available'}
- ${actionButtons}
- `;
- agentsTbody.appendChild(tr);
+
+ if (!isGlobal) {
+ actionButtons += `
+
+
+
+
+
+ `;
}
- });
+
+ tr.innerHTML = `
+
+ ${escapeHtml(displayName)}
+ ${isGlobal ? ' Global ' : ''}
+
+ ${escapeHtml(truncatedDesc)}
+ ${actionButtons}
+ `;
+ agentsTbody.appendChild(tr);
+ }
+}
+
+function renderAgentsGrid(agentsList) {
+ if (!agentsGridView) return;
+ agentsGridView.innerHTML = '';
+ if (!agentsList.length) {
+ agentsGridView.innerHTML = 'No agents found.
';
+ return;
+ }
+
+ for (const agent of agentsList) {
+ const card = createAgentCard(agent, {
+ onChat: (a) => chatWithAgent(a.name),
+ onView: (a) => openAgentViewModal(a),
+ onEdit: (a) => openAgentModal(a),
+ onDelete: (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); },
+ canManage: !agent.is_global
+ });
+ agentsGridView.appendChild(card);
+ }
}
async function fetchAgents() {
@@ -151,6 +142,7 @@ async function fetchAgents() {
agents = await res.json();
filteredAgents = agents; // Initialize filtered list
renderAgentsTable(filteredAgents);
+ renderAgentsGrid(filteredAgents);
} catch (e) {
renderError(e.message);
}
@@ -177,17 +169,14 @@ function attachAgentTableEvents() {
}
agentsTbody.addEventListener('click', function (e) {
- console.log('Agent table clicked, target:', e.target);
-
// Find the button element (could be the target or a parent)
const editBtn = e.target.closest('.edit-agent-btn');
const deleteBtn = e.target.closest('.delete-agent-btn');
const chatBtn = e.target.closest('.chat-agent-btn');
+ const viewBtn = e.target.closest('.view-agent-btn');
if (editBtn) {
- console.log('Edit agent button clicked, dataset:', editBtn.dataset);
const agent = agents.find(a => a.name === editBtn.dataset.name);
- console.log('Found agent:', agent);
openAgentModal(agent);
}
@@ -201,33 +190,32 @@ function attachAgentTableEvents() {
const agentName = chatBtn.dataset.name;
chatWithAgent(agentName);
}
+
+ if (viewBtn) {
+ const agent = agents.find(a => a.name === viewBtn.dataset.name);
+ if (agent) openAgentViewModal(agent);
+ }
});
}
async function chatWithAgent(agentName) {
try {
- console.log('DEBUG: chatWithAgent called with agentName:', agentName);
- console.log('DEBUG: Available agents:', agents);
-
- // Find the agent to get its is_global status
const agent = agents.find(a => a.name === agentName);
- console.log('DEBUG: Found agent:', agent);
-
if (!agent) {
throw new Error('Agent not found');
}
- console.log('DEBUG: Agent is_global flag:', agent.is_global);
- console.log('DEBUG: !!agent.is_global:', !!agent.is_global);
-
- // Set the selected agent with proper is_global flag
const payloadData = {
selected_agent: {
- name: agentName,
- is_global: !!agent.is_global
+ id: agent.id || null,
+ name: agentName,
+ display_name: agent.display_name || agent.displayName || agentName,
+ is_global: !!agent.is_global,
+ is_group: false,
+ group_id: null,
+ group_name: null
}
};
- console.log('DEBUG: Sending payload:', payloadData);
const resp = await fetch('/api/user/settings/selected_agent', {
method: 'POST',
@@ -239,9 +227,6 @@ async function chatWithAgent(agentName) {
throw new Error('Failed to select agent');
}
- console.log('DEBUG: Agent selection saved successfully');
-
- // Navigate to chat page
window.location.href = '/chats';
} catch (err) {
console.error('Error selecting agent for chat:', err);
@@ -351,8 +336,19 @@ async function deleteAgent(name) {
// --- Execution: Event Wiring & Initial Load ---
function initializeWorkspaceAgentUI() {
- window.agentModalStepper = new AgentModalStepper(false);
+ window.agentModalStepper = new AgentModalStepper(false, { settingsEndpoint: '/api/user/agent/settings' });
attachAgentTableEvents();
+
+ // Set up view toggle
+ setupViewToggle('agents', 'agentsViewPreference', (mode) => {
+ currentViewMode = mode;
+ switchViewContainers(mode, agentsListView, agentsGridView);
+ // Re-render grid if switching to grid and we have data
+ if (mode === 'grid' && filteredAgents.length) {
+ renderAgentsGrid(filteredAgents);
+ }
+ });
+
fetchAgents();
}
diff --git a/application/single_app/static/js/workspace/workspace_model_endpoints.js b/application/single_app/static/js/workspace/workspace_model_endpoints.js
new file mode 100644
index 00000000..612ea15d
--- /dev/null
+++ b/application/single_app/static/js/workspace/workspace_model_endpoints.js
@@ -0,0 +1,722 @@
+// workspace_model_endpoints.js
+
+import { showToast } from "../chat/chat-toast.js";
+
+const enableMultiEndpointToggle = document.getElementById("enable_multi_model_endpoints");
+const endpointsWrapper = document.getElementById("model-endpoints-wrapper");
+const endpointsTbody = document.getElementById("model-endpoints-tbody");
+const addEndpointBtn = document.getElementById("add-model-endpoint-btn");
+
+const endpointModalEl = document.getElementById("modelEndpointModal");
+const endpointModal = endpointModalEl && window.bootstrap ? bootstrap.Modal.getOrCreateInstance(endpointModalEl) : null;
+
+const endpointIdInput = document.getElementById("model-endpoint-id");
+const endpointNameInput = document.getElementById("model-endpoint-name");
+const endpointProviderSelect = document.getElementById("model-endpoint-provider");
+const endpointUrlInput = document.getElementById("model-endpoint-endpoint");
+const endpointProjectGroup = document.getElementById("model-endpoint-project-group");
+const endpointProjectInput = document.getElementById("model-endpoint-project-name");
+const endpointProjectApiVersionGroup = document.getElementById("model-endpoint-project-api-version-group");
+const endpointProjectApiVersionInput = document.getElementById("model-endpoint-project-api-version");
+const endpointOpenAiApiVersionGroup = document.getElementById("model-endpoint-openai-api-version-group");
+const endpointOpenAiApiVersionInput = document.getElementById("model-endpoint-openai-api-version");
+const endpointSubscriptionGroup = document.getElementById("model-endpoint-subscription-group");
+const endpointResourceGroup = document.getElementById("model-endpoint-resource-group-group");
+const endpointSubscriptionInput = document.getElementById("model-endpoint-subscription-id");
+const endpointResourceGroupInput = document.getElementById("model-endpoint-resource-group");
+const endpointAuthTypeSelect = document.getElementById("model-endpoint-auth-type");
+const endpointManagementCloudGroup = document.getElementById("model-endpoint-management-cloud-group");
+const endpointManagementCloudSelect = document.getElementById("model-endpoint-management-cloud");
+const endpointCustomAuthorityGroup = document.getElementById("model-endpoint-custom-authority-group");
+const endpointCustomAuthorityInput = document.getElementById("model-endpoint-custom-authority");
+const endpointFoundryScopeGroup = document.getElementById("model-endpoint-foundry-scope-group");
+const endpointFoundryScopeInput = document.getElementById("model-endpoint-foundry-scope");
+const apiKeyNote = document.getElementById("model-endpoint-api-key-note");
+
+const miTypeGroup = document.getElementById("model-endpoint-mi-type-group");
+const miClientGroup = document.getElementById("model-endpoint-mi-client-group");
+const tenantGroup = document.getElementById("model-endpoint-tenant-group");
+const clientGroup = document.getElementById("model-endpoint-client-group");
+const secretGroup = document.getElementById("model-endpoint-secret-group");
+const apiKeyGroup = document.getElementById("model-endpoint-key-group");
+
+const miTypeSelect = document.getElementById("model-endpoint-mi-type");
+const miClientIdInput = document.getElementById("model-endpoint-mi-client-id");
+const tenantIdInput = document.getElementById("model-endpoint-tenant-id");
+const clientIdInput = document.getElementById("model-endpoint-client-id");
+const clientSecretInput = document.getElementById("model-endpoint-client-secret");
+const apiKeyInput = document.getElementById("model-endpoint-api-key");
+
+const fetchBtn = document.getElementById("model-endpoint-fetch-btn");
+const saveBtn = document.getElementById("model-endpoint-save-btn");
+const modelsListEl = document.getElementById("model-endpoint-models-list");
+const addModelBtn = document.getElementById("model-endpoint-add-model-btn");
+
+const scope = window.modelEndpointScope || "user";
+const endpointsContainerId = scope === "group" ? "group-multi-endpoint-configuration" : "workspace-multi-endpoint-configuration";
+const endpointsContainer = document.getElementById(endpointsContainerId);
+const endpointsApi = scope === "group" ? "/api/group/model-endpoints" : "/api/user/model-endpoints";
+const modelsFetchApi = scope === "group" ? "/api/group/models/fetch" : "/api/user/models/fetch";
+const modelsTestApi = scope === "group" ? "/api/group/models/test-model" : "/api/user/models/test-model";
+
+let workspaceEndpoints = Array.isArray(window.workspaceModelEndpoints) ? [...window.workspaceModelEndpoints] : [];
+let modalModels = [];
+
+function hasEndpointManagementUi() {
+ return Boolean(endpointsWrapper && endpointsTbody);
+}
+
+function hideEndpointManagementUi() {
+ if (endpointsContainer) {
+ endpointsContainer.classList.add("d-none");
+ }
+}
+
+function isEndpointsFeatureDisabled(error) {
+ const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
+ return message.includes("custom endpoints") && message.includes("is disabled");
+}
+
+function generateId() {
+ if (window.crypto && window.crypto.randomUUID) {
+ return window.crypto.randomUUID();
+ }
+ return `id_${Math.random().toString(36).slice(2)}_${Date.now()}`;
+}
+
+function setElementVisibility(element, isVisible) {
+ if (!element) {
+ return;
+ }
+ element.classList.toggle("d-none", !isVisible);
+}
+
+function formatProviderLabel(provider) {
+ if (provider === "aifoundry") {
+ return "Foundry (classic)";
+ }
+ if (provider === "new_foundry") {
+ return "New Foundry";
+ }
+ return "Azure OpenAI";
+}
+
+function collectSelectedModels(endpoint) {
+ const models = endpoint?.models || [];
+ const selected = models.filter((model) => model?.enabled);
+ if (!selected.length) {
+ return "No models selected";
+ }
+ const names = selected.map((model) => model.displayName || model.deploymentName || model.modelName || "Unnamed");
+ return names.join(", ");
+}
+
+function renderEndpoints() {
+ if (!endpointsTbody) {
+ return;
+ }
+
+ endpointsTbody.innerHTML = "";
+
+ if (!workspaceEndpoints.length) {
+ endpointsTbody.innerHTML = `
+
+ No endpoints configured yet.
+
+ `;
+ return;
+ }
+
+ workspaceEndpoints.forEach((endpoint) => {
+ const row = document.createElement("tr");
+ const selectedModels = collectSelectedModels(endpoint);
+ const statusLabel = endpoint.enabled ? "Enabled" : "Disabled";
+ const statusClass = endpoint.enabled ? "success" : "secondary";
+ const toggleLabel = endpoint.enabled ? "Disable" : "Enable";
+
+ row.innerHTML = `
+
+ ${escapeHtml(endpoint.name || "Unnamed Endpoint")}
+ ${escapeHtml(endpoint.connection?.endpoint || "")}
+
+ ${escapeHtml(formatProviderLabel(endpoint.provider))}
+
+ ${escapeHtml(selectedModels)}
+
+ ${statusLabel}
+
+
+ Edit
+ ${toggleLabel}
+ Delete
+
+
+ `;
+
+ endpointsTbody.appendChild(row);
+ });
+}
+
+function updateAuthVisibility() {
+ const authType = endpointAuthTypeSelect?.value || "managed_identity";
+ const provider = endpointProviderSelect?.value || "aoai";
+ const isApiKey = authType === "api_key";
+ const isFoundry = provider === "aifoundry" || provider === "new_foundry";
+ setElementVisibility(endpointProjectGroup, isFoundry);
+ setElementVisibility(endpointProjectApiVersionGroup, isFoundry);
+ setElementVisibility(endpointOpenAiApiVersionGroup, true);
+ setElementVisibility(endpointSubscriptionGroup, provider === "aoai" && !isApiKey);
+ setElementVisibility(endpointResourceGroup, provider === "aoai" && !isApiKey);
+ setElementVisibility(miTypeGroup, authType === "managed_identity");
+ setElementVisibility(miClientGroup, authType === "managed_identity" && (miTypeSelect?.value === "user_assigned"));
+ setElementVisibility(tenantGroup, authType === "service_principal");
+ setElementVisibility(clientGroup, authType === "service_principal");
+ setElementVisibility(secretGroup, authType === "service_principal");
+ setElementVisibility(apiKeyGroup, authType === "api_key");
+ setElementVisibility(endpointManagementCloudGroup, authType === "service_principal" && isFoundry);
+ setElementVisibility(endpointCustomAuthorityGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom");
+ setElementVisibility(endpointFoundryScopeGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom");
+ setElementVisibility(apiKeyNote, authType === "api_key");
+ setElementVisibility(addModelBtn, authType === "api_key");
+ setElementVisibility(fetchBtn, authType !== "api_key");
+}
+
+function resetModal() {
+ if (endpointIdInput) endpointIdInput.value = "";
+ if (endpointNameInput) endpointNameInput.value = "";
+ if (endpointProviderSelect) endpointProviderSelect.value = "aoai";
+ if (endpointUrlInput) endpointUrlInput.value = "";
+ if (endpointProjectInput) endpointProjectInput.value = "";
+ if (endpointProjectApiVersionInput) endpointProjectApiVersionInput.value = "v1";
+ if (endpointOpenAiApiVersionInput) endpointOpenAiApiVersionInput.value = "2024-05-01-preview";
+ if (endpointSubscriptionInput) endpointSubscriptionInput.value = "";
+ if (endpointResourceGroupInput) endpointResourceGroupInput.value = "";
+ if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = "managed_identity";
+ if (endpointManagementCloudSelect) endpointManagementCloudSelect.value = "public";
+ if (endpointCustomAuthorityInput) endpointCustomAuthorityInput.value = "";
+ if (endpointFoundryScopeInput) endpointFoundryScopeInput.value = "";
+ if (miTypeSelect) miTypeSelect.value = "system_assigned";
+ if (miClientIdInput) miClientIdInput.value = "";
+ if (tenantIdInput) tenantIdInput.value = "";
+ if (clientIdInput) clientIdInput.value = "";
+ if (clientSecretInput) clientSecretInput.value = "";
+ if (apiKeyInput) apiKeyInput.value = "";
+ if (clientSecretInput) clientSecretInput.placeholder = "";
+ if (apiKeyInput) apiKeyInput.placeholder = "";
+
+ modalModels = [];
+ if (modelsListEl) modelsListEl.innerHTML = "Fetch models to begin selection.
";
+
+ updateAuthVisibility();
+}
+
+function openModalForEndpoint(endpoint) {
+ if (!endpointModal) {
+ return;
+ }
+
+ resetModal();
+
+ if (endpoint) {
+ if (endpointIdInput) endpointIdInput.value = endpoint.id || "";
+ if (endpointNameInput) endpointNameInput.value = endpoint.name || "";
+ if (endpointProviderSelect) endpointProviderSelect.value = endpoint.provider || "aoai";
+ if (endpointUrlInput) endpointUrlInput.value = endpoint.connection?.endpoint || "";
+ if (endpointProjectInput) endpointProjectInput.value = endpoint.connection?.project_name || "";
+ if (endpointProjectApiVersionInput) {
+ endpointProjectApiVersionInput.value = endpoint.connection?.project_api_version || endpoint.connection?.api_version || "v1";
+ }
+ if (endpointOpenAiApiVersionInput) {
+ endpointOpenAiApiVersionInput.value = endpoint.connection?.openai_api_version || endpoint.connection?.api_version || "2024-05-01-preview";
+ }
+ if (endpointSubscriptionInput) endpointSubscriptionInput.value = endpoint.management?.subscription_id || "";
+ if (endpointResourceGroupInput) endpointResourceGroupInput.value = endpoint.management?.resource_group || "";
+ if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = endpoint.auth?.type || "managed_identity";
+ if (endpointManagementCloudSelect) endpointManagementCloudSelect.value = endpoint.auth?.management_cloud || "public";
+ if (endpointCustomAuthorityInput) endpointCustomAuthorityInput.value = endpoint.auth?.custom_authority || "";
+ if (endpointFoundryScopeInput) endpointFoundryScopeInput.value = endpoint.auth?.foundry_scope || "";
+ if (miTypeSelect) miTypeSelect.value = endpoint.auth?.managed_identity_type || "system_assigned";
+ if (miClientIdInput) miClientIdInput.value = endpoint.auth?.managed_identity_client_id || "";
+ if (tenantIdInput) tenantIdInput.value = endpoint.auth?.tenant_id || "";
+ if (clientIdInput) clientIdInput.value = endpoint.auth?.client_id || "";
+ if (clientSecretInput) {
+ clientSecretInput.value = endpoint.auth?.client_secret || "";
+ if (!clientSecretInput.value && endpoint.has_client_secret) {
+ clientSecretInput.placeholder = "Stored";
+ }
+ }
+ if (apiKeyInput) {
+ apiKeyInput.value = endpoint.auth?.api_key || "";
+ if (!apiKeyInput.value && endpoint.has_api_key) {
+ apiKeyInput.placeholder = "Stored";
+ }
+ }
+ modalModels = Array.isArray(endpoint.models) ? [...endpoint.models] : [];
+ renderModalModels(modalModels);
+ }
+
+ updateAuthVisibility();
+ endpointModal.show();
+}
+
+function renderModalModels(models) {
+ if (!modelsListEl) {
+ return;
+ }
+
+ if (!models || !models.length) {
+ modelsListEl.innerHTML = "No models loaded yet.
";
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ models.forEach((model) => {
+ const wrapper = document.createElement("div");
+ wrapper.className = "border rounded p-2 mb-2";
+ const deploymentName = model.deploymentName || "";
+ const modelName = model.modelName || "";
+ const displayName = model.displayName || deploymentName;
+ const description = model.description || "";
+ const modelId = model.id || generateId();
+ model.id = modelId;
+
+ wrapper.innerHTML = `
+
+
+ ${escapeHtml(displayName)}
+
+
+
+ Description
+
+
+ `;
+
+ fragment.appendChild(wrapper);
+ });
+
+ modelsListEl.innerHTML = "";
+ modelsListEl.appendChild(fragment);
+}
+
+function collectModalModels() {
+ if (!modelsListEl) {
+ return [];
+ }
+
+ const updated = modalModels.map((model) => ({ ...model }));
+ updated.forEach((model) => {
+ const checkbox = modelsListEl.querySelector(`input[data-model-id="${model.id}"]`);
+ const deploymentInput = modelsListEl.querySelector(`input[data-deployment-name-for="${model.id}"]`);
+ const displayInput = modelsListEl.querySelector(`input[data-display-name-for="${model.id}"]`);
+ const descriptionInput = modelsListEl.querySelector(`textarea[data-description-for="${model.id}"]`);
+ model.enabled = checkbox ? checkbox.checked : model.enabled;
+ model.deploymentName = deploymentInput ? deploymentInput.value.trim() : model.deploymentName;
+ model.displayName = displayInput ? displayInput.value.trim() : model.displayName;
+ model.description = descriptionInput ? descriptionInput.value.trim() : model.description;
+ });
+ return updated;
+}
+
+async function testModelConnection(model) {
+ const payload = buildEndpointPayload();
+ if (!payload || !model?.deploymentName) {
+ showToast("Model deployment name is required for testing.", "warning");
+ return;
+ }
+
+ const requestBody = {
+ ...payload,
+ model: {
+ deploymentName: model.deploymentName
+ }
+ };
+
+ try {
+ const response = await fetch(modelsTestApi, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(requestBody)
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || "Connection test failed.");
+ }
+ showToast("Model connection successful.", "success");
+ } catch (error) {
+ console.error("Model connection failed", error);
+ showToast(error.message || "Model connection failed.", "danger");
+ }
+}
+
+async function fetchModels() {
+ const payload = buildEndpointPayload();
+ if (!payload) {
+ return;
+ }
+
+ modalModels = collectModalModels();
+
+ try {
+ const response = await fetch(modelsFetchApi, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to fetch models.");
+ }
+
+ const models = Array.isArray(data.models) ? data.models : [];
+ const existingMap = new Map();
+ modalModels.forEach((model) => {
+ const key = (model.deploymentName || "").trim().toLowerCase();
+ if (key) {
+ existingMap.set(key, model);
+ }
+ });
+
+ let addedCount = 0;
+ models.forEach((model) => {
+ const deploymentName = (model.deploymentName || model.deployment || "").trim();
+ if (!deploymentName) {
+ return;
+ }
+ const key = deploymentName.toLowerCase();
+ if (existingMap.has(key)) {
+ return;
+ }
+ modalModels.push({
+ id: generateId(),
+ deploymentName,
+ modelName: model.modelName || model.name || "",
+ displayName: deploymentName,
+ description: "",
+ enabled: false,
+ isDiscovered: true
+ });
+ existingMap.set(key, true);
+ addedCount += 1;
+ });
+ renderModalModels(modalModels);
+ showToast(`Fetched ${models.length} models. Added ${addedCount} new.`, "success");
+ } catch (error) {
+ console.error("Model fetch failed", error);
+ showToast(error.message || "Failed to fetch models.", "danger");
+ }
+}
+
+function buildEndpointPayload() {
+ if (!endpointNameInput || !endpointUrlInput || !endpointOpenAiApiVersionInput) {
+ return null;
+ }
+ const endpointId = endpointIdInput?.value.trim() || "";
+ const name = endpointNameInput.value.trim();
+ const endpoint = endpointUrlInput.value.trim();
+ const projectName = endpointProjectInput?.value.trim() || "";
+ const projectApiVersion = endpointProjectApiVersionInput?.value.trim() || "v1";
+ const openAiApiVersion = endpointOpenAiApiVersionInput.value.trim();
+ const provider = endpointProviderSelect?.value || "aoai";
+ const subscriptionId = endpointSubscriptionInput?.value.trim() || "";
+ const resourceGroup = endpointResourceGroupInput?.value.trim() || "";
+ const authType = endpointAuthTypeSelect?.value || "managed_identity";
+
+ if (!name || !endpoint || !openAiApiVersion) {
+ showToast("Endpoint name, URL, and OpenAI API version are required.", "warning");
+ return null;
+ }
+
+ if ((provider === "aifoundry" || provider === "new_foundry") && !projectApiVersion) {
+ showToast("Project API version is required for Foundry project discovery.", "warning");
+ return null;
+ }
+
+ if ((provider === "aifoundry" || provider === "new_foundry") && !endpoint.includes("/api/projects/") && !projectName) {
+ showToast("Foundry project name is required when the endpoint does not include /api/projects/.", "warning");
+ return null;
+ }
+
+ if (provider === "aoai" && authType !== "api_key" && (!subscriptionId || !resourceGroup)) {
+ showToast("Subscription ID and resource group are required for Azure OpenAI model discovery.", "warning");
+ return null;
+ }
+
+ const auth = {
+ type: authType,
+ managed_identity_type: miTypeSelect?.value || "system_assigned",
+ managed_identity_client_id: miClientIdInput?.value.trim() || "",
+ tenant_id: tenantIdInput?.value.trim() || "",
+ client_id: clientIdInput?.value.trim() || "",
+ client_secret: clientSecretInput?.value.trim() || "",
+ api_key: apiKeyInput?.value.trim() || "",
+ management_cloud: endpointManagementCloudSelect?.value || "public",
+ custom_authority: endpointCustomAuthorityInput?.value.trim() || "",
+ foundry_scope: endpointFoundryScopeInput?.value.trim() || ""
+ };
+
+ if (authType === "service_principal" && (!auth.tenant_id || !auth.client_id || !auth.client_secret)) {
+ showToast("Tenant ID, Client ID, and Client Secret are required for service principal auth.", "warning");
+ return null;
+ }
+
+ if ((provider === "aifoundry" || provider === "new_foundry") && authType === "service_principal" && auth.management_cloud === "custom") {
+ if (!auth.custom_authority) {
+ showToast("Custom authority is required when Management Cloud is set to Custom.", "warning");
+ return null;
+ }
+ if (!auth.foundry_scope) {
+ showToast("Foundry scope is required when Management Cloud is set to Custom.", "warning");
+ return null;
+ }
+ }
+
+ if (authType === "api_key" && !auth.api_key) {
+ showToast("API key is required for API key authentication.", "warning");
+ return null;
+ }
+
+ const management = provider === "aoai" ? {
+ subscription_id: subscriptionId,
+ resource_group: resourceGroup
+ } : {};
+
+ const connection = {
+ endpoint,
+ openai_api_version: openAiApiVersion
+ };
+
+ if (provider === "aifoundry" || provider === "new_foundry") {
+ connection.project_api_version = projectApiVersion;
+ if (projectName) {
+ connection.project_name = projectName;
+ }
+ }
+
+ return {
+ id: endpointId,
+ provider,
+ name,
+ connection,
+ management,
+ auth
+ };
+}
+
+function saveEndpoint() {
+ try {
+ const payload = buildEndpointPayload();
+ if (!payload) {
+ return;
+ }
+
+ const models = collectModalModels();
+ const endpointId = endpointIdInput?.value || generateId();
+ const existingEndpoint = workspaceEndpoints.find((endpoint) => endpoint.id === endpointId);
+ const authType = payload.auth?.type || "managed_identity";
+ const hasApiKey = authType === "api_key" && (Boolean(payload.auth?.api_key) || Boolean(existingEndpoint?.has_api_key));
+ const hasClientSecret = authType === "service_principal" && (Boolean(payload.auth?.client_secret) || Boolean(existingEndpoint?.has_client_secret));
+
+ const endpointData = {
+ id: endpointId,
+ name: payload.name,
+ provider: payload.provider,
+ enabled: true,
+ auth: payload.auth,
+ connection: payload.connection,
+ management: payload.management,
+ models,
+ has_api_key: hasApiKey,
+ has_client_secret: hasClientSecret
+ };
+
+ const existingIndex = workspaceEndpoints.findIndex((endpoint) => endpoint.id === endpointId);
+ if (existingIndex >= 0) {
+ workspaceEndpoints[existingIndex] = endpointData;
+ } else {
+ workspaceEndpoints.push(endpointData);
+ }
+
+ persistEndpoints();
+ renderEndpoints();
+ endpointModal.hide();
+ showToast("Endpoint saved successfully.", "success");
+ } catch (error) {
+ console.error("Error saving endpoint", error);
+ showToast(error.message || "Failed to save endpoint.", "danger");
+ }
+}
+
+function persistEndpoints() {
+ fetch(endpointsApi, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ endpoints: workspaceEndpoints })
+ }).catch((error) => {
+ console.error("Failed to save endpoints", error);
+ showToast("Failed to save endpoints.", "danger");
+ });
+}
+
+function toggleEndpoint(endpointId) {
+ const endpoint = workspaceEndpoints.find((item) => item.id === endpointId);
+ if (!endpoint) {
+ return;
+ }
+ endpoint.enabled = !endpoint.enabled;
+ persistEndpoints();
+ renderEndpoints();
+}
+
+function deleteEndpoint(endpointId) {
+ workspaceEndpoints = workspaceEndpoints.filter((item) => item.id !== endpointId);
+ persistEndpoints();
+ renderEndpoints();
+}
+
+function handleTableClick(event) {
+ const target = event.target.closest("button[data-action]");
+ if (!target) {
+ return;
+ }
+ const action = target.dataset.action;
+ const endpointId = target.dataset.endpointId;
+ if (!endpointId) {
+ return;
+ }
+
+ if (action === "edit") {
+ const endpoint = workspaceEndpoints.find((item) => item.id === endpointId);
+ openModalForEndpoint(endpoint);
+ return;
+ }
+ if (action === "toggle") {
+ toggleEndpoint(endpointId);
+ return;
+ }
+ if (action === "delete") {
+ deleteEndpoint(endpointId);
+ }
+}
+
+function addManualModel() {
+ modalModels = collectModalModels();
+ modalModels.push({
+ id: generateId(),
+ deploymentName: "",
+ modelName: "",
+ displayName: "",
+ description: "",
+ enabled: true
+ });
+ renderModalModels(modalModels);
+}
+
+function escapeHtml(value) {
+ if (!value) return "";
+ return value.replace(/[&<>"']/g, (char) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'"
+ }[char] || char));
+}
+
+async function loadEndpoints() {
+ if (!hasEndpointManagementUi()) {
+ return;
+ }
+
+ try {
+ const response = await fetch(endpointsApi);
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(payload.error || "Failed to load endpoints");
+ }
+ workspaceEndpoints = Array.isArray(payload.endpoints) ? payload.endpoints : [];
+ renderEndpoints();
+ } catch (error) {
+ if (isEndpointsFeatureDisabled(error)) {
+ console.info("[WorkspaceEndpoints] Endpoint management is disabled; skipping endpoint load.");
+ workspaceEndpoints = [];
+ renderEndpoints();
+ hideEndpointManagementUi();
+ return;
+ }
+
+ console.error("Failed to load endpoints", error);
+ showToast(error.message || "Failed to load endpoints.", "danger");
+ }
+}
+
+function initialize() {
+ if (!hasEndpointManagementUi()) {
+ return;
+ }
+
+ if (enableMultiEndpointToggle) {
+ enableMultiEndpointToggle.checked = Boolean(window.enableMultiModelEndpoints);
+ }
+ if (endpointsWrapper) {
+ endpointsWrapper.classList.toggle("d-none", !window.enableMultiModelEndpoints);
+ }
+
+ renderEndpoints();
+ loadEndpoints();
+
+ if (addEndpointBtn) {
+ addEndpointBtn.addEventListener("click", () => openModalForEndpoint(null));
+ }
+
+ if (endpointsTbody) {
+ endpointsTbody.addEventListener("click", handleTableClick);
+ }
+
+ if (endpointProviderSelect) {
+ endpointProviderSelect.addEventListener("change", updateAuthVisibility);
+ }
+
+ if (endpointAuthTypeSelect) {
+ endpointAuthTypeSelect.addEventListener("change", updateAuthVisibility);
+ }
+
+ if (endpointManagementCloudSelect) {
+ endpointManagementCloudSelect.addEventListener("change", updateAuthVisibility);
+ }
+
+ if (miTypeSelect) {
+ miTypeSelect.addEventListener("change", updateAuthVisibility);
+ }
+
+ if (fetchBtn) {
+ fetchBtn.addEventListener("click", fetchModels);
+ }
+
+ if (saveBtn) {
+ saveBtn.addEventListener("click", saveEndpoint);
+ }
+
+ if (addModelBtn) {
+ addModelBtn.addEventListener("click", addManualModel);
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initialize);
+} else {
+ initialize();
+}
diff --git a/application/single_app/static/js/workspace/workspace_plugins.js b/application/single_app/static/js/workspace/workspace_plugins.js
index 30fef0d5..67a7a966 100644
--- a/application/single_app/static/js/workspace/workspace_plugins.js
+++ b/application/single_app/static/js/workspace/workspace_plugins.js
@@ -1,10 +1,14 @@
// workspace_plugins.js (refactored to use plugin_common.js and new multi-step modal)
-import { renderPluginsTable, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js';
+import { renderPluginsTable, renderPluginsGrid, ensurePluginsTableInRoot, validatePluginManifest, getErrorMessageFromResponse } from '../plugin_common.js';
import { showToast } from "../chat/chat-toast.js"
+import {
+ setupViewToggle, switchViewContainers, openViewModal
+} from './view-utils.js';
const root = document.getElementById('workspace-plugins-root');
let plugins = [];
let filteredPlugins = [];
+let currentViewMode = 'list';
function renderLoading() {
root.innerHTML = ``;
@@ -14,6 +18,22 @@ function renderError(msg) {
root.innerHTML = `${msg}
`;
}
+function getViewHandlers() {
+ return {
+ onEdit: name => openPluginModal(plugins.find(p => p.name === name)),
+ onDelete: name => deletePlugin(name),
+ onView: name => {
+ const plugin = plugins.find(p => p.name === name);
+ if (plugin) {
+ openViewModal(plugin, 'action', {
+ onEdit: (item) => openPluginModal(item),
+ onDelete: (item) => deletePlugin(item.name)
+ });
+ }
+ }
+ };
+}
+
function filterPlugins(searchTerm) {
if (!searchTerm || !searchTerm.trim()) {
filteredPlugins = plugins;
@@ -26,14 +46,18 @@ function filterPlugins(searchTerm) {
});
}
- // Ensure table template is in place
ensurePluginsTableInRoot();
+ const handlers = getViewHandlers();
renderPluginsTable({
plugins: filteredPlugins,
tbodySelector: '#plugins-table-body',
- onEdit: name => openPluginModal(plugins.find(p => p.name === name)),
- onDelete: name => deletePlugin(name)
+ ...handlers
+ });
+ renderPluginsGrid({
+ plugins: filteredPlugins,
+ containerSelector: '#plugins-grid-view',
+ ...handlers
});
}
@@ -47,12 +71,26 @@ async function fetchPlugins() {
// Ensure table template is in place
ensurePluginsTableInRoot();
+ const handlers = getViewHandlers();
renderPluginsTable({
plugins: filteredPlugins,
tbodySelector: '#plugins-table-body',
- onEdit: name => openPluginModal(plugins.find(p => p.name === name)),
- onDelete: name => deletePlugin(name)
+ ...handlers
+ });
+ renderPluginsGrid({
+ plugins: filteredPlugins,
+ containerSelector: '#plugins-grid-view',
+ ...handlers
+ });
+
+ // Set up view toggle (only once after template is in DOM)
+ setupViewToggle('plugins', 'pluginsViewPreference', (mode) => {
+ currentViewMode = mode;
+ switchViewContainers(mode,
+ document.getElementById('plugins-list-view'),
+ document.getElementById('plugins-grid-view')
+ );
});
// Set up the create action button
@@ -98,9 +136,11 @@ function setupSaveHandler(plugin, modal) {
const formData = window.pluginModalStepper.getFormData();
// Validate with JSON schema
- const valid = await validatePluginManifest(formData);
- if (!valid) {
- window.pluginModalStepper.showError('Validation error: Invalid action data.');
+ const validation = await validatePluginManifest(formData);
+ const validationFailed = validation === false || (validation && validation.valid === false);
+ if (validationFailed) {
+ const message = validation?.errors?.join('\n') || 'Validation error: Invalid action data.';
+ window.pluginModalStepper.showError(message);
return;
}
@@ -137,6 +177,8 @@ function setupSaveHandler(plugin, modal) {
}
async function savePlugin(pluginData, existingPlugin = null) {
+ const payload = existingPlugin?.id ? { ...pluginData, id: existingPlugin.id } : { ...pluginData };
+
// Get all plugins first
const res = await fetch('/api/user/plugins');
@@ -145,11 +187,19 @@ async function savePlugin(pluginData, existingPlugin = null) {
let plugins = await res.json();
// Update or add the plugin
- const existingIndex = plugins.findIndex(p => p.name === pluginData.name);
+ const existingIndex = plugins.findIndex(p => {
+ if (payload.id && p.id === payload.id) {
+ return true;
+ }
+ if (existingPlugin?.name && p.name === existingPlugin.name) {
+ return true;
+ }
+ return p.name === payload.name;
+ });
if (existingIndex >= 0) {
- plugins[existingIndex] = pluginData;
+ plugins[existingIndex] = payload;
} else {
- plugins.push(pluginData);
+ plugins.push(payload);
}
// Save back to server
@@ -160,7 +210,8 @@ async function savePlugin(pluginData, existingPlugin = null) {
});
if (!saveRes.ok) {
- throw new Error('Failed to save action');
+ const errorMessage = await getErrorMessageFromResponse(saveRes, 'Failed to save action');
+ throw new Error(errorMessage);
}
}
diff --git a/application/single_app/static/js/workspace/workspace_plugins_new.js b/application/single_app/static/js/workspace/workspace_plugins_new.js
index 509e53e8..3a213ebb 100644
--- a/application/single_app/static/js/workspace/workspace_plugins_new.js
+++ b/application/single_app/static/js/workspace/workspace_plugins_new.js
@@ -1,5 +1,5 @@
// workspace_plugins.js (refactored to use plugin_common.js and new multi-step modal)
-import { renderPluginsTable, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js';
+import { renderPluginsTable, ensurePluginsTableInRoot, validatePluginManifest, getErrorMessageFromResponse } from '../plugin_common.js';
import { showToast } from "../chat/chat-toast.js"
const root = document.getElementById('workspace-plugins-root');
@@ -68,9 +68,11 @@ function setupSaveHandler(plugin, modal) {
const formData = window.pluginModalStepper.getFormData();
// Validate with JSON schema
- const valid = await validatePluginManifest(formData);
- if (!valid) {
- window.pluginModalStepper.showError('Validation error: Invalid action data.');
+ const validation = await validatePluginManifest(formData);
+ const validationFailed = validation === false || (validation && validation.valid === false);
+ if (validationFailed) {
+ const message = validation?.errors?.join('\n') || 'Validation error: Invalid action data.';
+ window.pluginModalStepper.showError(message);
return;
}
@@ -118,7 +120,8 @@ async function savePlugin(pluginData, existingPlugin = null) {
});
if (!saveRes.ok) {
- throw new Error('Failed to save action');
+ const errorMessage = await getErrorMessageFromResponse(saveRes, 'Failed to save action');
+ throw new Error(errorMessage);
}
}
diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json
index 402304c8..64f91251 100644
--- a/application/single_app/static/json/schemas/agent.schema.json
+++ b/application/single_app/static/json/schemas/agent.schema.json
@@ -57,6 +57,18 @@
"azure_agent_apim_gpt_api_version": {
"type": "string"
},
+ "model_endpoint_id": {
+ "type": "string",
+ "description": "Selected model endpoint ID when using multi-endpoint routing."
+ },
+ "model_id": {
+ "type": "string",
+ "description": "Selected model ID from the multi-endpoint catalog."
+ },
+ "model_provider": {
+ "type": "string",
+ "description": "Model provider for the selected multi-endpoint model (aoai, aifoundry, or new_foundry)."
+ },
"enable_agent_gpt_apim": {
"type": "boolean"
},
@@ -81,7 +93,7 @@
},
"agent_type": {
"type": "string",
- "enum": ["local", "aifoundry"],
+ "enum": ["local", "aifoundry", "new_foundry"],
"description": "Type of agent to instantiate."
},
"instructions": {
@@ -140,6 +152,29 @@
}
}
}
+ },
+ {
+ "if": {
+ "properties": {
+ "agent_type": { "const": "new_foundry" }
+ }
+ },
+ "then": {
+ "required": [
+ "azure_openai_gpt_api_version",
+ "other_settings"
+ ],
+ "properties": {
+ "actions_to_load": {
+ "type": "array",
+ "maxItems": 0,
+ "description": "New Foundry agents manage tools within Azure and must not specify local plugins."
+ },
+ "other_settings": {
+ "$ref": "#/definitions/NewFoundrySettingsWrapper"
+ }
+ }
+ }
}
]
},
@@ -149,6 +184,9 @@
"properties": {
"azure_ai_foundry": {
"$ref": "#/definitions/AzureAIFoundrySettings"
+ },
+ "new_foundry": {
+ "$ref": "#/definitions/NewFoundrySettings"
}
}
},
@@ -175,6 +213,54 @@
}
},
"required": ["agent_id"]
+ },
+ "NewFoundrySettingsWrapper": {
+ "type": "object",
+ "required": ["new_foundry"],
+ "properties": {
+ "new_foundry": {
+ "$ref": "#/definitions/NewFoundrySettings"
+ }
+ }
+ },
+ "NewFoundrySettings": {
+ "type": "object",
+ "additionalProperties": true,
+ "properties": {
+ "application_id": {
+ "type": "string",
+ "description": "Application identifier for the new Foundry experience. Usually application_name:version."
+ },
+ "application_name": {
+ "type": "string",
+ "description": "Application name from the new Foundry portal."
+ },
+ "application_version": {
+ "type": "string",
+ "description": "Application version from the new Foundry portal."
+ },
+ "endpoint": {
+ "type": "string",
+ "description": "Project endpoint root for the new Foundry application."
+ },
+ "project_name": {
+ "type": "string",
+ "description": "Foundry project name when endpoint does not already include /api/projects/."
+ },
+ "responses_api_version": {
+ "type": "string",
+ "description": "API version used for the protocols/openai/responses call."
+ },
+ "activity_api_version": {
+ "type": "string",
+ "description": "Optional API version for the activity protocol endpoint."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Optional helper text for administrators managing new Foundry applications."
+ }
+ },
+ "required": ["application_id", "responses_api_version"]
}
}
}
diff --git a/application/single_app/static/json/schemas/sql_query.definition.json b/application/single_app/static/json/schemas/sql_query.definition.json
index d38a41a8..6903c22a 100644
--- a/application/single_app/static/json/schemas/sql_query.definition.json
+++ b/application/single_app/static/json/schemas/sql_query.definition.json
@@ -1,6 +1,9 @@
{
"$schema": "./plugin.definition.schema.json",
"allowedAuthTypes": [
+ "user",
+ "identity",
+ "servicePrincipal",
"connection_string"
]
}
diff --git a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json
index 9e4f6d34..f7f46ebd 100644
--- a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json
+++ b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json
@@ -3,13 +3,13 @@
"title": "SQL Query Plugin Additional Settings",
"type": "object",
"properties": {
- "connection_string__Secret": {
+ "connection_string": {
"type": "string",
"description": "Database connection string. Required if server/database not provided."
},
"database_type": {
"type": "string",
- "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"],
+ "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"],
"description": "Type of database engine."
},
"server": {
@@ -24,7 +24,7 @@
"type": "string",
"description": "Username for authentication."
},
- "password__Secret": {
+ "password": {
"type": "string",
"description": "Password for authentication."
},
@@ -50,6 +50,6 @@
"description": "Query timeout in seconds."
}
},
- "required": ["database_type", "database"],
+ "required": ["database_type"],
"additionalProperties": false
}
diff --git a/application/single_app/static/json/schemas/sql_schema.definition.json b/application/single_app/static/json/schemas/sql_schema.definition.json
index d38a41a8..6903c22a 100644
--- a/application/single_app/static/json/schemas/sql_schema.definition.json
+++ b/application/single_app/static/json/schemas/sql_schema.definition.json
@@ -1,6 +1,9 @@
{
"$schema": "./plugin.definition.schema.json",
"allowedAuthTypes": [
+ "user",
+ "identity",
+ "servicePrincipal",
"connection_string"
]
}
diff --git a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json
index e97c7b4b..29fb6b3f 100644
--- a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json
+++ b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json
@@ -3,13 +3,13 @@
"title": "SQL Schema Plugin Additional Settings",
"type": "object",
"properties": {
- "connection_string__Secret": {
+ "connection_string": {
"type": "string",
"description": "Database connection string. Required if server/database not provided."
},
"database_type": {
"type": "string",
- "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"],
+ "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"],
"description": "Type of database engine."
},
"server": {
@@ -24,7 +24,7 @@
"type": "string",
"description": "Username for authentication."
},
- "password__Secret": {
+ "password": {
"type": "string",
"description": "Password for authentication."
},
@@ -33,6 +33,6 @@
"description": "ODBC or DB driver name."
}
},
- "required": ["database_type", "database"],
+ "required": ["database_type"],
"additionalProperties": false
}
diff --git a/application/single_app/support_menu_config.py b/application/single_app/support_menu_config.py
new file mode 100644
index 00000000..7fd469d0
--- /dev/null
+++ b/application/single_app/support_menu_config.py
@@ -0,0 +1,661 @@
+# support_menu_config.py
+"""Shared support menu configuration for user-facing latest features."""
+
+from copy import deepcopy
+
+
+_SUPPORT_LATEST_FEATURE_CATALOG = [
+ {
+ 'id': 'guided_tutorials',
+ 'title': 'Guided Tutorials',
+ 'icon': 'bi-signpost-split',
+ 'summary': 'Step-by-step walkthroughs help users discover core chat, workspace, and onboarding flows faster, and each user can now hide the launchers when they no longer need them.',
+ 'details': 'Guided Tutorials add in-product walkthroughs so you can learn the interface in context instead of hunting through menus first. Tutorial launchers are shown by default and can be hidden or restored later from your profile page.',
+ 'why': 'This matters because the fastest way to learn a new workflow is usually inside the workflow itself, with the right controls highlighted as you go, while still letting each user hide the launcher once they are comfortable with the app.',
+ 'guidance': [
+ 'Start with the Chat Tutorial to learn message tools, uploads, prompts, and follow-up workflows.',
+ 'If Personal Workspace is enabled for your environment, open the Workspace Tutorial to learn uploads, filters, tags, prompts, agents, and actions.',
+ 'Tutorial buttons are visible by default. If you prefer a cleaner interface, open your profile page and hide them for your own account.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat Tutorial',
+ 'description': 'Jump to Chat and launch the guided walkthrough from the floating tutorial button.',
+ 'endpoint': 'chats',
+ 'fragment': 'chat-tutorial-launch',
+ 'icon': 'bi-chat-dots',
+ },
+ {
+ 'label': 'Open Workspace Tutorial',
+ 'description': 'Jump to Personal Workspace and launch the workspace walkthrough when that workspace is enabled.',
+ 'endpoint': 'workspace',
+ 'fragment': 'workspace-tutorial-launch',
+ 'icon': 'bi-folder2-open',
+ 'requires_settings': ['enable_user_workspace'],
+ },
+ {
+ 'label': 'Manage Tutorial Visibility',
+ 'description': 'Open your profile page to show or hide the tutorial launch buttons for your account.',
+ 'endpoint': 'profile',
+ 'fragment': 'tutorial-preferences',
+ 'icon': 'bi-person-gear',
+ },
+ ],
+ 'image': 'images/features/guided_tutorials_chat.png',
+ 'image_alt': 'Guided tutorials feature screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/guided_tutorials_chat.png',
+ 'alt': 'Guided chat tutorial screenshot',
+ 'title': 'Guided Chat Tutorial',
+ 'caption': 'Guided walkthrough entry point for the live chat experience.',
+ 'label': 'Chat Tutorial',
+ },
+ {
+ 'path': 'images/features/guided_tutorials_workspace.png',
+ 'alt': 'Workspace guided tutorial screenshot',
+ 'title': 'Guided Workspace Tutorial',
+ 'caption': 'Walkthrough entry point for Personal Workspace uploads, filters, tools, and tags.',
+ 'label': 'Workspace Tutorial',
+ },
+ ],
+ },
+ {
+ 'id': 'background_chat',
+ 'title': 'Background Chat',
+ 'icon': 'bi-bell',
+ 'summary': 'Long-running chat requests can finish in the background while users continue working elsewhere in the app.',
+ 'details': 'Background Chat lets a long-running request keep working after you move away from the chat page.',
+ 'why': 'This matters most for larger uploads and heavier prompts, where waiting on one screen is wasted time and makes the app feel blocked.',
+ 'guidance': [
+ 'Start the request from Chat the same way you normally would.',
+ 'If the request takes longer, you can keep using the app and come back when the completion notification appears.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat',
+ 'description': 'Start a prompt in Chat and let the app notify you when longer work finishes.',
+ 'endpoint': 'chats',
+ 'icon': 'bi-chat-dots',
+ },
+ ],
+ 'image': 'images/features/background_completion_notifications-01.png',
+ 'image_alt': 'Background chat notification screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/background_completion_notifications-01.png',
+ 'alt': 'Background completion notification screenshot',
+ 'title': 'Background Completion Notification',
+ 'caption': 'Notification example showing that a chat response completed after the user moved away.',
+ 'label': 'Completion Notification',
+ },
+ {
+ 'path': 'images/features/background_completion_notifications-02.png',
+ 'alt': 'Background completion deep link screenshot',
+ 'title': 'Notification Deep Link',
+ 'caption': 'Notification detail showing how users can jump back into the finished chat result.',
+ 'label': 'Return to Finished Chat',
+ },
+ ],
+ },
+ {
+ 'id': 'gpt_selection',
+ 'title': 'GPT Selection',
+ 'icon': 'bi-cpu',
+ 'summary': 'Teams can expose better model-selection options so users can choose the best experience for a task.',
+ 'details': 'GPT Selection gives users a clearer way to choose the model that best fits a task when multiple options are available.',
+ 'why': 'That matters because different prompts often need different tradeoffs in speed, cost, or reasoning depth.',
+ 'guidance': [
+ 'Open Chat and look for the model picker in the composer toolbar.',
+ 'Try another model when you need faster output, stronger reasoning, or a different cost profile.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat Model Picker',
+ 'description': 'Go to Chat and jump to the model selector in the composer area.',
+ 'endpoint': 'chats',
+ 'fragment': 'model-select-container',
+ 'icon': 'bi-cpu',
+ },
+ ],
+ 'image': 'images/features/model_selection_multi_endpoint_admin.png',
+ 'image_alt': 'Admin multi-endpoint model management screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/model_selection_multi_endpoint_admin.png',
+ 'alt': 'Admin multi-endpoint model management screenshot',
+ 'title': 'Admin Multi-Endpoint Model Management',
+ 'caption': 'Admin endpoint table showing configured Azure OpenAI and Foundry model endpoints.',
+ 'label': 'Admin Endpoint Table',
+ },
+ {
+ 'path': 'images/features/model_selection_chat_selector.png',
+ 'alt': 'User chat model selector screenshot',
+ 'title': 'User Chat Model Selector',
+ 'caption': 'Chat composer model selector showing multiple available GPT choices.',
+ 'label': 'Chat Model Selector',
+ },
+ ],
+ },
+ {
+ 'id': 'tabular_analysis',
+ 'title': 'Tabular Analysis',
+ 'icon': 'bi-table',
+ 'summary': 'Spreadsheet and table workflows continue to improve for exploration, filtering, and grounded follow-up questions.',
+ 'details': 'Tabular Analysis improves how SimpleChat works with CSV and spreadsheet files for filtering, comparisons, and grounded follow-up questions.',
+ 'why': 'You get the most value after the file is uploaded, because the assistant can reason over the stored rows and columns instead of only whatever is pasted into one message.',
+ 'guidance': [
+ 'Upload your CSV or XLSX to Personal Workspace if it is enabled, or add the file directly to Chat when you want a quicker one-off analysis.',
+ 'If you are updating an existing table, upload the newer file with the same name. You do not need to delete the previous version first.',
+ 'Ask follow-up questions after the upload so the assistant can stay grounded in the stored tabular data.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Upload in Personal Workspace',
+ 'description': 'Jump to the Personal Workspace upload area for a durable tabular file workflow.',
+ 'endpoint': 'workspace',
+ 'fragment': 'upload-area',
+ 'icon': 'bi-upload',
+ 'requires_settings': ['enable_user_workspace'],
+ },
+ {
+ 'label': 'Upload a New Revision',
+ 'description': 'Jump to the same upload area and add the updated file with the same name to create a new revision.',
+ 'endpoint': 'workspace',
+ 'fragment': 'upload-area',
+ 'icon': 'bi-arrow-repeat',
+ 'requires_settings': ['enable_user_workspace'],
+ },
+ {
+ 'label': 'Add a File to Chat',
+ 'description': 'Use Chat when you want to attach a spreadsheet directly to a conversation.',
+ 'endpoint': 'chats',
+ 'fragment': 'choose-file-btn',
+ 'icon': 'bi-paperclip',
+ },
+ ],
+ 'image': 'images/features/tabular_analysis_enhanced_citations.png',
+ 'image_alt': 'Tabular analysis enhanced citations screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/tabular_analysis_enhanced_citations.png',
+ 'alt': 'Tabular analysis enhanced citations screenshot',
+ 'title': 'Tabular Analysis with Enhanced Citations',
+ 'caption': 'Tabular analysis preview showing the improved citation-backed experience for spreadsheet content.',
+ 'label': 'Tabular Analysis Preview',
+ },
+ ],
+ },
+ {
+ 'id': 'citation_improvements',
+ 'title': 'Citation Improvements',
+ 'icon': 'bi-journal-text',
+ 'summary': 'Enhanced citations give users better source traceability, document previews, and history-aware grounding.',
+ 'details': 'Citation Improvements help you see where answers came from and keep grounded evidence available across follow-up questions.',
+ 'why': 'That matters because better citation carry-forward means fewer follow-up turns lose context or force you to rebuild the same evidence chain from scratch.',
+ 'guidance': [
+ 'Stay in the same conversation when you ask follow-up questions so the assistant can reuse the earlier grounded evidence.',
+ 'Open citations or previews when you want to inspect the supporting material behind an answer.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat for Follow-ups',
+ 'description': 'Ask a follow-up in Chat and review how citations stay available across turns.',
+ 'endpoint': 'chats',
+ 'fragment': 'chatbox',
+ 'icon': 'bi-chat-dots',
+ },
+ ],
+ 'image': 'images/features/citation_improvements_history_replay.png',
+ 'image_alt': 'Conversation history citation replay screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/citation_improvements_history_replay.png',
+ 'alt': 'Conversation history citation replay screenshot',
+ 'title': 'Conversation History Citation Replay',
+ 'caption': 'Follow-up chat where prior citation summaries are replayed into the next turn\'s reasoning context.',
+ 'label': 'History Citation Replay',
+ },
+ {
+ 'path': 'images/features/citation_improvements_amplified_results.png',
+ 'alt': 'Citation amplification details screenshot',
+ 'title': 'Citation Amplification Details',
+ 'caption': 'Expanded citation detail showing amplified supporting evidence and fuller artifact-backed results.',
+ 'label': 'Amplified Citation Detail',
+ },
+ ],
+ },
+ {
+ 'id': 'document_versioning',
+ 'title': 'Document Versioning',
+ 'icon': 'bi-files',
+ 'summary': 'Document revision visibility has improved so users can work with the right version of shared content.',
+ 'details': 'Document Versioning keeps same-name uploads organized as revisions so newer files become current without erasing the older record.',
+ 'why': 'That matters because ongoing chats and citations can stay tied to the right version while you continue updating the same document over time.',
+ 'guidance': [
+ 'Upload the updated file with the same name to create a new current revision.',
+ 'You do not need to delete the older file first unless you no longer want to keep its history.',
+ 'Use the workspace document list to confirm which revision is current before you ask more questions about it.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Review Workspace Documents',
+ 'description': 'Open Personal Workspace and review the current document list for revision-aware uploads.',
+ 'endpoint': 'workspace',
+ 'fragment': 'documents-table',
+ 'icon': 'bi-files',
+ 'requires_settings': ['enable_user_workspace'],
+ },
+ {
+ 'label': 'Upload an Updated Version',
+ 'description': 'Jump to the upload area and add the newer file with the same name to create a new revision.',
+ 'endpoint': 'workspace',
+ 'fragment': 'upload-area',
+ 'icon': 'bi-arrow-repeat',
+ 'requires_settings': ['enable_user_workspace'],
+ },
+ ],
+ 'image': 'images/features/document_revision_workspace.png',
+ 'image_alt': 'Document revision workspace screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/document_revision_workspace.png',
+ 'alt': 'Document revision workspace screenshot',
+ 'title': 'Current Revision in Workspace',
+ 'caption': 'Workspace document list showing the current revision state for same-name uploads.',
+ 'label': 'Current Revision View',
+ },
+ {
+ 'path': 'images/features/document_revision_delete_compare.png',
+ 'alt': 'Document revision actions and comparison screenshot',
+ 'title': 'Revision Actions and Comparison',
+ 'caption': 'Version-aware actions such as comparison, analysis of previous revisions, or current-versus-all-versions deletion choices.',
+ 'label': 'Revision Actions',
+ },
+ ],
+ },
+ {
+ 'id': 'summaries_export',
+ 'title': 'Summaries and Export',
+ 'icon': 'bi-file-earmark-arrow-down',
+ 'summary': 'Conversation summaries and export workflows continue to expand for reporting and follow-up sharing.',
+ 'details': 'Summaries and Export features make it easier to capture, reuse, and share the important parts of a chat session.',
+ 'why': 'This matters when a long chat needs a reusable summary, a PDF handoff, or per-message reuse in email, documents, or other downstream workflows.',
+ 'guidance': [
+ 'Open an existing conversation when you want to generate or refresh a summary.',
+ 'Use export options when you need to share the full conversation or reuse a single message outside the app.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat History',
+ 'description': 'Go to Chat and open a conversation with enough content to summarize, export, or reuse.',
+ 'endpoint': 'chats',
+ 'fragment': 'chatbox',
+ 'icon': 'bi-file-earmark-arrow-down',
+ },
+ ],
+ 'image': 'images/features/conversation_summary_card.png',
+ 'image_alt': 'Conversation summary card screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/conversation_summary_card.png',
+ 'alt': 'Conversation summary card screenshot',
+ 'title': 'Conversation Summary Card',
+ 'caption': 'Conversation summary panel preview in the chat experience.',
+ 'label': 'Summary Card',
+ },
+ {
+ 'path': 'images/features/pdf_export_option.png',
+ 'alt': 'PDF export option screenshot',
+ 'title': 'PDF Export Option',
+ 'caption': 'PDF export entry in the conversation export workflow.',
+ 'label': 'PDF Export',
+ },
+ {
+ 'path': 'images/features/per_message_export_menu.png',
+ 'alt': 'Per-message export menu screenshot',
+ 'title': 'Per-Message Export Menu',
+ 'caption': 'Expanded per-message export and reuse actions.',
+ 'label': 'Per-Message Actions',
+ },
+ ],
+ },
+ {
+ 'id': 'agent_operations',
+ 'title': 'Agent Operations',
+ 'icon': 'bi-grid',
+ 'summary': 'Agent creation, organization, and operational controls keep getting smoother for advanced scenarios.',
+ 'details': 'Agent Operations updates improve how teams browse, manage, and reason about reusable AI assistants and their connected actions.',
+ 'why': 'That matters because advanced agent workflows are only useful when users can find the right assistant quickly and trust the connected tools behind it.',
+ 'guidance': [
+ 'Open Personal Workspace if your environment exposes per-user agents and actions.',
+ 'Use list or grid views to browse agents based on whether you want denser detail or quicker scanning.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Personal Workspace',
+ 'description': 'Jump to Personal Workspace, then switch to the Agents tab if agents are enabled in your environment.',
+ 'endpoint': 'workspace',
+ 'icon': 'bi-grid',
+ 'requires_settings': ['enable_user_workspace', 'enable_semantic_kernel', 'per_user_semantic_kernel'],
+ },
+ ],
+ 'image': 'images/features/agent_action_grid_view.png',
+ 'image_alt': 'Agent and action grid view screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/agent_action_grid_view.png',
+ 'alt': 'Agent and action grid view screenshot',
+ 'title': 'Agent and Action Grid View',
+ 'caption': 'Grid browsing experience for agents and actions.',
+ 'label': 'Grid View',
+ },
+ {
+ 'path': 'images/features/sql_test_connection.png',
+ 'alt': 'SQL test connection screenshot',
+ 'title': 'SQL Test Connection',
+ 'caption': 'Inline SQL connection test preview before save.',
+ 'label': 'SQL Test Connection',
+ },
+ ],
+ },
+ {
+ 'id': 'ai_transparency',
+ 'title': 'AI Transparency',
+ 'icon': 'bi-stars',
+ 'summary': 'Thought and reasoning transparency options help users better understand what the assistant is doing.',
+ 'details': 'AI Transparency adds clearer visibility into the assistant\'s in-flight work when your team chooses to expose it.',
+ 'why': 'This helps the app feel less opaque during longer responses because you can see progress instead of guessing whether the request stalled.',
+ 'guidance': [
+ 'Look for Processing Thoughts while a response is being generated in Chat.',
+ 'If you do not see them, your admins may have kept this feature turned off for your environment.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Chat',
+ 'description': 'Go to Chat and watch for processing-state visibility while a response is generated.',
+ 'endpoint': 'chats',
+ 'fragment': 'chatbox',
+ 'icon': 'bi-stars',
+ },
+ ],
+ 'image': 'images/features/thoughts_visibility.png',
+ 'image_alt': 'Processing thoughts visibility screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/thoughts_visibility.png',
+ 'alt': 'Processing thoughts visibility screenshot',
+ 'title': 'Processing Thoughts Visibility',
+ 'caption': 'Processing thoughts state and timing details preview.',
+ 'label': 'Processing Thoughts',
+ },
+ ],
+ },
+ {
+ 'id': 'fact_memory',
+ 'title': 'Fact Memory',
+ 'icon': 'bi-journal-bookmark',
+ 'summary': 'Profile-based memory now distinguishes always-on Instructions from recall-only Facts so the assistant can carry durable preferences and relevant personal context forward more cleanly.',
+ 'details': 'Fact Memory gives each user a compact profile experience for saving Instructions and Facts. Instructions act like durable response preferences, while Facts are recalled only when they are relevant to the current request.',
+ 'why': 'This matters because you no longer need to restate the same preferences or personal context in every conversation, and the chat experience now shows when saved instructions and facts were actually used.',
+ 'guidance': [
+ 'Open your profile page and use Fact Memory when you want to save a lasting preference or a detail about yourself.',
+ 'Choose Instruction for durable preferences like tone, brevity, formatting, or things the assistant should always keep in mind.',
+ 'Choose Fact for details that should only be recalled when relevant, such as who you are, what you prefer, or other personal context.',
+ 'Try a chat prompt like "tell me all about myself" when you want to confirm which saved facts the assistant can recall.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Manage Fact Memory',
+ 'description': 'Open your profile page and jump straight to the Fact Memory section to add, edit, or remove saved instructions and facts.',
+ 'endpoint': 'profile',
+ 'fragment': 'fact-memory-settings',
+ 'icon': 'bi-person-gear',
+ },
+ {
+ 'label': 'Try It in Chat',
+ 'description': 'Open Chat and ask a personal or preference-aware question to see instruction memory and fact recall in action.',
+ 'endpoint': 'chats',
+ 'fragment': 'chatbox',
+ 'icon': 'bi-chat-dots',
+ },
+ ],
+ 'image': 'images/features/fact_memory_management.png',
+ 'image_alt': 'Fact memory management modal screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/facts_memory_view_profile.png',
+ 'alt': 'Profile fact memory section screenshot',
+ 'title': 'Fact Memory on Profile',
+ 'caption': 'Profile page section for adding saved instructions and facts and opening the manager modal.',
+ 'label': 'Profile Entry Point',
+ },
+ {
+ 'path': 'images/features/fact_memory_management.png',
+ 'alt': 'Fact memory management modal screenshot',
+ 'title': 'Manage Fact Memories',
+ 'caption': 'Compact popup manager showing saved instructions and facts with search, paging, edit, and type controls.',
+ 'label': 'Memory Manager',
+ },
+ {
+ 'path': 'images/features/facts_citation_and_thoughts.png',
+ 'alt': 'Chat fact memory thoughts and citations screenshot',
+ 'title': 'Instruction Memory and Fact Recall in Chat',
+ 'caption': 'Chat response showing instruction memory and fact recall surfaced as dedicated thoughts and citations.',
+ 'label': 'Chat Recall',
+ },
+ ],
+ },
+ {
+ 'id': 'deployment',
+ 'title': 'Deployment',
+ 'icon': 'bi-hdd-rack',
+ 'summary': 'Deployment guidance and diagnostics keep improving so admins can roll out changes with less guesswork.',
+ 'details': 'Deployment updates focus on making configuration, startup validation, and operational guidance easier for admins to follow.',
+ 'why': 'For users, this usually shows up as a more stable rollout of new capabilities rather than a brand-new button on the page.',
+ 'guidance': [
+ 'This is mainly an operational improvement managed by your admins.',
+ 'If a newly announced feature is not visible yet, your environment may still be rolling forward to the latest configuration.',
+ ],
+ 'actions': [],
+ 'image': 'images/features/gunicorn_startup_guidance.png',
+ 'image_alt': 'Deployment guidance screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/gunicorn_startup_guidance.png',
+ 'alt': 'Deployment guidance screenshot',
+ 'title': 'Deployment Startup Guidance',
+ 'caption': 'Startup guidance that helps admins configure the app runtime more predictably.',
+ 'label': 'Deployment Guidance',
+ },
+ ],
+ },
+ {
+ 'id': 'redis_key_vault',
+ 'title': 'Redis and Key Vault',
+ 'icon': 'bi-key',
+ 'summary': 'Caching and secret-management setup guidance has expanded for more secure and predictable operations.',
+ 'details': 'Redis and Key Vault improvements make it easier for teams to configure caching and secret storage patterns correctly.',
+ 'why': 'For users, the practical outcome is usually reliability and performance, with fewer environment-level issues caused by secret or cache misconfiguration.',
+ 'guidance': [
+ 'This is another behind-the-scenes improvement mostly managed by your admins.',
+ 'You may notice it indirectly through smoother repeated access patterns or fewer environment issues.',
+ ],
+ 'actions': [],
+ 'image': 'images/features/redis_key_vault.png',
+ 'image_alt': 'Redis and Key Vault screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/redis_key_vault.png',
+ 'alt': 'Redis and Key Vault screenshot',
+ 'title': 'Redis Key Vault Configuration',
+ 'caption': 'Redis authentication with Key Vault secret name preview.',
+ 'label': 'Redis Key Vault',
+ },
+ ],
+ },
+ {
+ 'id': 'send_feedback',
+ 'title': 'Send Feedback',
+ 'icon': 'bi-envelope-paper',
+ 'summary': 'End users can prepare bug reports and feature requests for their SimpleChat admins directly from the Support menu.',
+ 'details': 'Send Feedback opens a guided, text-only email draft workflow so you can report issues or request improvements without leaving the app.',
+ 'why': 'That gives your admins a cleaner starting point for triage than a vague message without context or reproduction details.',
+ 'guidance': [
+ 'Choose Bug Report when something is broken, confusing, or behaving differently than you expected.',
+ 'Choose Feature Request when you want a new workflow, capability, or quality-of-life improvement.',
+ 'Your draft is addressed to the internal support recipient configured by your admins.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Open Send Feedback',
+ 'description': 'Go straight to the Support feedback page and prepare a structured email draft.',
+ 'endpoint': 'support_send_feedback',
+ 'icon': 'bi-envelope-paper',
+ 'requires_settings': ['enable_support_send_feedback'],
+ },
+ ],
+ 'image': 'images/features/support_menu_entry.png',
+ 'image_alt': 'Support menu entry showing Send Feedback access',
+ 'images': [
+ {
+ 'path': 'images/features/support_menu_entry.png',
+ 'alt': 'Support menu entry screenshot',
+ 'title': 'Send Feedback Entry Point',
+ 'caption': 'Support menu entry showing where Send Feedback lives for end users.',
+ 'label': 'Support Entry Point',
+ },
+ ],
+ },
+ {
+ 'id': 'support_menu',
+ 'title': 'Support Menu',
+ 'icon': 'bi-life-preserver',
+ 'summary': 'Admins can surface a dedicated Support menu in navigation with Latest Features and Send Feedback entries for end users.',
+ 'details': 'Support Menu configuration lets admins rename the menu, choose the internal feedback recipient, and decide which user-facing release notes are shared.',
+ 'why': 'That matters because new capabilities are easier to discover when help, feature announcements, and feedback all live in one predictable place.',
+ 'guidance': [
+ 'Use Latest Features when you want a curated explanation of what changed and why it matters.',
+ 'Use Send Feedback when you want to tell your admins what is missing, confusing, or especially helpful.',
+ ],
+ 'actions': [
+ {
+ 'label': 'Browse Latest Features',
+ 'description': 'Refresh this page later when you want to review other recently shared updates.',
+ 'endpoint': 'support_latest_features',
+ 'icon': 'bi-life-preserver',
+ },
+ {
+ 'label': 'Open Send Feedback',
+ 'description': 'Go from Support directly into the structured feedback workflow when that destination is enabled.',
+ 'endpoint': 'support_send_feedback',
+ 'icon': 'bi-envelope-paper',
+ 'requires_settings': ['enable_support_send_feedback'],
+ },
+ ],
+ 'image': 'images/features/support_menu_entry.png',
+ 'image_alt': 'Support menu entry screenshot',
+ 'images': [
+ {
+ 'path': 'images/features/support_menu_entry.png',
+ 'alt': 'Support menu entry screenshot',
+ 'title': 'User Support Menu Entry',
+ 'caption': 'User-facing Support menu entry exposing Latest Features and Send Feedback.',
+ 'label': 'Support Menu Entry',
+ },
+ ],
+ },
+]
+
+
+def _setting_enabled(settings, key):
+ """Return True when the named setting is enabled."""
+ value = (settings or {}).get(key, False)
+ if isinstance(value, str):
+ return value.strip().lower() == 'true'
+ return bool(value)
+
+
+def _action_enabled(action, settings):
+ """Return True when an action should be exposed for the current settings."""
+ required_settings = action.get('requires_settings', [])
+ return all(_setting_enabled(settings, setting_key) for setting_key in required_settings)
+
+
+def _normalize_feature_media(feature):
+ """Ensure every visible feature exposes at least one image entry for the template."""
+ images = feature.get('images') or []
+ if images:
+ if not feature.get('image'):
+ feature['image'] = images[0].get('path')
+ feature['image_alt'] = images[0].get('alt', '')
+ return
+
+ image_path = feature.get('image')
+ if not image_path:
+ return
+
+ feature['images'] = [
+ {
+ 'path': image_path,
+ 'alt': feature.get('image_alt') or f"{feature.get('title', 'Feature')} screenshot",
+ 'title': feature.get('title', 'Feature Preview'),
+ 'caption': feature.get('summary', ''),
+ 'label': feature.get('title', 'Preview'),
+ }
+ ]
+
+
+def get_support_latest_feature_catalog():
+ """Return a copy of the support latest-features catalog."""
+ return deepcopy(_SUPPORT_LATEST_FEATURE_CATALOG)
+
+
+def get_default_support_latest_features_visibility():
+ """Return default visibility for each user-facing latest feature."""
+ defaults = {item['id']: True for item in _SUPPORT_LATEST_FEATURE_CATALOG}
+ defaults['deployment'] = False
+ defaults['redis_key_vault'] = False
+ return defaults
+
+
+def normalize_support_latest_features_visibility(raw_visibility):
+ """Normalize persisted latest-feature visibility to the current catalog."""
+ defaults = get_default_support_latest_features_visibility()
+ if not isinstance(raw_visibility, dict):
+ return defaults
+
+ normalized = defaults.copy()
+ for feature_id in defaults:
+ if feature_id in raw_visibility:
+ normalized[feature_id] = bool(raw_visibility.get(feature_id))
+
+ return normalized
+
+
+def get_visible_support_latest_features(settings):
+ """Return the subset of latest-feature entries enabled for end users."""
+ normalized_visibility = normalize_support_latest_features_visibility(
+ (settings or {}).get('support_latest_features_visibility', {})
+ )
+ visible_items = []
+
+ for item in _SUPPORT_LATEST_FEATURE_CATALOG:
+ if normalized_visibility.get(item['id'], True):
+ visible_item = deepcopy(item)
+ visible_item['actions'] = [
+ action for action in visible_item.get('actions', [])
+ if _action_enabled(action, settings)
+ ]
+ _normalize_feature_media(visible_item)
+ visible_items.append(visible_item)
+
+ return visible_items
+
+
+def has_visible_support_latest_features(settings):
+ """Return True when at least one latest-feature entry is enabled for users."""
+ normalized_visibility = normalize_support_latest_features_visibility(
+ (settings or {}).get('support_latest_features_visibility', {})
+ )
+ return any(normalized_visibility.values())
\ No newline at end of file
diff --git a/application/single_app/swagger_wrapper.py b/application/single_app/swagger_wrapper.py
index 564973a9..ce4a358a 100644
--- a/application/single_app/swagger_wrapper.py
+++ b/application/single_app/swagger_wrapper.py
@@ -1160,7 +1160,7 @@ def _extract_file_tag(view_func) -> str:
# Fallback for other module names
tag_name = ' '.join(word.capitalize() for word in module_name.split('_'))
return f"📄 {tag_name}"
- except:
+ except Exception as ex:
return "📄 Unknown Module"
def _extract_tags_from_route_path(route_path: str) -> List[str]:
diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html
index 52f95cdc..398e930c 100644
--- a/application/single_app/templates/_agent_examples_modal.html
+++ b/application/single_app/templates/_agent_examples_modal.html
@@ -92,7 +92,7 @@ Agent Template
Additional Settings
@@ -427,7 +427,12 @@
Agent Template
detailDescription.textContent = template.description || template.helper_text || 'No description provided.';
}
if (detailInstructions) {
- detailInstructions.textContent = template.instructions || '';
+ const rawInstructions = template.instructions || '';
+ if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined' && rawInstructions) {
+ detailInstructions.innerHTML = DOMPurify.sanitize(marked.parse(rawInstructions));
+ } else {
+ detailInstructions.textContent = rawInstructions;
+ }
}
if (detailSettingsWrapper && detailSettings) {
if (template.additional_settings) {
diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html
index 80f068ca..ef7fefc5 100644
--- a/application/single_app/templates/_agent_modal.html
+++ b/application/single_app/templates/_agent_modal.html
@@ -1,5 +1,5 @@
-
+
Choose the provider for this agent. Foundry agents cannot attach local actions.
@@ -83,131 +88,177 @@
Basic Information
Model & Connection
-
+
-
- Azure AI Foundry agents use Azure-managed tools; local actions are disabled for this agent type.
+
+ Foundry-managed tools are used for this agent type and local actions are disabled.
-
Azure AI Foundry Endpoint *
-
+
Foundry Endpoint
+
+ Select a configured endpoint...
+
+
Endpoints come from global and workspace configurations.
+
+
+
+ Fetch Agents
+
+
-
Azure AI Foundry API Version *
-
+
Foundry Agent
+
+ Select an agent...
+
+
Select a classic Foundry agent to import its identity.
- Foundry Deployment / Project *
-
+ Foundry Project Endpoint *
+
- Foundry Agent ID *
-
+ Foundry Project Name *
+
+
+
+ Classic Foundry API Version *
+
+
+
+
+ Foundry Agent ID *
+
+
+
+
+
+
Responses API Version *
+
+
This is populated from the selected endpoint or fetch response when available, but you can override it if your Foundry project requires a different supported Responses API version.
+
+
+
+
+
Application Name
+
+
Fetch an application above to populate the published version automatically. The runtime invokes the application by name, so you do not need to type a version manually.
+
+
+
Activity Protocol API Version
+
+
Optional for now. Phase 1 uses the Responses endpoint; activity polling can be added later.
+
Admin Notes (optional)
-
+
-
+
Select Model *
Loading models...
+
+
+
-
-
-
Custom Connection
-
-
-
-
-
Override global model connection settings
-
-
-
-
-
- Note: If a value is not provided for an inheritable property, it will be inherited from the selected model.
-
-
-
-
Enable APIM Integration
+ {% if not settings.enable_multi_model_endpoints %}
+
+
-
-
-
- Azure OpenAI GPT Endpoint
- Inheritable
-
-
-
- Azure OpenAI GPT Key
- Inheritable
-
-
-
- GPT Deployment
- Inheritable
-
-
-
- GPT API Version
- Inheritable
-
-
-
-
Reasoning Effort
-
Optional
-
- Inherit from model default
- None - Fastest responses
- Minimal - Light reasoning
- Low - Balanced (recommended)
- Medium - Enhanced thinking
- High - Maximum analysis
-
-
Only applies to models that support reasoning (e.g., gpt-5, o1, o3)
-
-
-
-
-
-
APIM GPT Endpoint
-
Inheritable
-
+
+
+
+
+ Note: If a value is not provided for an inheritable property, it will be inherited from the selected model.
-
-
APIM Subscription Key
-
Inheritable
-
+
+
+
Enable APIM Integration
+
+
+
+
-
-
APIM Deployment
-
Inheritable
-
+
+
+
+ Azure OpenAI GPT Endpoint
+ Inheritable
+
+
+
+ Azure OpenAI GPT Key
+ Inheritable
+
+
+
+ GPT Deployment
+ Inheritable
+
+
+
+ GPT API Version
+ Inheritable
+
+
+
+
Reasoning Effort
+
Optional
+
+ Inherit from model default
+ None - Fastest responses
+ Minimal - Light reasoning
+ Low - Balanced (recommended)
+ Medium - Enhanced thinking
+ High - Maximum analysis
+
+
Only applies to models that support reasoning (e.g., gpt-5, o1, o3)
+
-
-
APIM API Version
-
Inheritable
-
+
+
-
+ {% endif %}
Instructions
- Instructions are managed in Azure AI Foundry.
+ Instructions are managed in Foundry for this agent type.
Agent Instructions *
@@ -222,7 +273,7 @@
Instructions
- Actions are not available for Azure AI Foundry agents. Foundry-managed tools are used automatically.
+ Actions are not available for Foundry agents. Foundry-managed tools are used automatically.
@@ -257,7 +308,7 @@
Available Actions
-
+
@@ -270,7 +321,7 @@
Available Actions
-
+
@@ -287,11 +338,11 @@
Available Actions
-
+
-
+
@@ -325,27 +376,32 @@
Available Actions
Advanced Settings
-
-
Additional Settings (JSON)
-
-
Optional additional configuration settings for this agent in JSON format.
+
+ Advanced options are managed by Foundry and are not available for Foundry agents.
-
-
-
Show Agent Power User Settings
-
-
-
-
+
+
+
Additional Settings (JSON)
+
+
Optional additional configuration settings for this agent in JSON format.
-
-
Max Completion Tokens
-
-
- Specify the maximum number of tokens the model can generate in a single response.
- Set to -1 to use the model's default limit.
- Setting a higher cap only allows the model to generate longer answers—it does not force the extra tokens.
- Use this control mainly to keep answers concise or to shorten responses for sensitive channels.
+
+
+
Show Agent Power User Settings
+
+
+
+
+
+
+
Max Completion Tokens
+
+
+ Specify the maximum number of tokens the model can generate in a single response.
+ Set to -1 to use the model's default limit.
+ Setting a higher cap only allows the model to generate longer answers—it does not force the extra tokens.
+ Use this control mainly to keep answers concise or to shorten responses for sensitive channels.
+
diff --git a/application/single_app/templates/_multiendpoint_modal.html b/application/single_app/templates/_multiendpoint_modal.html
new file mode 100644
index 00000000..3c41dee2
--- /dev/null
+++ b/application/single_app/templates/_multiendpoint_modal.html
@@ -0,0 +1,133 @@
+
+
diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html
index 3af18019..0a5b6f35 100644
--- a/application/single_app/templates/_plugin_modal.html
+++ b/application/single_app/templates/_plugin_modal.html
@@ -1,6 +1,7 @@
-
+
@@ -347,7 +348,7 @@
API Information
Password *
-
+
@@ -360,7 +361,7 @@
API Information
Client Secret *
-
+
Tenant ID *
@@ -425,21 +426,46 @@
API Information
+
+
+
+
+
+ Test Connection
+
+
+
+
+
+
+
+
Advanced
+
Advanced settings are typically not required. Expand below if you need to customize metadata or additional fields.
-
Metadata (JSON)
-
-
Optional metadata for this action.
+
+ Show Advanced Settings
+
+
-
-
Additional Fields (JSON)
-
-
Additional configuration fields specific to this action type.
+
+
+
Metadata (JSON)
+
+
Optional metadata for this action.
+
+
+
Additional Fields (JSON)
+
+
Additional configuration fields specific to this action type.
+
@@ -656,6 +682,7 @@
Save Action
+
@@ -777,6 +804,15 @@
background-color: #f8f9fa;
}
+/* Advanced toggle chevron animation */
+#plugin-advanced-toggle-icon {
+ transition: transform 0.3s ease;
+}
+#plugin-advanced-collapse.show ~ .mb-3 #plugin-advanced-toggle-icon,
+[aria-expanded="true"] #plugin-advanced-toggle-icon {
+ transform: rotate(180deg);
+}
+
.sql-connection-config,
.sql-auth-config {
background-color: white;
diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html
index a0bceee8..d53586cf 100644
--- a/application/single_app/templates/_sidebar_nav.html
+++ b/application/single_app/templates/_sidebar_nav.html
@@ -1,5 +1,9 @@
{# Sidebar Navigation Partial #}
-
@@ -164,6 +233,71 @@
Request Details
+{% if has_agent_template_admin_access %}
+
+{% endif %}
{% endblock %}
{% block scripts %}
@@ -666,4 +800,7 @@
Request Details
}
});
+{% if has_agent_template_admin_access %}
+
+{% endif %}
{% endblock %}
diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html
index fa8d060f..16028a63 100644
--- a/application/single_app/templates/base.html
+++ b/application/single_app/templates/base.html
@@ -35,6 +35,7 @@
+
@@ -63,7 +64,7 @@
}
/* Sidebar padding for main content when sidebar is enabled */
.sidebar-padding {
- padding-left: 260px;
+ padding-left: var(--sidebar-width, 260px);
padding-top: 0 !important;
}
@@ -137,6 +138,47 @@
margin-bottom: 10px;
}
+ /* SimpleMDE uses Font Awesome class names, but the app does not ship that font. */
+ .editor-toolbar a.fa-bold:before,
+ .editor-toolbar a.fa-italic:before,
+ .editor-toolbar a.fa-strikethrough:before,
+ .editor-toolbar a.fa-header:before,
+ .editor-toolbar a.fa-code:before,
+ .editor-toolbar a.fa-quote-left:before,
+ .editor-toolbar a.fa-list-ul:before,
+ .editor-toolbar a.fa-list-ol:before,
+ .editor-toolbar a.fa-eraser:before,
+ .editor-toolbar a.fa-link:before,
+ .editor-toolbar a.fa-picture-o:before,
+ .editor-toolbar a.fa-table:before,
+ .editor-toolbar a.fa-minus:before,
+ .editor-toolbar a.fa-eye:before,
+ .editor-toolbar a.fa-columns:before,
+ .editor-toolbar a.fa-arrows-alt:before,
+ .editor-toolbar a.fa-question-circle:before {
+ font-family: var(--bs-font-sans-serif, Arial, sans-serif);
+ font-size: 13px;
+ font-weight: 700;
+ }
+
+ .editor-toolbar a.fa-bold:before { content: "B"; }
+ .editor-toolbar a.fa-italic:before { content: "I"; font-style: italic; }
+ .editor-toolbar a.fa-strikethrough:before { content: "S"; text-decoration: line-through; }
+ .editor-toolbar a.fa-header:before { content: "#"; }
+ .editor-toolbar a.fa-code:before { content: ">"; font-size: 11px; }
+ .editor-toolbar a.fa-quote-left:before { content: "\201C"; }
+ .editor-toolbar a.fa-list-ul:before { content: "\2022"; }
+ .editor-toolbar a.fa-list-ol:before { content: "1."; }
+ .editor-toolbar a.fa-eraser:before { content: "\232B"; }
+ .editor-toolbar a.fa-link:before { content: "\2197"; }
+ .editor-toolbar a.fa-picture-o:before { content: "\25A3"; }
+ .editor-toolbar a.fa-table:before { content: "\25A6"; }
+ .editor-toolbar a.fa-minus:before { content: "\2014"; }
+ .editor-toolbar a.fa-eye:before { content: "P"; }
+ .editor-toolbar a.fa-columns:before { content: "\2194"; }
+ .editor-toolbar a.fa-arrows-alt:before { content: "\2922"; }
+ .editor-toolbar a.fa-question-circle:before { content: "?"; }
+
.chat-container {
max-height: calc(100vh - 56px);
display: flex;
@@ -347,16 +389,52 @@
+
+ {% if session.get('user') %}
+
+
+ {% endif %}
{% if app_settings.enable_user_agreement %}
{% endif %}
{% block scripts %}{% endblock %}
+ {% if session.get('user') %}
+
+
+
+
+
+
{{ app_settings.idle_warning_message | default("You've been inactive for a while.") }}
+
You will be signed out in 60 seconds unless you continue your session.
+
+
+
+
+
+ {% endif %}
+
{% if app_settings.enable_user_agreement %}
@@ -372,8 +450,8 @@
Please read and accept the following agreement before uploading files:
-
+
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html
index b6c212cc..f47936a8 100644
--- a/application/single_app/templates/chats.html
+++ b/application/single_app/templates/chats.html
@@ -291,9 +291,9 @@
-
+
- {% endif %}
+
{% if settings.per_user_semantic_kernel and settings.enable_semantic_kernel %}
@@ -730,30 +922,109 @@
Personal Workspace
-
- Display Name Description Actions
-
-
-
-
+
+
+
+ Display Name Description Actions
+
+
+
+
+
+
+
-
{% else %}
Custom User Actions are currently disabled. Please ask your admin to enable actions for your workspace.
{% endif %}
+
+
+
+ {% if settings.allow_user_custom_endpoints and settings.enable_multi_model_endpoints %}
+
+
+
+
+
+
Workspace Model Endpoints
+
Manage personal model endpoints used by your workspace agents. Global endpoints are managed by admins.
+
+
+ Multi-endpoint management is controlled by your admin
+
+
+
+
Your Endpoints
+
+ Add Endpoint
+
+
+
+
+
+
+ Name
+ Provider
+ Selected Models
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %}
{% endif %}
+ {% include '_multiendpoint_modal.html' %}
+
+
+
Personal Workspace
tabindex="-1"
aria-labelledby="promptModalLabel"
aria-hidden="true"
+ autocomplete="off"
+ data-lpignore="true"
+ data-1p-ignore="true"
+ data-bwignore="true"
>