diff --git a/.github/instructions/html-lang.instructions.md b/.github/instructions/html-lang.instructions.md new file mode 100644 index 00000000..b2537c6f --- /dev/null +++ b/.github/instructions/html-lang.instructions.md @@ -0,0 +1,23 @@ +--- +applyTo: '**/*.html' +--- + +# HTML Language Guide + +- Use 4 spaces per indentation level. No tabs. + +- Use double quotes for all HTML attributes. Ex: `
` + +- Self-closing tags should include the trailing slash. Ex: `` + +- Use semantic HTML5 elements where appropriate. Ex: `
`, `
-
Function Result:
-

+              
+
Function Result:
+
+
+
+

             
@@ -336,109 +547,62 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) { document.body.appendChild(modalContainer); } - // Update the content const toolNameEl = document.getElementById("agent-tool-name"); const toolArgsEl = document.getElementById("agent-tool-args"); const toolResultEl = document.getElementById("agent-tool-result"); + const toolResultSummaryEl = document.getElementById("agent-tool-result-summary"); + const toolResultActionsEl = document.getElementById("agent-tool-result-actions"); const toolSourceEl = document.getElementById("agent-tool-source"); const toolUrlEl = document.getElementById("agent-tool-url"); const toolUrlMetaEl = document.getElementById("agent-tool-url-meta"); - if (toolNameEl) { - toolNameEl.textContent = toolName || "Unknown"; - } - - let parsedArgs = null; - if (toolArgsEl) { - // Handle empty or no parameters more gracefully - let argsContent = ""; - + const artifactId = options.artifactId || ""; + const conversationId = options.conversationId + || window.chatConversations?.getCurrentConversationId?.() + || window.currentConversationId + || ""; + let citationPayload = { + tool_name: toolName, + function_arguments: toolArgs, + function_result: toolResult, + }; + + if (artifactId && conversationId) { + showLoadingIndicator(); try { - if (!toolArgs || toolArgs === "" || toolArgs === "{}") { - argsContent = "No parameters required"; - } else { - parsedArgs = JSON.parse(toolArgs); - // Check if it's an empty object - if (typeof parsedArgs === 'object' && Object.keys(parsedArgs).length === 0) { - argsContent = "No parameters required"; - } else { - argsContent = JSON.stringify(parsedArgs, null, 2); - } - } - } catch (e) { - // If it's not valid JSON, check if it's an object representation - if (toolArgs === "[object Object]" || !toolArgs || toolArgs.trim() === "") { - argsContent = "No parameters required"; - } else { - argsContent = toolArgs; + const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId); + if (hydratedCitation && typeof hydratedCitation === "object") { + citationPayload = hydratedCitation; } - } - - // Add truncation with expand/collapse if content is long - if (argsContent.length > 300 && argsContent !== "No parameters required") { - const truncatedContent = argsContent.substring(0, 300); - const remainingContent = argsContent.substring(300); - - toolArgsEl.innerHTML = ` -
- ${escapeHtml(truncatedContent)} - -
- `; - } else { - toolArgsEl.textContent = argsContent; + } catch (error) { + console.warn("Failed to hydrate agent citation artifact, using compact payload.", error); + } finally { + hideLoadingIndicator(); } } - - if (toolResultEl) { - // Handle result formatting and truncation with expand/collapse - let resultContent = ""; - let parsedResult = null; - - try { - if (!toolResult || toolResult === "" || toolResult === "{}") { - resultContent = "No result"; - } else if (toolResult === "[object Object]") { - resultContent = "No result data available"; - } else { - // Try to parse as JSON first - try { - parsedResult = JSON.parse(toolResult); - resultContent = JSON.stringify(parsedResult, null, 2); - } catch (parseError) { - // If not JSON, treat as string - resultContent = toolResult; - } - } - } catch (e) { - resultContent = toolResult || "No result"; - } + const parsedArgs = parseAgentCitationValue(citationPayload.function_arguments ?? toolArgs); + const parsedResult = parseAgentCitationValue(citationPayload.function_result ?? toolResult); + activeAgentCitationState = { + rowMode: "preview", + parsedArgs, + parsedResult, + }; + + if (toolNameEl) { + toolNameEl.textContent = citationPayload.tool_name || toolName || "Unknown"; + } + + if (toolArgsEl) { + toolArgsEl.textContent = parsedArgs === null + ? "No parameters required" + : prettyPrintAgentCitationValue(parsedArgs); + } + + if (toolResultEl && toolResultSummaryEl && toolResultActionsEl) { const citationDetails = extractAgentCitationDetails(parsedResult || parsedArgs); updateAgentCitationSource(toolSourceEl, toolUrlEl, toolUrlMetaEl, citationDetails); - - // Add truncation with expand/collapse if content is long - if (resultContent.length > 300) { - const truncatedContent = resultContent.substring(0, 300); - const remainingContent = resultContent.substring(300); - - toolResultEl.innerHTML = ` -
- ${escapeHtml(truncatedContent)} - -
- `; - } else { - toolResultEl.textContent = resultContent; - } + renderAgentCitationResult(toolResultEl, toolResultSummaryEl, toolResultActionsEl); } const modal = new bootstrap.Modal(modalContainer); @@ -609,6 +773,7 @@ if (chatboxEl) { } const { docId, pageNumber } = parseDocIdAndPage(citationId); + const sheetName = target.getAttribute("data-sheet-name"); // Safety check: Ensure docId and pageNumber were parsed correctly if (!docId || !pageNumber) { @@ -649,7 +814,7 @@ if (chatboxEl) { if (attemptEnhanced) { // console.log(`Attempting Enhanced Citation for ${docId}, page/timestamp ${pageNumber}, citationId ${citationId}`); // Use new enhanced citation system that supports multiple file types - showEnhancedCitationModal(docId, pageNumber, citationId); + showEnhancedCitationModal(docId, pageNumber, citationId, sheetName); } else { // console.log(`Fetching Text Citation for ${citationId}`); // Use text citation if globally disabled OR explicitly disabled for this doc OR if parsing failed earlier @@ -662,6 +827,10 @@ if (chatboxEl) { const toolName = target.getAttribute("data-tool-name"); const toolArgs = target.getAttribute("data-tool-args"); const toolResult = target.getAttribute("data-tool-result"); + const artifactId = target.getAttribute("data-artifact-id"); + const conversationId = target.getAttribute("data-conversation-id") + || window.chatConversations?.getCurrentConversationId?.() + || window.currentConversationId; if (!toolName) { console.warn("Agent citation link clicked but data-tool-name is missing."); @@ -669,7 +838,10 @@ if (chatboxEl) { return; } - showAgentCitationModal(toolName, toolArgs, toolResult); + void showAgentCitationModal(toolName, toolArgs, toolResult, { + artifactId, + conversationId, + }); } else if (target && target.matches("a.file-link")) { // Keep existing file link logic event.preventDefault(); @@ -700,42 +872,4 @@ function escapeHtml(text) { div.textContent = text; return div.innerHTML; } - -// Global function to toggle result expansion (called from inline onclick) -window.toggleResultExpansion = function(button) { - const resultContent = button.closest('.result-content'); - const remaining = resultContent.querySelector('.result-remaining'); - const icon = button.querySelector('i'); - - if (remaining.style.display === 'none') { - // Expand - remaining.style.display = 'inline'; - icon.className = 'bi bi-chevron-up'; - button.title = 'Show less'; - } else { - // Collapse - remaining.style.display = 'none'; - icon.className = 'bi bi-chevron-down'; - button.title = 'Show more'; - } -}; - -// Global function to toggle arguments expansion (called from inline onclick) -window.toggleArgsExpansion = function(button) { - const argsContent = button.closest('.args-content'); - const remaining = argsContent.querySelector('.args-remaining'); - const icon = button.querySelector('i'); - - if (remaining.style.display === 'none') { - // Expand - remaining.style.display = 'inline'; - icon.className = 'bi bi-chevron-up'; - button.title = 'Show less'; - } else { - // Collapse - remaining.style.display = 'none'; - icon.className = 'bi bi-chevron-down'; - button.title = 'Show more'; - } -}; // --------------------------------------- \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index 19851bae..c8438617 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -75,7 +75,7 @@ export async function showConversationDetails(conversationId) { * @returns {string} HTML string */ function renderConversationMetadata(metadata, conversationId) { - const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [] } = metadata; + const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [], summary = null } = metadata; // Organize tags by category const tagsByCategory = { @@ -97,6 +97,18 @@ function renderConversationMetadata(metadata, conversationId) { // Build HTML sections let html = `
+ +
+
+
+
Summary
+ ${summary ? `Generated ${formatDate(summary.generated_at)}${summary.model_deployment ? ` · ${summary.model_deployment}` : ''}` : ''} +
+
+ ${renderSummaryContent(summary, conversationId)} +
+
+
@@ -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)}

+
+ +
+ `; + } + + // Build model options from the global model-select dropdown + const modelOptions = getAvailableModelOptions(); + return ` +

No summary has been generated for this conversation yet.

+
+ + +
+ `; +} + +/** + * 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 ''; + } + let options = ''; + for (const opt of globalSelect.options) { + options += ``; + } + return options || ''; +} + +/** + * 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 ``; + }) + .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 += ``; + } + 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 += ``; + } + html += ''; + } + html += '
${escaped}
${escaped}
'; + + 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}

+
+
+ + +
+
+
+ + +
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) { @@ -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
  • '; } + + 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) {
    @@ -783,6 +937,19 @@ externalLinksCaret.style.transform = externalLinksCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; }); } + + // Collapsible Support Menu Section + var supportMenuToggle = document.getElementById('support-menu-toggle'); + var supportMenuSection = document.getElementById('support-menu-links-section'); + var supportMenuCaret = document.getElementById('support-menu-caret'); + var supportMenuCollapsed = false; + if (supportMenuToggle && supportMenuSection && supportMenuCaret) { + supportMenuToggle.addEventListener('click', function() { + supportMenuCollapsed = !supportMenuCollapsed; + supportMenuSection.style.display = supportMenuCollapsed ? 'none' : ''; + supportMenuCaret.style.transform = supportMenuCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; + }); + } }); // Sidebar Toggle Functionality @@ -850,8 +1017,8 @@ /* Fixed user account section */ #sidebar-user-account { - width: 260px; - min-width: 220px; + width: var(--sidebar-width, 260px); + min-width: var(--sidebar-min-width, 220px); transition: inherit; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); diff --git a/application/single_app/templates/_sidebar_short_nav.html b/application/single_app/templates/_sidebar_short_nav.html index 34413abc..dcd66446 100644 --- a/application/single_app/templates/_sidebar_short_nav.html +++ b/application/single_app/templates/_sidebar_short_nav.html @@ -1,5 +1,9 @@ {# Sidebar Navigation Partial - Short version without brand/logo #} -
    @@ -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') %} + + {% endif %} + {% if app_settings.enable_user_agreement %}
    @@ -713,7 +905,7 @@

    Personal Workspace

    {% endif %} - {% endif %} + {% if settings.per_user_semantic_kernel and settings.enable_semantic_kernel %} @@ -730,30 +922,109 @@

    Personal Workspace

    +
    + + + + +
    - - - - - -
    Display NameDescriptionActions
    + +
    + + + + + +
    Display NameDescriptionActions
    +
    + +
    - {% 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.

    +
    + + +
    +
    +
    +
    Your Endpoints
    + +
    +
    + + + + + + + + + + + + + +
    NameProviderSelected ModelsStatusActions
    +
    +
    + +
    +
    +
    + + {% endif %} {% endif %} + {% include '_multiendpoint_modal.html' %} + + +