gateway/static/63_workflowUi.js
2025-04-30 00:39:37 +02:00

1513 lines
No EOL
57 KiB
JavaScript

/**
* Responsible for all UI rendering and updates in the workflow module
* Purely renders UI based on state, contains no business logic
*/
import api from '../shared/apiCalls.js';
import * as WorkflowUtils from './workflowUtils.js';
// DOM elements
let domElements = {};
// Current preview file
let currentPreviewFile = null;
/**
* Initializes the UI components
* @param {Object} workflowState - Current workflow state
* @param {Object} callbackFunctions - Callback functions for UI events
*/
function initUI(workflowState, callbackFunctions) {
console.log("Initializing workflow UI...");
// Store callback functions
const callbacks = {
onLayoutChange: callbackFunctions.onLayoutChange || (() => {}),
onResetWorkflow: callbackFunctions.onResetWorkflow || (() => {}),
onStopWorkflow: callbackFunctions.onStopWorkflow || (() => {})
};
// Set up event listeners for the custom events from coordination layer
document.addEventListener('workflowStatusChanged', (event) => {
updateWorkflowButtonsState(event.detail);
});
document.addEventListener('logsUpdated', (event) => {
renderLogs(event.detail.logs, event.detail.waitingLogId);
});
document.addEventListener('chatMessagesUpdated', (event) => {
renderChatMessages(event.detail.chatMessages, event.detail.workflowId);
});
document.addEventListener('dataStatsUpdated', (event) => {
updateDataStatistics(event.detail.sentBytes, event.detail.receivedBytes, event.detail.tokensUsed);
});
document.addEventListener('filesUpdated', (event) => {
renderAdditionalFiles(event.detail.files);
});
// Add listener for workflow completion
document.addEventListener('workflowCompleted', (event) => {
showWorkflowCompletionIndicator(event.detail);
});
// Initialize buttons
setupUIButtons(callbacks);
// Initialize layout controls
initializeFixedLayout();
setupHeaderToggle();
// Initial renders
renderLogs(workflowState.logs || []);
renderChatMessages(workflowState.chatMessages || [], workflowState.workflowId);
updateDataStatistics(
workflowState.dataStats ? workflowState.dataStats.bytesSent : 0,
workflowState.dataStats ? workflowState.dataStats.bytesReceived : 0,
workflowState.dataStats ? workflowState.dataStats.tokensUsed : 0
);
updateWorkflowButtonsState({
status: workflowState.status,
workflowId: workflowState.workflowId
});
console.log("Workflow UI successfully initialized");
}
/**
* Sets up UI buttons and their event handlers
* @param {Object} callbacks - Callback functions
*/
function setupUIButtons(callbacks) {
// Reset button
const resetBtn = document.getElementById('reset-btn');
if (resetBtn) {
resetBtn.addEventListener('click', callbacks.onResetWorkflow);
}
// Stop button
const stopBtn = document.getElementById('stop-workflow-btn');
if (stopBtn) {
stopBtn.addEventListener('click', callbacks.onStopWorkflow);
}
// File preview controls
const downloadBtn = document.getElementById('download-file-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', downloadPreviewFile);
}
const copyBtn = document.getElementById('copy-file-btn');
if (copyBtn) {
copyBtn.addEventListener('click', copyPreviewFileContent);
}
const closePreviewBtn = document.getElementById('close-file-preview-btn');
if (closePreviewBtn) {
closePreviewBtn.addEventListener('click', () => {
const container = document.getElementById('file-preview-container');
if (container) {
container.style.display = 'none';
}
});
}
}
/**
* Updates the status of workflow buttons based on current state
* @param {Object} stateInfo - Object containing state information
*/
function updateWorkflowButtonsState(stateInfo) {
// Extract status (supports both direct status and event detail format)
const status = stateInfo.status || (stateInfo.detail ? stateInfo.detail.status : null);
const workflowId = stateInfo.workflowId || (stateInfo.detail ? stateInfo.detail.workflowId : null);
if (status === undefined) return;
console.log(`Updating workflow buttons for status: ${status}`);
const stopBtn = document.getElementById('stop-workflow-btn');
const resetBtn = document.getElementById('reset-btn');
const sendBtn = document.getElementById('send-user-message-btn');
const userInput = document.getElementById('user-message-input');
// Update stop button
if (stopBtn) {
if (status === 'running') {
stopBtn.style.display = 'inline-block';
stopBtn.disabled = false;
stopBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
} else {
stopBtn.style.display = 'none';
}
}
// Update reset button
if (resetBtn) {
resetBtn.disabled = status === 'running';
}
// Update send button and input field
const isInputEnabled = status !== 'running';
if (sendBtn) {
sendBtn.disabled = !isInputEnabled;
// Only update text if not in loading state (no spinner)
if (!sendBtn.querySelector('.fa-spinner')) {
const isInitial = !workflowId && (status === null);
sendBtn.innerHTML = isInitial ?
'<i class="fas fa-play"></i> Start' :
'<i class="fas fa-paper-plane"></i> Send';
}
}
// Update input field
if (userInput) {
userInput.disabled = !isInputEnabled;
// Update placeholder based on state
if (status === 'completed') {
userInput.placeholder = "Workflow completed. Enter a new prompt to continue...";
} else if (status === 'failed') {
userInput.placeholder = "Workflow failed. Enter a new prompt to retry...";
} else if (status === 'stopped') {
userInput.placeholder = "Workflow stopped. Enter a new prompt to resume...";
} else if (!workflowId) {
userInput.placeholder = "Enter a prompt to start...";
} else {
userInput.placeholder = "Enter your message...";
}
}
// Update workflow status indicator
updateWorkflowStatusIndicator(status);
}
/**
* Updates the workflow status indicator in the UI
* @param {string} status - Workflow status
*/
function updateWorkflowStatusIndicator(status) {
// Find or create status indicator
let statusIndicator = document.getElementById('workflow-status-indicator');
if (!statusIndicator) {
statusIndicator = document.createElement('div');
statusIndicator.id = 'workflow-status-indicator';
// Insert after header or as first child of workflow container
const header = document.querySelector('.workflow-header');
const container = document.querySelector('.workflow-container');
if (header && header.parentNode) {
header.parentNode.insertBefore(statusIndicator, header.nextSibling);
} else if (container) {
container.insertBefore(statusIndicator, container.firstChild);
}
}
// Set status classes and text
statusIndicator.className = `status-indicator status-${status || 'null'}`;
let statusText = '';
let statusIcon = '';
switch (status) {
case 'running':
statusText = 'Workflow Running';
statusIcon = '<i class="fas fa-spinner fa-spin"></i>';
break;
case 'completed':
statusText = 'Workflow Completed';
statusIcon = '<i class="fas fa-check-circle"></i>';
break;
case 'failed':
statusText = 'Workflow Failed';
statusIcon = '<i class="fas fa-exclamation-circle"></i>';
break;
case 'stopped':
statusText = 'Workflow Stopped';
statusIcon = '<i class="fas fa-stop-circle"></i>';
break;
default:
statusText = 'Ready to Start';
statusIcon = '<i class="fas fa-circle"></i>';
}
statusIndicator.innerHTML = `${statusIcon} <span>${statusText}</span>`;
// Hide indicator if status is null
if (status === null) {
statusIndicator.style.display = 'none';
} else {
statusIndicator.style.display = 'flex';
}
}
/**
* Shows a workflow completion indicator
* @param {Object} detail - Completion details
*/
function showWorkflowCompletionIndicator(detail) {
const chatContainer = document.getElementById('agent-chat-messages');
if (!chatContainer) return;
// Create completion indicator
const completionIndicator = document.createElement('div');
completionIndicator.className = 'workflow-completion-indicator';
let message = detail.message || 'Workflow completed';
let className = 'completion-success';
let icon = 'check-circle';
if (detail.status === 'failed') {
message = detail.message || 'Workflow failed';
className = 'completion-error';
icon = 'exclamation-circle';
} else if (detail.status === 'stopped') {
message = detail.message || 'Workflow stopped';
className = 'completion-warning';
icon = 'stop-circle';
}
completionIndicator.classList.add(className);
completionIndicator.innerHTML = `
<i class="fas fa-${icon}"></i>
<span>${message}</span>
`;
// Add to chat
chatContainer.appendChild(completionIndicator);
// Scroll to bottom
chatContainer.scrollTop = chatContainer.scrollHeight;
// Remove after delay
setTimeout(() => {
completionIndicator.classList.add('fade-out');
setTimeout(() => {
if (completionIndicator.parentNode) {
completionIndicator.parentNode.removeChild(completionIndicator);
}
}, 500);
}, 5000);
}
/**
* Renders logs in the UI
* @param {Array} logs - Array of log entries
* @param {string} waitingLogId - ID of the log that should show a waiting animation
*/
function renderLogs(logs, waitingLogId = null) {
const logContainer = document.getElementById('execution-log');
if (!logContainer) return;
// Check if user has scrolled to bottom
const isAtBottom = isUserAtBottom(logContainer);
// If no logs, show empty state
if (!logs || logs.length === 0) {
logContainer.innerHTML = '<div class="log-empty-state">No logs yet. Workflow execution logs will appear here.</div>';
return;
}
// Build logs HTML
let logsHTML = '';
// Deduplicate logs by grouping those with the same message and agent
const uniqueLogs = [];
const seenMessageAgentPairs = new Map();
// Process logs backwards (newest first) to keep the latest one in each group
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
if (!log) continue;
const key = `${log.message}|${log.agentName || ''}`;
// If we haven't seen this message+agent combo or this is a special log type, add it
if (!seenMessageAgentPairs.has(key) ||
log.type === 'error' ||
log.type === 'warning' ||
log.highlighted) {
seenMessageAgentPairs.set(key, uniqueLogs.length);
uniqueLogs.unshift(log); // Add to beginning to maintain original order
} else {
// If we already have this message, update the existing one with any new info
const existingIndex = seenMessageAgentPairs.get(key);
// Keep the newest ID (for polling reference)
uniqueLogs[existingIndex].id = log.id;
// Update progress if present
if (log.progress !== undefined && log.progress !== null) {
uniqueLogs[existingIndex].progress = log.progress;
}
// Update details if present
if (log.details && !uniqueLogs[existingIndex].details) {
uniqueLogs[existingIndex].details = log.details;
}
// Update waiting status if needed
if (log.waiting) {
uniqueLogs[existingIndex].waiting = true;
}
}
}
// Find the latest log with progress information and mark it
let latestProgressLog = null;
for (let i = uniqueLogs.length - 1; i >= 0; i--) {
const log = uniqueLogs[i];
if (log && log.progress !== undefined && log.progress !== null) {
latestProgressLog = log;
break;
}
}
// Now render the deduplicated logs
uniqueLogs.forEach((log, index) => {
if (!log) return; // Skip invalid logs
const logId = log.id || `log_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const logType = log.type || 'info';
const logMessage = log.message || 'No message';
const logTimestamp = log.timestamp ? new Date(log.timestamp).toLocaleTimeString() : new Date().toLocaleTimeString();
// FIX 1: Only show waiting animation on the last log entry
// All older logs should have no animation
const isLastLog = index === uniqueLogs.length - 1;
const shouldShowWaiting = isLastLog && (waitingLogId === null || waitingLogId === logId);
const logWaiting = shouldShowWaiting ? '<span class="waiting-dots"></span>' : '';
// Extract agent name and format it
let agentHTML = '';
if (log.agentName) {
// Agent-specific colors
const agentClass = `agent-${log.agentName.toLowerCase().replace(/\s+/g, '-')}`;
agentHTML = `<span class="log-agent ${agentClass}">[${log.agentName}]</span>`;
}
// Extra classes for styling
const highlightClass = log.highlighted ? 'highlighted' : '';
// FIX 1: Progress indicator - only show active progress for the latest log
// All previous logs should be either at 100% or have no progress bar
let progressHTML = '';
if (log.progress !== undefined && log.progress !== null) {
// For the latest log, show actual progress
// For all older logs, force to 100%
const progressPercent = isLastLog ?
Math.min(100, Math.max(0, parseInt(log.progress, 10))) : 100;
progressHTML = `
<div class="log-progress-container">
<div class="log-progress-bar" style="width: ${progressPercent}%"></div>
<span class="log-progress-text">${progressPercent}%</span>
</div>
`;
}
// Collapsible details
if (log.details) {
// Format the log message for display - truncate if too long
const shortMessage = truncateLogMessage(logMessage, 60);
const isCollapsed = !log.expanded; // Default to collapsed unless explicitly expanded
logsHTML += `
<div class="log-entry ${highlightClass}" data-log-id="${logId}">
<div class="log-header">
<span class="log-time">[${logTimestamp}]</span>
${agentHTML}
<span class="log-message log-${logType}" title="${logMessage}">${shortMessage}</span>
${progressHTML}
${logWaiting}
<span class="log-toggle" title="Toggle details">${isCollapsed ? '▾' : '▴'}</span>
</div>
<div class="log-content" style="display: ${isCollapsed ? 'none' : 'block'};">
${WorkflowUtils.formatMarkdownLike(log.details)}
</div>
</div>
`;
} else {
logsHTML += `
<div class="log-entry ${highlightClass}" data-log-id="${logId}">
<span class="log-time">[${logTimestamp}]</span>
${agentHTML}
<span class="log-message log-${logType}">${logMessage}</span>
${progressHTML}
${logWaiting}
</div>
`;
}
});
// Batch DOM updates using requestAnimationFrame to reduce reflow violations
requestAnimationFrame(() => {
logContainer.innerHTML = logsHTML;
// Add toggle listener for collapsible details
logContainer.querySelectorAll('.log-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const logEntry = toggle.closest('.log-entry');
const content = logEntry.querySelector('.log-content');
const isVisible = content.style.display !== 'none';
// Toggle display
content.style.display = isVisible ? 'none' : 'block';
toggle.textContent = isVisible ? '▾' : '▴';
// Update full message when expanded
if (!isVisible) {
const logMessage = logEntry.querySelector('.log-message');
const fullMessage = logMessage.getAttribute('title');
if (fullMessage) {
logMessage.textContent = fullMessage;
}
} else {
// Revert to truncated when collapsed
const logMessage = logEntry.querySelector('.log-message');
const fullMessage = logMessage.getAttribute('title');
if (fullMessage) {
logMessage.textContent = truncateLogMessage(fullMessage, 60);
}
}
// Store expanded state for this log ID
const logId = logEntry.getAttribute('data-log-id');
if (logId) {
const log = logs.find(l => l.id === logId);
if (log) {
log.expanded = !isVisible;
}
}
});
});
// Auto-scroll if user was at bottom - using requestAnimationFrame to avoid forced reflow
if (isAtBottom) {
requestAnimationFrame(() => {
logContainer.scrollTop = logContainer.scrollHeight;
});
}
});
}
/**
* Truncates a log message to a specified length
* @param {string} message - The message to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} - Truncated message
*/
function truncateLogMessage(message, maxLength = 60) {
if (!message || message.length <= maxLength) {
return message;
}
return message.substring(0, maxLength) + '...';
}
/**
* Renders chat messages in the UI
* @param {Array} chatMessages - Array of chat messages
* @param {string} currentWorkflowId - ID of the current workflow
*/
function renderChatMessages(chatMessages, currentWorkflowId) {
const chatContainer = document.getElementById('agent-chat-messages');
const emptyStateContainer = document.getElementById('empty-chat-state');
if (!chatContainer) return;
// If no messages, show empty state
if (!chatMessages || chatMessages.length === 0) {
if (emptyStateContainer) {
emptyStateContainer.style.display = 'flex';
}
chatContainer.style.display = 'none';
return;
}
// Show chat container, hide empty state
if (emptyStateContainer) {
emptyStateContainer.style.display = 'none';
}
chatContainer.style.display = 'block';
// Remember scroll position
const wasAtBottom = isUserAtBottom(chatContainer);
// Clear container
chatContainer.innerHTML = '';
// Render each message
chatMessages.forEach((message, index) => {
if (!message) return; // Skip invalid messages
const messageDiv = document.createElement('div');
if (message.type === 'system') {
// System message
messageDiv.className = 'chat-message system-message';
messageDiv.innerHTML = `<div class="message-content">${message.content || 'No message'}</div>`;
} else {
// Standard message (user or assistant)
const isLastMessage = index === chatMessages.length - 1;
const isCollapsed = !isLastMessage && message.role !== 'user'; // Only last message expanded by default, user messages always expanded
const isUserMessage = message.role === 'user';
// Apply classes
messageDiv.className = `chat-message agent-message ${isCollapsed ? 'collapsed' : ''} ${isUserMessage ? 'user' : 'assistant'}`;
// Check for message status
if (message.status) {
messageDiv.setAttribute('data-status', message.status);
// Handle "last" message status - completion indicator
if (message.status === 'last') {
messageDiv.classList.add('last-message');
}
}
// Format content
let formattedContent = message.content || '';
// Delete button for messages
let deleteButton = '';
if (currentWorkflowId && message.id) {
deleteButton = `
<div class="message-delete-container">
<button class="message-delete-btn" data-message-id="${message.id}" data-workflow-id="${currentWorkflowId}" title="Delete message">
<i class="fas fa-times"></i>
</button>
</div>
`;
}
// File attachments
let filesHTML = '';
if (message.documents && message.documents.length > 0) {
filesHTML = `
<div class="message-files">
<div class="files-heading">Attached Files (${message.documents.length}):</div>
<ul class="files-list">
`;
message.documents.forEach(doc => {
// Extract file information from the document object
const fileInfo = extractFileInfo(doc);
if (fileInfo) {
const fileIcon = WorkflowUtils.getFileTypeIcon({mimeType: fileInfo.contentType});
// IMPORTANT: Use fileId for data-file-id if available, otherwise fall back to id
const fileIdForAPI = fileInfo.fileId || fileInfo.id;
filesHTML += `
<li class="file-item">
<i class="fas ${fileIcon}"></i>
<span class="file-name">${fileInfo.name}</span>
<div class="file-actions">
<button class="preview-file-btn" data-file-id="${fileIdForAPI}" data-doc-id="${fileInfo.id || ''}" title="Preview">
<i class="fas fa-eye"></i>
</button>
<button class="delete-file-btn" data-file-id="${fileIdForAPI}" data-message-id="${message.id}" title="Delete file">
<i class="fas fa-times"></i>
</button>
</div>
</li>
`;
}
});
filesHTML += `
</ul>
</div>
`;
}
// Status indicator for 'last' messages
let statusIndicator = '';
if (message.status === 'last') {
statusIndicator = '<div class="message-status-indicator">Final Response</div>';
}
messageDiv.innerHTML = `
<div class="message-header">
<span class="agent-name">${isUserMessage ? (window.globalState?.user?.fullName || 'You') : (message.agentName || 'Assistant')}</span>
<span class="message-time">${new Date(message.timestamp || new Date()).toLocaleTimeString()}</span>
${deleteButton}
</div>
<div class="message-content">${WorkflowUtils.formatMarkdownLike(formattedContent)}</div>
${filesHTML}
${statusIndicator}
${message.role !== 'user' ? `<div class="toggle-content">${isCollapsed ? 'Show more' : 'Show less'}</div>` : ''}
`;
}
chatContainer.appendChild(messageDiv);
});
// Set up toggle buttons
setupMessageToggles(chatContainer);
// Set up file actions
setupFileActions(chatContainer, chatMessages, currentWorkflowId);
// Set up message delete buttons
setupMessageDeleteButtons(chatContainer, currentWorkflowId);
// Auto-scroll to bottom if user was at bottom
if (wasAtBottom) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
/**
* Extracts file information from a document object
* Prioritizes the numeric fileId for API operations
* @param {Object} doc - Document object
* @returns {Object|null} - Extracted file info or null if invalid
*/
function extractFileInfo(doc) {
// Initialize with default values
let fileInfo = {
fileId: null,
name: 'Unknown File',
contentType: 'application/octet-stream',
size: 0
};
try {
// Extract numeric fileId
if (doc.fileId !== undefined && doc.fileId !== null) {
fileInfo.fileId = Number(doc.fileId);
}
// If no valid numeric ID found, return null
if (fileInfo.fileId === null || isNaN(fileInfo.fileId)) {
console.warn("No valid numeric fileId found in document:", doc);
return null;
}
// Extract other properties based on available data
if (doc.source && doc.source.type === 'file') {
fileInfo.name = doc.source.name || 'Unknown File';
fileInfo.contentType = doc.source.content_type || doc.source.mimeType || 'application/octet-stream';
fileInfo.size = doc.source.size || 0;
} else {
// MODIFIED: Combine name and ext if both are available
if (doc.name && doc.ext) {
fileInfo.name = `${doc.name}.${doc.ext}`;
} else {
fileInfo.name = doc.name || 'Unknown File';
}
fileInfo.contentType = doc.contentType || doc.content_type || doc.mimeType || 'application/octet-stream';
fileInfo.size = doc.size || 0;
}
return fileInfo;
} catch (error) {
console.error("Error extracting file info:", error);
return null;
}
}
/**
* Sets up toggle buttons for expanding/collapsing messages
* @param {HTMLElement} container - Container with messages
*/
function setupMessageToggles(container) {
container.querySelectorAll('.toggle-content').forEach(toggle => {
toggle.addEventListener('click', () => {
const messageEl = toggle.closest('.chat-message');
if (messageEl) {
messageEl.classList.toggle('collapsed');
toggle.textContent = messageEl.classList.contains('collapsed') ? 'Show more' : 'Show less';
}
});
});
}
/**
* Sets up file actions (preview, delete) for message attachments
* @param {HTMLElement} container - Container with messages
* @param {Array} chatMessages - Array of chat messages
* @param {string} currentWorkflowId - ID of the current workflow
*/
function setupFileActions(container, chatMessages, currentWorkflowId) {
// File preview buttons
container.querySelectorAll('.preview-file-btn').forEach(btn => {
btn.addEventListener('click', async () => {
// Get fileId attribute and ensure it's a number
const fileIdAttr = btn.getAttribute('data-file-id');
const fileId = !isNaN(Number(fileIdAttr)) ? Number(fileIdAttr) : null;
if (!fileId) {
window.utils.showToast("Preview Error", "No valid numeric file ID for preview", "error");
return;
}
console.log(`Previewing file ID: ${fileId}`);
// Find file info in messages
let file = null;
// Try to find in globalState first
if (window.globalState?.mainView?.availableFiles) {
file = window.globalState.mainView.availableFiles.find(f =>
(typeof f.fileId === 'number' && f.fileId === fileId) ||
(f.fileId !== undefined && Number(f.fileId) === fileId)
);
}
// If not found in globalState, search in messages
if (!file) {
// Search through all messages
for (const message of chatMessages) {
if (!message.documents) continue;
for (const doc of message.documents) {
// Extract file info
const fileInfo = extractFileInfo(doc);
if (fileInfo && fileInfo.fileId === fileId) {
// Create a file object with the necessary properties for preview
file = {
...fileInfo,
contents: doc.contents || [],
data: doc.data || null,
base64Encoded: doc.base64Encoded || false
};
break;
}
}
if (file) break;
}
}
if (file) {
previewFile(file);
// Enable download and copy buttons
const downloadBtn = document.getElementById('download-file-btn');
const copyBtn = document.getElementById('copy-file-btn');
if (downloadBtn) downloadBtn.disabled = false;
if (copyBtn) copyBtn.disabled = false;
} else {
window.utils.showToast("Preview Error", "File not found for preview", "error");
}
});
});
// File delete buttons
container.querySelectorAll('.delete-file-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const fileIdAttr = btn.getAttribute('data-file-id');
const messageId = btn.getAttribute('data-message-id');
// Require numeric fileId
const fileId = !isNaN(Number(fileIdAttr)) ? Number(fileIdAttr) : null;
if (!fileId || !messageId || !currentWorkflowId) {
window.utils.showToast("Delete Error", "Missing required information or invalid file ID", "error");
return;
}
try {
// Find file details before deletion
const message = chatMessages.find(msg => msg.id === messageId);
if (!message) {
throw new Error(`Message with ID ${messageId} not found`);
}
// Find the file to get details
let fileName = "File";
if (message.documents) {
for (const doc of message.documents) {
const fileInfo = extractFileInfo(doc);
if (fileInfo && fileInfo.fileId === fileId) {
fileName = fileInfo.name || "File";
break;
}
}
}
// Call API to delete the file with numeric fileId
console.log(`Deleting file from message: workflow=${currentWorkflowId}, message=${messageId}, file=${fileId}`);
const success = await api.deleteFileFromMessage(currentWorkflowId, messageId, fileId);
if (success) {
window.utils.showToast("Success", `${fileName} was successfully deleted`, "success");
// Hide the file element in the UI
const fileElement = btn.closest('.file-item');
if (fileElement) {
fileElement.style.display = 'none';
}
} else {
window.utils.showToast("Delete Failed", "Could not delete the file", "error");
}
} catch (error) {
console.error(`Error deleting file: ${error.message}`);
window.utils.showToast("Error", `Delete failed: ${error.message}`, "error");
}
});
});
}
/**
* Sets up message delete buttons
* @param {HTMLElement} container - Container with messages
* @param {string} workflowId - ID of the current workflow
*/
function setupMessageDeleteButtons(container, workflowId) {
container.querySelectorAll('.message-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const messageId = btn.getAttribute('data-message-id');
if (!messageId || !workflowId) return;
if (confirm('Are you sure you want to delete this message?')) {
try {
console.log(`Deleting message: workflow=${workflowId}, message=${messageId}`);
const success = await api.deleteWorkflowMessage(workflowId, messageId);
if (success) {
// Remove message from DOM
const messageEl = btn.closest('.chat-message');
if (messageEl && messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
}
window.utils.showToast("Success", "Message deleted", "success");
} else {
window.utils.showToast("Delete Failed", "Could not delete the message", "error");
}
} catch (error) {
console.error(`Error deleting message: ${error.message}`);
window.utils.showToast("Error", `Delete failed: ${error.message}`, "error");
}
}
});
});
}
/**
* Renders the additional files list
* @param {Array} files - Array of file objects
*/
function renderAdditionalFiles(files) {
const container = document.getElementById('additional-files-container');
if (!container) return;
container.innerHTML = '';
if (!files || files.length === 0) return;
files.forEach((file, index) => {
const fileEl = document.createElement('div');
fileEl.className = 'additional-file-item';
// Add status class based on file parsing success
if (file.hasOwnProperty('isExtracted')) {
fileEl.classList.add(file.isExtracted ? 'parsing-success' : 'parsing-failed');
}
fileEl.innerHTML = `
<i class="fas ${WorkflowUtils.getFileTypeIcon(file)}"></i>
<span class="file-name" title="${file.name}">${file.name}</span>
${file.hasOwnProperty('isExtracted') && !file.isExtracted ?
'<span class="file-parsing-error" title="File parsing failed">⚠️</span>' : ''}
<span class="file-size">${WorkflowUtils.formatFileSize(file.size)}</span>
<span class="remove-additional-file" data-index="${index}">
<i class="fas fa-times"></i>
</span>
`;
container.appendChild(fileEl);
});
// Add click handlers for remove buttons
container.querySelectorAll('.remove-additional-file').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
// Dispatch custom event for file removal
const event = new CustomEvent('removeAdditionalFile', {
detail: { index: index }
});
document.dispatchEvent(event);
});
});
}
/**
* Updates the visualization of prompt and files
*/
function updatePromptVisualization() {
const userInputArea = document.getElementById('user-input-area');
if (!userInputArea) return;
// Find or create the file preview area
let filePreviewArea = document.getElementById('selected-files-preview');
if (!filePreviewArea) {
filePreviewArea = document.createElement('div');
filePreviewArea.id = 'selected-files-preview';
filePreviewArea.className = 'selected-files-preview';
userInputArea.appendChild(filePreviewArea);
}
// Get attached files from custom event
const filesEvent = new CustomEvent('getAttachedFiles', {
detail: { files: [] }
});
document.dispatchEvent(filesEvent);
const files = filesEvent.detail.files || [];
// If no files, hide the preview
if (!files.length) {
filePreviewArea.style.display = 'none';
filePreviewArea.innerHTML = '';
return;
}
// Show files
filePreviewArea.style.display = 'block';
filePreviewArea.innerHTML = `
<div class="files-preview-header">
${files.length} ${files.length === 1 ? 'file' : 'files'} attached:
</div>
<ul class="files-preview-list"></ul>
`;
const filesList = filePreviewArea.querySelector('.files-preview-list');
files.forEach(file => {
const item = document.createElement('li');
item.className = 'file-preview-item';
// Add status class based on file parsing success
const extractionClass = file.hasOwnProperty('isExtracted') ?
(file.isExtracted ? 'extracted' : 'extraction-failed') : '';
// IMPORTANT: Use fileId for API operations if available, otherwise fall back to id
const fileIdForAPI = file.fileId !== undefined ? file.fileId : file.id;
item.innerHTML = `
<i class="fas ${WorkflowUtils.getFileTypeIcon(file)}"></i>
<span class="file-name ${extractionClass}" title="${file.name}">${file.name}</span>
${file.hasOwnProperty('isExtracted') && !file.isExtracted ?
'<span class="file-parsing-error" title="File may not be fully processed">⚠️</span>' : ''}
<span class="file-size">${WorkflowUtils.formatFileSize(file.size)}</span>
<button class="remove-file-btn" data-file-id="${fileIdForAPI}">
<i class="fas fa-times"></i>
</button>
`;
filesList.appendChild(item);
});
// FIX 2: Properly set up remove file event handlers with error messaging
filePreviewArea.querySelectorAll('.remove-file-btn').forEach(btn => {
btn.addEventListener('click', () => {
const fileId = btn.getAttribute('data-file-id');
if (!fileId) {
window.utils.showToast("Error", "No file ID found for removal", "error");
return;
}
try {
// Dispatch custom event for file removal - log the event for debugging
console.log(`Attempting to remove file with ID: ${fileId}`);
const event = new CustomEvent('removeFileById', {
detail: { fileId: fileId }
});
document.dispatchEvent(event);
// Show confirmation toast for better UX
const fileItem = btn.closest('.file-preview-item');
const fileName = fileItem ? fileItem.querySelector('.file-name').textContent : 'File';
window.utils.showToast("Removing file", `Removing ${fileName}...`, "info");
} catch (error) {
console.error("Error removing file:", error);
window.utils.showToast("Error", `Failed to remove file: ${error.message}`, "error");
}
});
});
}
/**
* Previews a file in the preview pane
* @param {Object} file - File object to preview
*/
async function previewFile(file) {
if (!file) {
console.error("Cannot preview file: file is null");
return;
}
const previewContainer = document.getElementById('file-preview-container');
const previewContent = document.getElementById('file-preview-content');
if (!previewContainer || !previewContent) {
console.error("Preview containers not found");
return;
}
// Show preview container
previewContainer.style.display = 'flex';
// Get numeric fileId
const fileId = typeof file.fileId === 'number' ? file.fileId :
(file.fileId !== undefined && !isNaN(Number(file.fileId)) ? Number(file.fileId) : null);
// Log file information
console.log(`Previewing file: ${file.name || 'unnamed'} (ID: ${fileId || 'N/A'}, Type: ${file.contentType || file.mimeType || 'unknown'})`);
// Update preview container header
const previewHeader = previewContainer.querySelector('.file-preview-header');
if (previewHeader) {
previewHeader.innerHTML = `
<span class="preview-title">${file.name || 'File Preview'}</span>
<span class="preview-info">${WorkflowUtils.formatFileSize(file.size)}</span>
`;
}
// Set current preview file for download/copy actions
currentPreviewFile = file;
// Show loading indicator
previewContent.innerHTML = `
<div class="loading-preview">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading file...</span>
</div>
`;
// Require numeric fileId
if (!fileId) {
previewContent.innerHTML = `
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<h4>Preview Unavailable</h4>
<p>This document doesn't have a valid numeric ID for preview.</p>
<p>According to the data model, only files with numeric IDs can be fetched from the API.</p>
</div>
`;
return;
}
try {
// Call API with the numeric fileId
const fileData = await api.previewFile(fileId);
if (!fileData || !fileData.content) {
throw new Error("No file data available");
}
renderFilePreview(fileData, file, previewContent);
} catch (error) {
console.error("Error fetching file data:", error);
previewContent.innerHTML = `
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<h4>Preview Error</h4>
<p>${error.message || "Could not load file preview"}</p>
</div>
`;
}
}
/**
* Renders the file preview based on file type
* @param {Object} fileData - File data from API
* @param {Object} file - File metadata
* @param {HTMLElement} previewContent - Container for preview
*/
function renderFilePreview(fileData, file, previewContent) {
const mimeType = file.contentType || file.mimeType || '';
// Handle different file types
if (mimeType.includes('image')) {
// Image preview - always assume base64 for images from API
try {
const imageData = `data:${mimeType};base64,${fileData.content}`;
previewContent.innerHTML = `
<img src="${imageData}" alt="${file.name}" class="file-preview-image">
`;
} catch (error) {
console.error("Error rendering image preview:", error);
window.utils.showToast(previewContent, "Error displaying image","error");
}
}
else if (mimeType.includes('text') ||
mimeType.includes('json') ||
mimeType.includes('csv') ||
mimeType.includes('markdown') ||
mimeType.includes('javascript') ||
mimeType.includes('html') ||
mimeType.includes('xml') ||
mimeType.includes('css')) {
// Text preview - check base64Encoded flag
try {
// If we have the base64Encoded flag explicitly set to true, decode
// Otherwise, assume it's already text since we're in the text formats branch
const textContent = fileData.base64Encoded ? atob(fileData.content) : fileData.content;
const formattedContent = WorkflowUtils.formatMarkdownLike(textContent);
previewContent.innerHTML = `
<div class="file-preview-text formatted-preview">${formattedContent}</div>
`;
} catch (error) {
console.error("Error processing text content:", error);
window.utils.showToast(previewContent, "Error decoding file content","error");
}
}
else if (mimeType.includes('pdf')) {
// PDF preview
try {
const pdfData = `data:${mimeType};base64,${fileData.content}`;
previewContent.innerHTML = `
<embed src="${pdfData}" type="application/pdf" class="file-preview-pdf">
`;
} catch (error) {
console.error("Error rendering PDF preview:", error);
window.utils.showToast(previewContent, "Error displaying PDF","error");
}
}
else if (mimeType.includes('spreadsheet') ||
mimeType.includes('excel') ||
file.name.endsWith('.xlsx') ||
file.name.endsWith('.xls') ||
file.name.endsWith('.csv')) {
// Excel/CSV preview
try {
const textContent = fileData.content ? atob(fileData.content) : 'No content available';
// For CSV or extracted text from Excel
previewContent.innerHTML = `
<div class="file-preview-text">
<div class="spreadsheet-note">Spreadsheet preview (limited formatting):</div>
<pre class="spreadsheet-preview">${textContent}</pre>
</div>
`;
} catch (error) {
console.error("Error processing spreadsheet content:", error);
window.utils.showToast(previewContent, "Error rendering spreadsheet preview","error");
}
}
else {
// Generic file info
previewContent.innerHTML = `
<div class="file-info">
<i class="fas ${WorkflowUtils.getFileTypeIcon(file)} fa-3x"></i>
<h4>${file.name}</h4>
<p>Type: ${mimeType || 'Unknown'}</p>
<p>Size: ${WorkflowUtils.formatFileSize(file.size)}</p>
<p class="file-preview-unsupported">No preview available for this file type</p>
<button id="download-file-direct" class="preview-action-btn">
<i class="fas fa-download"></i> Download File
</button>
</div>
`;
// Add direct download button event listener
const downloadBtn = previewContent.querySelector('#download-file-direct');
if (downloadBtn) {
downloadBtn.addEventListener('click', downloadPreviewFile);
}
}
}
/**
* Downloads the currently previewed file
*/
async function downloadPreviewFile() {
if (!currentPreviewFile) {
window.utils.showToast("Error", "No file selected for download", "error");
return;
}
try {
const file = currentPreviewFile;
// Get numeric fileId
const fileId = typeof file.fileId === 'number' ? file.fileId :
(file.fileId !== undefined && !isNaN(Number(file.fileId)) ? Number(file.fileId) : null);
if (!fileId) {
window.utils.showToast("Error", "No valid numeric ID found for download", "error");
return;
}
console.log(`Downloading file: ${file.name} (ID: ${fileId})`);
// Get file from API using the numeric fileId
const response = await api.downloadFile(fileId);
if (!response || !response.content) {
throw new Error("No file data available for download");
}
// Initiate download
window.utils.data.initiateDownload(
response.content,
file.name || 'download',
file.contentType || file.mimeType || 'application/octet-stream'
);
} catch (error) {
console.error("Error downloading file:", error);
window.utils.showToast("Error", `Download failed: ${error.message}`, "error");
}
}
/**
* Copies the content of the currently previewed file to clipboard
*/
async function copyPreviewFileContent() {
if (!currentPreviewFile) {
window.utils.showToast("Error", "No file selected for copying", "error");
return;
}
try {
const file = currentPreviewFile;
// Check if it's a text file - this is just for validation
const mimeType = file.contentType || file.mimeType || '';
const isTextFile = mimeType.includes('text') ||
mimeType.includes('json') ||
mimeType.includes('csv') ||
mimeType.includes('markdown') ||
mimeType.includes('javascript') ||
mimeType.includes('html') ||
mimeType.includes('xml') ||
mimeType.includes('css');
if (!isTextFile) {
window.utils.showToast("Warning", "Only text files can be copied to clipboard", "warning");
return;
}
// Only use the API - check for valid numeric fileId
const fileId = file.fileId;
if (!fileId || typeof fileId !== 'number') {
window.utils.showToast("Error", "No valid database ID found for copy operation", "error");
return;
}
// Get file content from API
console.log(`Fetching file content for copying (ID: ${fileId})`);
const response = await api.previewFile(fileId);
if (!response || !response.content) {
throw new Error("No file content available");
}
// Use base64Encoded flag to determine if we need to decode
const textContent = response.base64Encoded ? atob(response.content) : response.content;
// Copy to clipboard
try {
// Try using the modern Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(textContent);
window.utils.showToast("Success", "Content copied to clipboard", "success");
return;
}
// Fallback method using a temporary textarea element
const textarea = document.createElement('textarea');
textarea.value = textContent;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
// Try the deprecated execCommand as a fallback
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (successful) {
window.utils.showToast("Success", "Content copied to clipboard", "success");
} else {
throw new Error("Copy operation failed");
}
} catch (clipboardError) {
console.error("Error copying to clipboard:", clipboardError);
window.utils.showToast("Error", "Failed to copy to clipboard. Try manually selecting the text.", "error");
}
} catch (error) {
console.error("Error copying file content:", error);
window.utils.showToast("Error", `Copy failed: ${error.message}`, "error");
}
}
/**
* Updates data statistics display
* @param {number} sentBytes - Bytes sent
* @param {number} receivedBytes - Bytes received
* @param {number} tokensUsed - Tokens used
*/
function updateDataStatistics(sentBytes, receivedBytes, tokensUsed = 0) {
const statsEl = document.getElementById('data-statistics');
if (!statsEl) return;
// Format sizes
const formatSize = (bytes) => {
if (!bytes || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const kbytes = bytes / 1024;
if (kbytes < 1000) return `${Math.round(kbytes)} kB`;
const mbytes = kbytes / 1024;
return `${Math.round(mbytes * 10) / 10} MB`;
};
const sentFormatted = formatSize(sentBytes);
const receivedFormatted = formatSize(receivedBytes);
// Update display
let statsHTML = `
<span class="stat-item" title="Data sent">↑ ${sentFormatted}</span>
<span class="stat-item" title="Data received">↓ ${receivedFormatted}</span>
`;
// Add tokens if available
if (tokensUsed > 0) {
statsHTML += `<span class="stat-item" title="Tokens used">🔤 ${Math.round(tokensUsed).toLocaleString()}</span>`;
}
statsEl.innerHTML = statsHTML;
}
/**
* Initializes fixed layout components
*/
function initializeFixedLayout() {
// Get main container
const container = document.querySelector('.workflow-container');
if (!container) return;
// Store initial heights
const workflowHeader = document.querySelector('.workflow-header');
if (workflowHeader && !workflowHeader.getAttribute('data-default-height')) {
const defaultHeight = window.getComputedStyle(workflowHeader).height;
workflowHeader.setAttribute('data-default-height', defaultHeight);
}
// Set up resize handler
window.addEventListener('resize', adjustLayoutOnResize);
// Initial adjustment
adjustLayoutOnResize();
}
/**
* Sets up header toggle functionality
*/
function setupHeaderToggle() {
const toggleBtn = document.getElementById('toggle-header-btn');
const header = document.querySelector('.workflow-header');
if (!toggleBtn || !header) return;
toggleBtn.addEventListener('click', () => {
header.classList.toggle('collapsed');
// Update icon
const icon = toggleBtn.querySelector('i');
if (icon) {
if (header.classList.contains('collapsed')) {
icon.className = 'fas fa-plus';
} else {
icon.className = 'fas fa-minus';
}
}
// Adjust layout
adjustLayoutOnResize();
});
}
/**
* Adjusts layout on window resize
*/
function adjustLayoutOnResize() {
const container = document.querySelector('.workflow-container');
const header = document.querySelector('.workflow-header');
const chatSection = document.querySelector('.chat-section');
const footer = document.querySelector('.workflow-footer');
if (!container || !header || !chatSection || !footer) return;
// Calculate available heights
const windowHeight = window.innerHeight;
const headerHeight = header.offsetHeight;
const footerHeight = footer.offsetHeight;
// Set chat section height
const chatHeight = windowHeight - headerHeight - footerHeight;
if (chatHeight > 100) {
chatSection.style.height = `${chatHeight}px`;
// Adjust chat messages container
const messagesContainer = document.getElementById('agent-chat-messages');
if (messagesContainer) {
messagesContainer.style.height = `${chatHeight - 20}px`;
}
}
// Adjust file preview container
const previewContainer = document.getElementById('file-preview-container');
if (previewContainer) {
previewContainer.style.height = `${chatHeight}px`;
// Adjust preview content
const previewHeader = previewContainer.querySelector('.file-preview-header');
const previewContent = document.getElementById('file-preview-content');
if (previewHeader && previewContent) {
const headerHeight = previewHeader.offsetHeight;
previewContent.style.height = `${chatHeight - headerHeight - 20}px`;
}
}
}
/**
* Checks if user has scrolled to the bottom of an element
* @param {HTMLElement} element - Element to check
* @returns {boolean} - Whether user is at bottom
*/
function isUserAtBottom(element) {
if (!element) return false;
const tolerance = 30; // Pixels from bottom to consider "at bottom"
return element.scrollHeight - element.scrollTop - element.clientHeight <= tolerance;
}
// Export functions
export {
initUI,
renderLogs,
renderChatMessages,
updateWorkflowButtonsState,
updateDataStatistics,
previewFile,
renderAdditionalFiles,
updatePromptVisualization
};