1513 lines
No EOL
57 KiB
JavaScript
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
|
|
}; |