smoother progress bar and message rendering
This commit is contained in:
parent
18d03e4e78
commit
574e3e1cfa
10 changed files with 586 additions and 613 deletions
|
|
@ -1,27 +1,12 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import FileAttachmentPopup from './FileAttachmentPopup';
|
||||
import { Prompt, WorkflowState, WorkflowActions } from './dashboardChatAreaTypes';
|
||||
import { Prompt, WorkflowState, WorkflowActions, InputAreaProps, AttachedFile } from './dashboardChatAreaTypes';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
import styles from './DashboardChatAreaStyles/DashboardChatAreaInput.module.css';
|
||||
import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css';
|
||||
|
||||
interface InputAreaProps {
|
||||
selectedPrompt?: Prompt | null;
|
||||
workflowState: WorkflowState;
|
||||
workflowActions: WorkflowActions;
|
||||
attachedFiles?: AttachedFile[];
|
||||
onAttachedFilesChange?: (files: AttachedFile[]) => void;
|
||||
}
|
||||
|
||||
interface AttachedFile {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
fileData?: File;
|
||||
objectUrl?: string;
|
||||
}
|
||||
|
||||
const InputArea: React.FC<InputAreaProps> = ({
|
||||
selectedPrompt,
|
||||
|
|
@ -91,12 +76,8 @@ const InputArea: React.FC<InputAreaProps> = ({
|
|||
let success = false;
|
||||
|
||||
if (workflowState.currentWorkflowId) {
|
||||
// Continue existing workflow
|
||||
console.log(`➡️ Continuing workflow ${workflowState.currentWorkflowId}`);
|
||||
success = await workflowActions.continueWorkflow(inputValue, fileIds);
|
||||
} else {
|
||||
// Start new workflow
|
||||
console.log('🚀 Starting new workflow');
|
||||
const newWorkflowId = await workflowActions.startNewWorkflow(inputValue, fileIds);
|
||||
success = !!newWorkflowId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,42 +106,6 @@ const LogItem: React.FC<LogItemProps> = ({ log }) => {
|
|||
Progress: {log.progress}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(log.details || log.performance) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--color-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
padding: '4px 0',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
>
|
||||
{showDetails ? 'Hide Details' : 'Show Details'}
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className={messageStyles.log_details}>
|
||||
{log.details && (
|
||||
<div>
|
||||
<strong>Details:</strong>
|
||||
<pre>{JSON.stringify(log.details, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{log.performance && (
|
||||
<div>
|
||||
<strong>Performance:</strong>
|
||||
<pre>{JSON.stringify(log.performance, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,480 +1,96 @@
|
|||
import React from 'react';
|
||||
import { MessageListProps } from './dashboardChatAreaTypes';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import MessageItem from './DashboardChatAreaMessageItem';
|
||||
import LogItem from './DashboardChatAreaLogItem';
|
||||
import { WorkflowMessage, Document, WorkflowState, WorkflowLog } from './dashboardChatAreaTypes';
|
||||
import {
|
||||
calculateWorkflowProgress,
|
||||
mergeMessagesAndLogs,
|
||||
transformWorkflowMessage
|
||||
} from './dashboardChatAreaProgressBar';
|
||||
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
|
||||
import { IoIosArrowDown, IoIosChatbubbles } from 'react-icons/io';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
// Helper function to parse task and action progress from log messages
|
||||
const parseLogProgress = (logMessage: string): {
|
||||
taskNumber?: number;
|
||||
totalTasks?: number;
|
||||
actionNumber?: number;
|
||||
totalActions?: number;
|
||||
isCompleted?: boolean;
|
||||
type: 'task_start' | 'action_start' | 'action_complete' | 'task_complete' | 'unknown';
|
||||
} => {
|
||||
const message = logMessage.trim();
|
||||
|
||||
// Pattern: "Executing task X/Y"
|
||||
const taskStartPattern = /^Executing task (\d+)\/(\d+)$/i;
|
||||
const taskStartMatch = message.match(taskStartPattern);
|
||||
if (taskStartMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(taskStartMatch[1]),
|
||||
totalTasks: parseInt(taskStartMatch[2]),
|
||||
type: 'task_start'
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: "Task X - Starting action Y/Z"
|
||||
const actionStartPattern = /^Task (\d+) - Starting action (\d+)\/(\d+)$/i;
|
||||
const actionStartMatch = message.match(actionStartPattern);
|
||||
if (actionStartMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(actionStartMatch[1]),
|
||||
actionNumber: parseInt(actionStartMatch[2]),
|
||||
totalActions: parseInt(actionStartMatch[3]),
|
||||
type: 'action_start'
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: "Task X - Action Y/Z completed" or "✅ Task X - Action Y/Z completed"
|
||||
const actionCompletePattern = /^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i;
|
||||
const actionCompleteMatch = message.match(actionCompletePattern);
|
||||
if (actionCompleteMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(actionCompleteMatch[1]),
|
||||
actionNumber: parseInt(actionCompleteMatch[2]),
|
||||
totalActions: parseInt(actionCompleteMatch[3]),
|
||||
isCompleted: true,
|
||||
type: 'action_complete'
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: "Task X/Y completed" or "🎯 Task X/Y completed"
|
||||
const taskCompletePattern = /^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i;
|
||||
const taskCompleteMatch = message.match(taskCompletePattern);
|
||||
if (taskCompleteMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(taskCompleteMatch[1]),
|
||||
totalTasks: parseInt(taskCompleteMatch[2]),
|
||||
isCompleted: true,
|
||||
type: 'task_complete'
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'unknown' };
|
||||
};
|
||||
|
||||
// Helper function to analyze workflow progress from messages and logs
|
||||
const analyzeWorkflowProgressFromMessages = (messages: any[], logs: any[] = []): {
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
isLoading: boolean;
|
||||
taskBreakdown?: string;
|
||||
} | null => {
|
||||
console.log('🚨 analyzeWorkflowProgressFromMessages called:', {
|
||||
messagesLength: messages.length,
|
||||
logsLength: logs.length,
|
||||
logMessages: logs.map(l => l.message)
|
||||
});
|
||||
|
||||
if (messages.length === 0 && logs.length === 0) return null;
|
||||
|
||||
// Check if the last message is from user (indicating we're waiting for assistant response)
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const isWaitingForAssistant = lastMessage?.role === 'user';
|
||||
|
||||
// If we're waiting for assistant response, show loading state
|
||||
if (isWaitingForAssistant) {
|
||||
return {
|
||||
current: 0,
|
||||
total: 0,
|
||||
percentage: 0,
|
||||
isLoading: true
|
||||
};
|
||||
}
|
||||
|
||||
// Parse logs first (they contain the structure info)
|
||||
let totalTasks = 0;
|
||||
const taskActionCounts: { [taskNumber: number]: { total: number; completedActions: Set<number> } } = {};
|
||||
|
||||
// Parse logs for structure information
|
||||
logs.forEach((log) => {
|
||||
if (!log.message) return;
|
||||
|
||||
const content = log.message.trim();
|
||||
|
||||
// Debug: log message content for analysis
|
||||
if (content.includes('✅') || content.includes('🚀') || content.includes('⚡') || content.includes('🎯') || content.includes('Executing task') || content.includes('Starting action')) {
|
||||
console.log('📊 Progress: Analyzing log content:', content);
|
||||
}
|
||||
|
||||
// Look for task execution patterns: "Executing task X/Y"
|
||||
const taskExecMatch = content.match(/Executing task (\d+)\/(\d+)/i);
|
||||
if (taskExecMatch) {
|
||||
const totalTasksFound = parseInt(taskExecMatch[2]);
|
||||
if (totalTasksFound > totalTasks) {
|
||||
totalTasks = totalTasksFound;
|
||||
}
|
||||
console.log('📊 Found total tasks:', totalTasksFound);
|
||||
}
|
||||
|
||||
// Look for action start patterns: "Task X - Starting action Y/Z"
|
||||
const actionStartMatch = content.match(/Task (\d+) - Starting action (\d+)\/(\d+)/i);
|
||||
if (actionStartMatch) {
|
||||
const taskNumber = parseInt(actionStartMatch[1]);
|
||||
const totalActions = parseInt(actionStartMatch[3]);
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: 0, completedActions: new Set() };
|
||||
}
|
||||
if (totalActions > taskActionCounts[taskNumber].total) {
|
||||
taskActionCounts[taskNumber].total = totalActions;
|
||||
}
|
||||
console.log(`📊 Task ${taskNumber} has ${totalActions} actions`);
|
||||
}
|
||||
|
||||
// Look for action completion patterns: "✅ Task X - Action Y/Z completed"
|
||||
const actionCompleteMatch = content.match(/✅\s+Task (\d+) - Action (\d+)\/(\d+) completed/i);
|
||||
if (actionCompleteMatch) {
|
||||
const taskNumber = parseInt(actionCompleteMatch[1]);
|
||||
const actionNumber = parseInt(actionCompleteMatch[2]);
|
||||
const totalActions = parseInt(actionCompleteMatch[3]);
|
||||
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() };
|
||||
}
|
||||
// Add this specific action to the completed set
|
||||
taskActionCounts[taskNumber].completedActions.add(actionNumber);
|
||||
// Update total if needed
|
||||
if (totalActions > taskActionCounts[taskNumber].total) {
|
||||
taskActionCounts[taskNumber].total = totalActions;
|
||||
}
|
||||
console.log(`📊 Task ${taskNumber} action ${actionNumber} completed`);
|
||||
}
|
||||
|
||||
// Look for task completion patterns: "🎯 Task X/Y completed"
|
||||
const taskCompleteMatch = content.match(/🎯\s+Task (\d+)\/(\d+) completed/i);
|
||||
if (taskCompleteMatch) {
|
||||
const taskNumber = parseInt(taskCompleteMatch[1]);
|
||||
const totalTasksFound = parseInt(taskCompleteMatch[2]);
|
||||
if (totalTasksFound > totalTasks) {
|
||||
totalTasks = totalTasksFound;
|
||||
}
|
||||
// Mark all actions for this task as completed
|
||||
if (taskActionCounts[taskNumber]) {
|
||||
const task = taskActionCounts[taskNumber];
|
||||
for (let i = 1; i <= task.total; i++) {
|
||||
task.completedActions.add(i);
|
||||
}
|
||||
}
|
||||
console.log(`📊 Task ${taskNumber} completed (all actions)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Also parse assistant messages for any additional patterns
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== 'assistant' || !message.content) return;
|
||||
|
||||
const content = message.content.trim();
|
||||
|
||||
// Look for action completion in messages: "✅ Task X - Action document.generateReport completed"
|
||||
const actionCompleteMatch = content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i);
|
||||
if (actionCompleteMatch) {
|
||||
const taskNumber = parseInt(actionCompleteMatch[1]);
|
||||
|
||||
// If we have the action number format (e.g., "1/1")
|
||||
if (actionCompleteMatch[2] && actionCompleteMatch[3]) {
|
||||
const actionNumber = parseInt(actionCompleteMatch[2]);
|
||||
const totalActions = parseInt(actionCompleteMatch[3]);
|
||||
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNumber].completedActions.add(actionNumber);
|
||||
if (totalActions > taskActionCounts[taskNumber].total) {
|
||||
taskActionCounts[taskNumber].total = totalActions;
|
||||
}
|
||||
} else {
|
||||
// If it's a named action without numbers, treat it as action 1/1 for this task
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: 1, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNumber].completedActions.add(1);
|
||||
if (taskActionCounts[taskNumber].total === 0) {
|
||||
taskActionCounts[taskNumber].total = 1;
|
||||
}
|
||||
}
|
||||
console.log(`📊 Message: Task ${taskNumber} action completed`);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate completed tasks (a task is complete when all its actions are done)
|
||||
let completedTasks = 0;
|
||||
const taskStatuses = [];
|
||||
|
||||
// Check each task's completion status
|
||||
for (let taskNum = 1; taskNum <= totalTasks; taskNum++) {
|
||||
const task = taskActionCounts[taskNum];
|
||||
if (task) {
|
||||
const isTaskComplete = task.total > 0 && task.completedActions.size === task.total;
|
||||
if (isTaskComplete) {
|
||||
completedTasks++;
|
||||
}
|
||||
taskStatuses.push(`Task ${taskNum}: ${task.completedActions.size}/${task.total} actions`);
|
||||
} else {
|
||||
taskStatuses.push(`Task ${taskNum}: Not started`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Final calculation (task-based):', {
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
taskActionCounts: Object.fromEntries(
|
||||
Object.entries(taskActionCounts).map(([taskNum, task]) => [
|
||||
taskNum,
|
||||
{
|
||||
total: task.total,
|
||||
completed: task.completedActions.size,
|
||||
completedActions: Array.from(task.completedActions),
|
||||
isComplete: task.total > 0 && task.completedActions.size === task.total
|
||||
}
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
// Create task breakdown string
|
||||
const taskBreakdown = taskStatuses.length > 0 ? taskStatuses.join(', ') : undefined;
|
||||
|
||||
// If we have total tasks, always use task-based progress
|
||||
if (totalTasks > 0) {
|
||||
const percentage = Math.round((completedTasks / totalTasks) * 100);
|
||||
console.log(`📊 Task-based progress: ${completedTasks}/${totalTasks} tasks (${percentage}%)`);
|
||||
|
||||
return {
|
||||
current: completedTasks,
|
||||
total: totalTasks,
|
||||
percentage: percentage,
|
||||
isLoading: false,
|
||||
taskBreakdown: taskBreakdown || `${completedTasks}/${totalTasks} tasks completed`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to safely parse timestamps
|
||||
const safeParseDate = (timestamp: any, fallback: number = Date.now()): Date => {
|
||||
if (!timestamp) {
|
||||
console.warn(`⚠️ No timestamp provided, using fallback:`, { fallback });
|
||||
return new Date(fallback);
|
||||
}
|
||||
|
||||
// Handle different timestamp formats
|
||||
let dateToTry = timestamp;
|
||||
|
||||
// If it's a number, check if it's in seconds or milliseconds
|
||||
if (typeof timestamp === 'number') {
|
||||
// If it's a 10-digit number, it's likely seconds since epoch
|
||||
if (timestamp < 10000000000) {
|
||||
dateToTry = timestamp * 1000; // Convert seconds to milliseconds
|
||||
} else {
|
||||
dateToTry = timestamp; // Already in milliseconds
|
||||
}
|
||||
}
|
||||
// If it's a string that looks like a number, parse it and handle seconds/milliseconds
|
||||
else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) {
|
||||
const numericTimestamp = parseInt(timestamp);
|
||||
// If it's a 10-digit number, it's likely seconds since epoch
|
||||
if (numericTimestamp < 10000000000) {
|
||||
dateToTry = numericTimestamp * 1000; // Convert seconds to milliseconds
|
||||
} else {
|
||||
dateToTry = numericTimestamp; // Already in milliseconds
|
||||
}
|
||||
}
|
||||
// If it's already a Date object
|
||||
else if (timestamp instanceof Date) {
|
||||
dateToTry = timestamp;
|
||||
}
|
||||
|
||||
// Try to parse the timestamp
|
||||
const date = new Date(dateToTry);
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn(`⚠️ Invalid timestamp detected:`, {
|
||||
originalTimestamp: timestamp,
|
||||
type: typeof timestamp,
|
||||
processedTimestamp: dateToTry,
|
||||
fallback
|
||||
});
|
||||
return new Date(fallback);
|
||||
}
|
||||
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
// Helper function to merge and sort messages and logs by timestamp
|
||||
const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): Array<{type: 'message' | 'log', item: any, timestamp: Date}> => {
|
||||
const combined: Array<{type: 'message' | 'log', item: any, timestamp: Date}> = [];
|
||||
|
||||
|
||||
|
||||
// Add messages
|
||||
messages.forEach((message, index) => {
|
||||
const rawTimestamp = message.timestamp || message.publishedAt;
|
||||
|
||||
// Use current time minus index for fallback to maintain order
|
||||
const fallbackTime = Date.now() - ((messages.length + logs.length) - index) * 1000;
|
||||
const timestamp = safeParseDate(rawTimestamp, fallbackTime);
|
||||
|
||||
combined.push({
|
||||
type: 'message',
|
||||
item: message,
|
||||
timestamp
|
||||
});
|
||||
});
|
||||
|
||||
// Add logs
|
||||
logs.forEach((log, index) => {
|
||||
|
||||
// Use current time minus index for fallback to maintain order
|
||||
const fallbackTime = Date.now() - ((logs.length) - index) * 1000;
|
||||
const timestamp = safeParseDate(log.timestamp, fallbackTime);
|
||||
|
||||
combined.push({
|
||||
type: 'log',
|
||||
item: log,
|
||||
timestamp
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp (chronological order)
|
||||
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
|
||||
return combined;
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
workflowState: WorkflowState;
|
||||
onFilePreview?: (file: any) => void;
|
||||
}
|
||||
|
||||
// Helper function to transform WorkflowMessage to display Message
|
||||
const transformWorkflowMessage = async (workflowMessage: WorkflowMessage, request: any): Promise<any> => {
|
||||
let documents: Document[] = [];
|
||||
|
||||
// Check if we have documents directly from the API or need to fetch via fileIds
|
||||
if (workflowMessage.documents && workflowMessage.documents.length > 0) {
|
||||
// Use documents directly from the API
|
||||
documents = workflowMessage.documents.map(doc => ({
|
||||
id: doc.id || doc.fileId,
|
||||
fileId: typeof doc.fileId === 'string' ? parseInt(doc.fileId) : doc.fileId,
|
||||
name: doc.filename,
|
||||
ext: doc.filename.split('.').pop() || 'unknown',
|
||||
type: doc.mimeType,
|
||||
size: doc.fileSize,
|
||||
downloadUrl: `/api/workflows/files/${doc.fileId}/download`
|
||||
}));
|
||||
} else if (workflowMessage.fileIds && workflowMessage.fileIds.length > 0) {
|
||||
// Fallback to legacy fileIds approach
|
||||
|
||||
const documentPromises = workflowMessage.fileIds.map(async (fileId, fileIndex) => {
|
||||
try {
|
||||
console.log(`📁 Fetching metadata for file ${fileIndex + 1}/${workflowMessage.fileIds!.length}: ${fileId}`);
|
||||
const response = await request({
|
||||
url: `/api/workflows/files/${fileId}/preview`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
const document: Document = {
|
||||
id: fileId.toString(),
|
||||
fileId: fileId,
|
||||
name: response.name || response.fileName || `File_${fileId}`,
|
||||
ext: response.extension || response.ext || (response.name ? response.name.split('.').pop() : 'txt'),
|
||||
type: response.mimeType || response.type || 'application/octet-stream',
|
||||
size: response.size || 0,
|
||||
downloadUrl: response.downloadUrl || response.url
|
||||
};
|
||||
|
||||
console.log(`✅ File ${fileId} metadata processed:`, document.name);
|
||||
return document;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to fetch metadata for file ${fileId}:`, error);
|
||||
// Return a fallback object for failed requests
|
||||
return {
|
||||
id: fileId.toString(),
|
||||
fileId: fileId,
|
||||
name: `File_${fileId}`,
|
||||
ext: 'unknown',
|
||||
type: 'application/octet-stream',
|
||||
size: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
documents = await Promise.all(documentPromises);
|
||||
}
|
||||
|
||||
// Try different possible field names for content (API uses 'message', some legacy might use 'content')
|
||||
const possibleContent = workflowMessage.message ||
|
||||
workflowMessage.content ||
|
||||
(workflowMessage as any).text ||
|
||||
(workflowMessage as any).body ||
|
||||
'';
|
||||
|
||||
const transformedMessage = {
|
||||
id: workflowMessage.id,
|
||||
role: workflowMessage.role,
|
||||
agentName: workflowMessage.role === 'user' ? 'You' : 'Assistant',
|
||||
content: possibleContent,
|
||||
timestamp: workflowMessage.publishedAt || workflowMessage.timestamp,
|
||||
documents: documents
|
||||
};
|
||||
|
||||
return transformedMessage;
|
||||
};
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({
|
||||
workflowState,
|
||||
onFilePreview
|
||||
}) => {
|
||||
|
||||
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [transformedMessages, setTransformedMessages] = React.useState<any[]>([]);
|
||||
const [isTransforming, setIsTransforming] = React.useState(false);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = React.useState(false);
|
||||
const lastMessageCountRef = React.useRef(0);
|
||||
const [workflowProgress, setWorkflowProgress] = React.useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
isLoading: boolean;
|
||||
taskBreakdown?: string;
|
||||
} | null>(null);
|
||||
const [showProgressBar, setShowProgressBar] = React.useState(false);
|
||||
const [progressText, setProgressText] = React.useState('');
|
||||
const [progressPercentage, setProgressPercentage] = React.useState(0);
|
||||
const [maxCompletedTasks, setMaxCompletedTasks] = React.useState(0);
|
||||
|
||||
// Calculate workflow progress when messages or logs change
|
||||
// ONE single effect to handle all progress logic
|
||||
React.useEffect(() => {
|
||||
console.log('🔄 Progress calculation triggered:', {
|
||||
messagesCount: transformedMessages.length,
|
||||
logsCount: workflowState.logs?.length || 0,
|
||||
logs: workflowState.logs?.map(l => l.message) || []
|
||||
});
|
||||
const pendingMessages = workflowState.pendingMessages || [];
|
||||
const logs = workflowState.logs || [];
|
||||
|
||||
const progress = analyzeWorkflowProgressFromMessages(transformedMessages, workflowState.logs || []);
|
||||
// Show progress bar if user sent message or logs exist
|
||||
if ((pendingMessages.length > 0 || logs.length > 0) && !showProgressBar) {
|
||||
setShowProgressBar(true);
|
||||
setProgressPercentage(0);
|
||||
setMaxCompletedTasks(0);
|
||||
}
|
||||
|
||||
console.log('📊 Progress result:', progress);
|
||||
// Update text based on current state
|
||||
if (logs.length === 0 && pendingMessages.length > 0) {
|
||||
setProgressText(t('chat.messages.loading_progress'));
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowProgress(progress);
|
||||
}, [transformedMessages.length, JSON.stringify(transformedMessages.map(m => m.content)), workflowState.logs?.length, JSON.stringify(workflowState.logs?.map(l => l.message))]);
|
||||
if (logs.length > 0) {
|
||||
let totalTasks = 0;
|
||||
let completedTasks = 0;
|
||||
|
||||
logs.forEach(log => {
|
||||
const message = log.message || '';
|
||||
|
||||
const taskStartMatch = message.match(/Executing task (\d+)\/(\d+)/i);
|
||||
if (taskStartMatch) {
|
||||
totalTasks = Math.max(totalTasks, parseInt(taskStartMatch[2]));
|
||||
}
|
||||
|
||||
const taskCompleteMatch = message.match(/✅.*Task (\d+).*completed/i);
|
||||
if (taskCompleteMatch) {
|
||||
completedTasks = Math.max(completedTasks, parseInt(taskCompleteMatch[1]));
|
||||
}
|
||||
});
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const currentPercentage = Math.round((completedTasks / totalTasks) * 100);
|
||||
setProgressText(`${completedTasks}/${totalTasks} ${t('chat.messages.tasks')} (${currentPercentage}%)`);
|
||||
|
||||
// ONLY update percentage if we completed more tasks than before
|
||||
if (completedTasks > maxCompletedTasks) {
|
||||
setMaxCompletedTasks(completedTasks);
|
||||
setProgressPercentage(currentPercentage);
|
||||
}
|
||||
} else {
|
||||
setProgressText(`${t('chat.messages.analyzing_workflow')} (${logs.length} ${t('chat.messages.logs')})`);
|
||||
}
|
||||
}
|
||||
}, [workflowState.pendingMessages, workflowState.logs, showProgressBar, maxCompletedTasks, t]);
|
||||
|
||||
// Clear progress bar when workflow changes
|
||||
React.useEffect(() => {
|
||||
if (!workflowState.currentWorkflowId) {
|
||||
setShowProgressBar(false);
|
||||
setProgressText('');
|
||||
setProgressPercentage(0);
|
||||
setMaxCompletedTasks(0);
|
||||
}
|
||||
}, [workflowState.currentWorkflowId]);
|
||||
|
||||
// Create merged timeline of messages and logs
|
||||
const timeline = React.useMemo(() => {
|
||||
return mergeMessagesAndLogs(transformedMessages, workflowState.logs || []);
|
||||
}, [transformedMessages, workflowState.logs]);
|
||||
|
|
@ -482,23 +98,33 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
// Transform messages when workflow messages change
|
||||
React.useEffect(() => {
|
||||
const transformMessages = async () => {
|
||||
if (!workflowState.messages || workflowState.messages.length === 0) {
|
||||
// Use pending messages for immediate display, backend messages for confirmed content
|
||||
// Since the workflow manager already filters out duplicate user messages,
|
||||
// we need to combine them properly: pending first, then backend messages
|
||||
const pendingMessages = workflowState.pendingMessages || [];
|
||||
const backendMessages = workflowState.messages || [];
|
||||
|
||||
|
||||
|
||||
// Create a simple combined list - pending messages will be user messages,
|
||||
// backend messages will be mostly assistant messages (user messages filtered out)
|
||||
const allMessages = [...pendingMessages, ...backendMessages];
|
||||
|
||||
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
setTransformedMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTransforming(true);
|
||||
|
||||
try {
|
||||
const transformed = await Promise.all(
|
||||
workflowState.messages.map(async (msg: WorkflowMessage) => {
|
||||
|
||||
return await transformWorkflowMessage(msg, request);
|
||||
})
|
||||
allMessages.map(msg => transformWorkflowMessage(msg, request))
|
||||
);
|
||||
setTransformedMessages(transformed);
|
||||
} catch (error) {
|
||||
console.error('❌ Error transforming messages:', error);
|
||||
console.error('Error transforming messages:', error);
|
||||
setTransformedMessages([]);
|
||||
} finally {
|
||||
setIsTransforming(false);
|
||||
|
|
@ -506,23 +132,18 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
};
|
||||
|
||||
transformMessages();
|
||||
}, [workflowState.messages, request]);
|
||||
}, [workflowState.messages, workflowState.pendingMessages, request]);
|
||||
|
||||
// Check if user is scrolled near the bottom
|
||||
// Scroll handling
|
||||
const checkScrollPosition = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// Consider "near bottom" if within 100px of the bottom
|
||||
const isNearBottom = distanceFromBottom < 100;
|
||||
setIsUserScrolledUp(!isNearBottom);
|
||||
|
||||
setIsUserScrolledUp(distanceFromBottom > 100);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
|
|
@ -530,34 +151,27 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Auto-scroll when new items arrive (only if user is near bottom)
|
||||
// Auto-scroll on new messages
|
||||
React.useEffect(() => {
|
||||
const currentTimelineCount = timeline.length;
|
||||
const hadItems = lastMessageCountRef.current > 0;
|
||||
const hasNewItems = currentTimelineCount > lastMessageCountRef.current;
|
||||
const currentCount = timeline.length;
|
||||
const hasNewItems = currentCount > lastMessageCountRef.current;
|
||||
|
||||
if (hasNewItems && hadItems && !isUserScrolledUp) {
|
||||
// Small delay to ensure DOM is updated
|
||||
if (hasNewItems && !isUserScrolledUp) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
|
||||
lastMessageCountRef.current = currentTimelineCount;
|
||||
lastMessageCountRef.current = currentCount;
|
||||
}, [timeline.length, isUserScrolledUp, scrollToBottom]);
|
||||
|
||||
// Scroll to bottom on initial load
|
||||
React.useEffect(() => {
|
||||
if (timeline.length > 0 && lastMessageCountRef.current === 0) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}, [timeline.length, scrollToBottom]);
|
||||
|
||||
const { currentWorkflowId, isLoading, error } = workflowState;
|
||||
|
||||
const isEmpty = timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId;
|
||||
|
||||
return (
|
||||
<div className={messageStyles.message_list_container}>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={messageStyles.chat_messages}
|
||||
className={isEmpty ? messageStyles.chat_messages_empty : messageStyles.chat_messages}
|
||||
onScroll={checkScrollPosition}
|
||||
>
|
||||
{error && (
|
||||
|
|
@ -565,28 +179,25 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className={messageStyles.messages_container}>
|
||||
{timeline.map((timelineItem, index) => {
|
||||
if (timelineItem.type === 'message') {
|
||||
const message = timelineItem.item;
|
||||
const { type, item } = timelineItem;
|
||||
|
||||
if (type === 'message') {
|
||||
return (
|
||||
<MessageItem
|
||||
key={`message-${message.id}`}
|
||||
message={message}
|
||||
key={`message-${item.id}`}
|
||||
message={item}
|
||||
index={index}
|
||||
onFilePreview={onFilePreview}
|
||||
/>
|
||||
);
|
||||
} else if (timelineItem.type === 'log') {
|
||||
const log = timelineItem.item;
|
||||
|
||||
} else if (type === 'log') {
|
||||
return (
|
||||
<LogItem
|
||||
key={`log-${log.id}`}
|
||||
log={log}
|
||||
key={`log-${item.id}`}
|
||||
log={item}
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
|
|
@ -595,82 +206,41 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||
})}
|
||||
</div>
|
||||
|
||||
{(isLoading || isTransforming) && (
|
||||
<div className={messageStyles.message_loading}>
|
||||
{isTransforming ? 'Processing messages...' : 'Loading messages...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId && (
|
||||
{isEmpty && (
|
||||
<div className={messageStyles.message_empty_state}>
|
||||
No workflow selected. Start a conversation to create a new workflow.
|
||||
<div className={messageStyles.message_empty_state_icon}>
|
||||
<IoIosChatbubbles />
|
||||
</div>
|
||||
<h3>{t('chat.messages.no_workflow_selected')}</h3>
|
||||
<p>{t('chat.messages.no_workflow_selected_description')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeline.length === 0 && !isLoading && !isTransforming && currentWorkflowId && (
|
||||
<div className={messageStyles.message_empty_state}>
|
||||
No messages or logs in this workflow yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Workflow Progress Bar - positioned outside scrollable area */}
|
||||
{(workflowProgress || workflowState.logs?.length > 0) && (
|
||||
{/* Progress Bar */}
|
||||
{showProgressBar && (
|
||||
<div className={messageStyles.workflow_progress_container}>
|
||||
{workflowProgress ? (
|
||||
<>
|
||||
<div className={messageStyles.workflow_progress_label}>
|
||||
<span>Workflow Progress</span>
|
||||
<span>
|
||||
{workflowProgress.isLoading
|
||||
? 'Loading tasks...'
|
||||
: `${workflowProgress.current}/${workflowProgress.total} Actions (${workflowProgress.percentage}%)`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{workflowProgress.taskBreakdown && !workflowProgress.isLoading && (
|
||||
<div className={messageStyles.workflow_progress_breakdown}>
|
||||
{workflowProgress.taskBreakdown}
|
||||
</div>
|
||||
)}
|
||||
<div className={messageStyles.workflow_progress_bar}>
|
||||
<div
|
||||
className={`${messageStyles.workflow_progress_fill} ${
|
||||
workflowProgress.isLoading ? messageStyles.loading : ''
|
||||
}`}
|
||||
style={{ width: workflowProgress.isLoading ? '0%' : `${workflowProgress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={messageStyles.workflow_progress_label}>
|
||||
<span>Workflow Progress</span>
|
||||
<span>Analyzing workflow... ({workflowState.logs?.length || 0} logs)</span>
|
||||
<span>{t('chat.messages.workflow_progress')}</span>
|
||||
<span>{progressText}</span>
|
||||
</div>
|
||||
<div className={messageStyles.workflow_progress_bar}>
|
||||
<div
|
||||
className={`${messageStyles.workflow_progress_fill} ${messageStyles.loading}`}
|
||||
style={{ width: '0%' }}
|
||||
className={messageStyles.workflow_progress_fill}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll to bottom button - positioned relative to message list container */}
|
||||
<button
|
||||
className={`${messageStyles.scroll_to_bottom_btn} ${
|
||||
(isUserScrolledUp && timeline.length > 0)
|
||||
? messageStyles.visible
|
||||
: messageStyles.hidden
|
||||
(isUserScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
|
||||
}`}
|
||||
onClick={scrollToBottom}
|
||||
title="Scroll to bottom"
|
||||
title={t('chat.messages.scroll_to_bottom_btn')}
|
||||
>
|
||||
⬇️
|
||||
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,44 @@
|
|||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 15px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chat_messages_empty {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 15px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.message_empty_state_icon {
|
||||
font-size: 90px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.message_empty_state h3 {
|
||||
font-family: var(--font-family);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message_empty_state p {
|
||||
font-family: var(--font-family);
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chat_messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
|
@ -308,6 +340,7 @@
|
|||
.message_empty_state {
|
||||
color: var(--color-gray);
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
import { WorkflowLog, WorkflowProgress, TimelineItem } from './dashboardChatAreaTypes';
|
||||
|
||||
// Helper function to parse task and action progress from log messages
|
||||
const parseLogProgress = (logMessage: string) => {
|
||||
const message = logMessage.trim();
|
||||
|
||||
// Task start: "Executing task X/Y"
|
||||
const taskStartMatch = message.match(/^Executing task (\d+)\/(\d+)$/i);
|
||||
if (taskStartMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(taskStartMatch[1]),
|
||||
totalTasks: parseInt(taskStartMatch[2]),
|
||||
type: 'task_start' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Action start: "Task X - Starting action Y/Z"
|
||||
const actionStartMatch = message.match(/^Task (\d+) - Starting action (\d+)\/(\d+)$/i);
|
||||
if (actionStartMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(actionStartMatch[1]),
|
||||
actionNumber: parseInt(actionStartMatch[2]),
|
||||
totalActions: parseInt(actionStartMatch[3]),
|
||||
type: 'action_start' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Action complete: "✅ Task X - Action Y/Z completed"
|
||||
const actionCompleteMatch = message.match(/^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i);
|
||||
if (actionCompleteMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(actionCompleteMatch[1]),
|
||||
actionNumber: parseInt(actionCompleteMatch[2]),
|
||||
totalActions: parseInt(actionCompleteMatch[3]),
|
||||
isCompleted: true,
|
||||
type: 'action_complete' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Task complete: "🎯 Task X/Y completed"
|
||||
const taskCompleteMatch = message.match(/^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i);
|
||||
if (taskCompleteMatch) {
|
||||
return {
|
||||
taskNumber: parseInt(taskCompleteMatch[1]),
|
||||
totalTasks: parseInt(taskCompleteMatch[2]),
|
||||
isCompleted: true,
|
||||
type: 'task_complete' as const
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'unknown' as const };
|
||||
};
|
||||
|
||||
// Calculate workflow progress from messages and logs
|
||||
export const calculateWorkflowProgress = (messages: any[], logs: WorkflowLog[] = []): WorkflowProgress | null => {
|
||||
if (messages.length === 0 && logs.length === 0) return null;
|
||||
|
||||
// Check if waiting for assistant response
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage?.role === 'user') {
|
||||
return { current: 0, total: 0, percentage: 0, isLoading: true };
|
||||
}
|
||||
|
||||
// Parse logs for task structure
|
||||
let totalTasks = 0;
|
||||
const taskActionCounts: { [taskNumber: number]: { total: number; completedActions: Set<number> } } = {};
|
||||
|
||||
// Analyze logs
|
||||
logs.forEach((log) => {
|
||||
if (!log.message) return;
|
||||
|
||||
const progress = parseLogProgress(log.message);
|
||||
|
||||
if (progress.type === 'task_start' && progress.totalTasks) {
|
||||
totalTasks = Math.max(totalTasks, progress.totalTasks);
|
||||
}
|
||||
|
||||
if (progress.type === 'action_start' && progress.taskNumber && progress.totalActions) {
|
||||
const taskNum = progress.taskNumber;
|
||||
if (!taskActionCounts[taskNum]) {
|
||||
taskActionCounts[taskNum] = { total: 0, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNum].total = Math.max(taskActionCounts[taskNum].total, progress.totalActions);
|
||||
}
|
||||
|
||||
if (progress.type === 'action_complete' && progress.taskNumber && progress.actionNumber && progress.totalActions) {
|
||||
const taskNum = progress.taskNumber;
|
||||
if (!taskActionCounts[taskNum]) {
|
||||
taskActionCounts[taskNum] = { total: progress.totalActions, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNum].completedActions.add(progress.actionNumber);
|
||||
taskActionCounts[taskNum].total = Math.max(taskActionCounts[taskNum].total, progress.totalActions);
|
||||
}
|
||||
|
||||
if (progress.type === 'task_complete' && progress.taskNumber && progress.totalTasks) {
|
||||
const taskNum = progress.taskNumber;
|
||||
totalTasks = Math.max(totalTasks, progress.totalTasks);
|
||||
// Mark all actions complete for this task
|
||||
if (taskActionCounts[taskNum]) {
|
||||
const task = taskActionCounts[taskNum];
|
||||
for (let i = 1; i <= task.total; i++) {
|
||||
task.completedActions.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze messages for additional action completion patterns
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== 'assistant' || !message.content) return;
|
||||
|
||||
const actionCompleteMatch = message.content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i);
|
||||
if (actionCompleteMatch) {
|
||||
const taskNumber = parseInt(actionCompleteMatch[1]);
|
||||
|
||||
if (actionCompleteMatch[2] && actionCompleteMatch[3]) {
|
||||
const actionNumber = parseInt(actionCompleteMatch[2]);
|
||||
const totalActions = parseInt(actionCompleteMatch[3]);
|
||||
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: totalActions, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNumber].completedActions.add(actionNumber);
|
||||
taskActionCounts[taskNumber].total = Math.max(taskActionCounts[taskNumber].total, totalActions);
|
||||
} else {
|
||||
// Named action without numbers - treat as 1/1
|
||||
if (!taskActionCounts[taskNumber]) {
|
||||
taskActionCounts[taskNumber] = { total: 1, completedActions: new Set() };
|
||||
}
|
||||
taskActionCounts[taskNumber].completedActions.add(1);
|
||||
if (taskActionCounts[taskNumber].total === 0) {
|
||||
taskActionCounts[taskNumber].total = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate completed tasks
|
||||
let completedTasks = 0;
|
||||
for (let taskNum = 1; taskNum <= totalTasks; taskNum++) {
|
||||
const task = taskActionCounts[taskNum];
|
||||
if (task && task.total > 0 && task.completedActions.size === task.total) {
|
||||
completedTasks++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const percentage = Math.round((completedTasks / totalTasks) * 100);
|
||||
return {
|
||||
current: completedTasks,
|
||||
total: totalTasks,
|
||||
percentage: percentage,
|
||||
isLoading: false
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Safe date parsing with fallback
|
||||
export const safeParseDate = (timestamp: any, fallback: number = Date.now()): Date => {
|
||||
if (!timestamp) return new Date(fallback);
|
||||
|
||||
let dateToTry = timestamp;
|
||||
|
||||
if (typeof timestamp === 'number') {
|
||||
dateToTry = timestamp < 10000000000 ? timestamp * 1000 : timestamp;
|
||||
} else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) {
|
||||
const numericTimestamp = parseInt(timestamp);
|
||||
dateToTry = numericTimestamp < 10000000000 ? numericTimestamp * 1000 : numericTimestamp;
|
||||
} else if (timestamp instanceof Date) {
|
||||
dateToTry = timestamp;
|
||||
}
|
||||
|
||||
const date = new Date(dateToTry);
|
||||
return isNaN(date.getTime()) ? new Date(fallback) : date;
|
||||
};
|
||||
|
||||
// Merge and sort messages and logs by timestamp
|
||||
export const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): TimelineItem[] => {
|
||||
const combined: TimelineItem[] = [];
|
||||
|
||||
// Add messages
|
||||
messages.forEach((message, index) => {
|
||||
const rawTimestamp = message.timestamp || message.publishedAt;
|
||||
const fallbackTime = Date.now() - ((messages.length + logs.length) - index) * 1000;
|
||||
const timestamp = safeParseDate(rawTimestamp, fallbackTime);
|
||||
|
||||
combined.push({
|
||||
type: 'message',
|
||||
item: message,
|
||||
timestamp
|
||||
});
|
||||
});
|
||||
|
||||
// Add logs
|
||||
logs.forEach((log, index) => {
|
||||
const fallbackTime = Date.now() - ((logs.length) - index) * 1000;
|
||||
const timestamp = safeParseDate(log.timestamp, fallbackTime);
|
||||
|
||||
combined.push({
|
||||
type: 'log',
|
||||
item: log,
|
||||
timestamp
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp
|
||||
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return combined;
|
||||
};
|
||||
|
||||
// Transform workflow message to display message
|
||||
export const transformWorkflowMessage = async (workflowMessage: any, request: any): Promise<any> => {
|
||||
let documents: any[] = [];
|
||||
|
||||
// Handle documents
|
||||
if (workflowMessage.documents && workflowMessage.documents.length > 0) {
|
||||
documents = workflowMessage.documents.map((doc: any) => ({
|
||||
id: doc.id || doc.fileId,
|
||||
fileId: typeof doc.fileId === 'string' ? parseInt(doc.fileId) : doc.fileId,
|
||||
name: doc.filename,
|
||||
ext: doc.filename.split('.').pop() || 'unknown',
|
||||
type: doc.mimeType,
|
||||
size: doc.fileSize,
|
||||
downloadUrl: `/api/workflows/files/${doc.fileId}/download`
|
||||
}));
|
||||
} else if (workflowMessage.fileIds && workflowMessage.fileIds.length > 0) {
|
||||
// Legacy fileIds approach
|
||||
const documentPromises = workflowMessage.fileIds.map(async (fileId: number) => {
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/api/workflows/files/${fileId}/preview`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return {
|
||||
id: fileId.toString(),
|
||||
fileId: fileId,
|
||||
name: response.name || response.fileName || `File_${fileId}`,
|
||||
ext: response.extension || response.ext || (response.name ? response.name.split('.').pop() : 'txt'),
|
||||
type: response.mimeType || response.type || 'application/octet-stream',
|
||||
size: response.size || 0,
|
||||
downloadUrl: response.downloadUrl || response.url
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: fileId.toString(),
|
||||
fileId: fileId,
|
||||
name: `File_${fileId}`,
|
||||
ext: 'unknown',
|
||||
type: 'application/octet-stream',
|
||||
size: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
documents = await Promise.all(documentPromises);
|
||||
}
|
||||
|
||||
const content = workflowMessage.message ||
|
||||
workflowMessage.content ||
|
||||
(workflowMessage as any).text ||
|
||||
(workflowMessage as any).body ||
|
||||
'';
|
||||
|
||||
return {
|
||||
id: workflowMessage.id,
|
||||
role: workflowMessage.role,
|
||||
agentName: workflowMessage.role === 'user' ? 'You' : 'Assistant',
|
||||
content: content,
|
||||
timestamp: workflowMessage.publishedAt || workflowMessage.timestamp,
|
||||
documents: documents
|
||||
};
|
||||
};
|
||||
|
|
@ -122,6 +122,7 @@ export interface WorkflowState {
|
|||
currentWorkflowId: string | null;
|
||||
workflow: Workflow | null;
|
||||
messages: WorkflowMessage[];
|
||||
pendingMessages: WorkflowMessage[]; // Messages sent but not yet confirmed by backend
|
||||
logs: WorkflowLog[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -165,4 +166,42 @@ export interface Message {
|
|||
content: string;
|
||||
timestamp?: string;
|
||||
documents?: Document[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface InputAreaProps {
|
||||
selectedPrompt?: Prompt | null;
|
||||
workflowState: WorkflowState;
|
||||
workflowActions: WorkflowActions;
|
||||
attachedFiles?: AttachedFile[];
|
||||
onAttachedFilesChange?: (files: AttachedFile[]) => void;
|
||||
}
|
||||
|
||||
export interface AttachedFile {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
fileData?: File;
|
||||
objectUrl?: string;
|
||||
}
|
||||
|
||||
export interface MessageListProps {
|
||||
workflowState: WorkflowState;
|
||||
onFilePreview?: (file: any) => void;
|
||||
}
|
||||
|
||||
// Progress bar interfaces
|
||||
export interface WorkflowProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
isLoading: boolean;
|
||||
taskBreakdown?: string;
|
||||
}
|
||||
|
||||
// Timeline item for merged messages and logs
|
||||
export interface TimelineItem {
|
||||
type: 'message' | 'log';
|
||||
item: any;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
// Core state
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(initialWorkflowId || null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [pendingMessages, setPendingMessages] = useState<any[]>([]);
|
||||
const [sentUserMessages, setSentUserMessages] = useState<Set<string>>(new Set()); // Track sent user messages
|
||||
const pollingIntervalRef = useRef<number | null>(null);
|
||||
|
||||
// Hook-based data fetching
|
||||
|
|
@ -18,12 +20,40 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
// Use status for real-time updates, fallback to workflow for initial data
|
||||
const currentWorkflow = workflowStatus || workflow;
|
||||
|
||||
|
||||
// Helper to create optimistic user message
|
||||
const createOptimisticMessage = useCallback((prompt: string, fileIds: number[] = []) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: `temp-${Date.now()}`,
|
||||
workflowId: currentWorkflowId || 'pending',
|
||||
message: prompt,
|
||||
role: 'user' as const,
|
||||
status: 'pending',
|
||||
sequenceNr: 0,
|
||||
publishedAt: timestamp,
|
||||
success: true,
|
||||
fileIds: fileIds.length > 0 ? fileIds : undefined
|
||||
};
|
||||
}, [currentWorkflowId]);
|
||||
|
||||
// Combined loading and error states
|
||||
const isLoading = workflowLoading || statusLoading || messagesLoading || logsLoading;
|
||||
const error = workflowError || statusError || messagesError || logsError;
|
||||
|
||||
// Filter out ALL user messages from backend - we only show user messages from pendingMessages
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (!messages) return [];
|
||||
|
||||
// If we've tracked ANY sent messages, always filter out user messages from backend
|
||||
// This prevents flickering during new workflow creation
|
||||
if (sentUserMessages.size > 0) {
|
||||
return messages.filter(msg => msg.role !== 'user');
|
||||
}
|
||||
|
||||
// If no tracked sent messages, this is a historical workflow - show all messages
|
||||
return messages;
|
||||
}, [messages, sentUserMessages]);
|
||||
|
||||
// Auto-polling for active workflows and message updates
|
||||
useEffect(() => {
|
||||
if (isPolling && currentWorkflowId) {
|
||||
|
|
@ -53,6 +83,13 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
}, []);
|
||||
|
||||
const startNewWorkflow = useCallback(async (prompt: string, fileIds: number[] = []): Promise<string | null> => {
|
||||
// Add optimistic message immediately
|
||||
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
|
||||
setPendingMessages(prev => [...prev, optimisticMessage]);
|
||||
|
||||
// Track this message as sent
|
||||
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
|
||||
|
||||
const workflowData: StartWorkflowRequest = {
|
||||
prompt,
|
||||
listFileId: fileIds
|
||||
|
|
@ -69,13 +106,28 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
refetchLogs();
|
||||
}, 500);
|
||||
return newWorkflowId;
|
||||
} else {
|
||||
// Remove optimistic message and sent tracking on failure
|
||||
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
|
||||
setSentUserMessages(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(prompt.trim());
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [startWorkflow, refetchMessages]);
|
||||
}, [startWorkflow, refetchMessages, createOptimisticMessage]);
|
||||
|
||||
const continueWorkflow = useCallback(async (prompt: string, fileIds: number[] = []): Promise<boolean> => {
|
||||
if (!currentWorkflowId) return false;
|
||||
|
||||
// Add optimistic message immediately
|
||||
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
|
||||
setPendingMessages(prev => [...prev, optimisticMessage]);
|
||||
|
||||
// Track this message as sent
|
||||
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
|
||||
|
||||
const workflowData: StartWorkflowRequest = {
|
||||
prompt,
|
||||
listFileId: fileIds
|
||||
|
|
@ -90,9 +142,17 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
refetchLogs();
|
||||
}, 500);
|
||||
return true;
|
||||
} else {
|
||||
// Remove optimistic message and sent tracking on failure
|
||||
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
|
||||
setSentUserMessages(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(prompt.trim());
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs]);
|
||||
}, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs, createOptimisticMessage]);
|
||||
|
||||
const stopWorkflow = useCallback(async (): Promise<boolean> => {
|
||||
if (!currentWorkflowId) return false;
|
||||
|
|
@ -113,8 +173,32 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
const clearWorkflow = useCallback(() => {
|
||||
setCurrentWorkflowId(null);
|
||||
setIsPolling(false);
|
||||
setPendingMessages([]);
|
||||
setSentUserMessages(new Set());
|
||||
}, []);
|
||||
|
||||
// Only clear pending messages when workflow changes to a different ID
|
||||
// (not when creating a new workflow)
|
||||
const previousWorkflowId = useRef(currentWorkflowId);
|
||||
useEffect(() => {
|
||||
const prev = previousWorkflowId.current;
|
||||
const current = currentWorkflowId;
|
||||
|
||||
// If we're switching between different existing workflows, clear state
|
||||
if (prev && current && prev !== current) {
|
||||
setPendingMessages([]);
|
||||
setSentUserMessages(new Set());
|
||||
}
|
||||
// If we're going from a workflow to no workflow, clear state
|
||||
else if (prev && !current) {
|
||||
setPendingMessages([]);
|
||||
setSentUserMessages(new Set());
|
||||
}
|
||||
// If we're going from no workflow to a workflow (new workflow creation), keep pending messages
|
||||
|
||||
previousWorkflowId.current = current;
|
||||
}, [currentWorkflowId]);
|
||||
|
||||
// Sync with external workflow ID changes
|
||||
useEffect(() => {
|
||||
if (initialWorkflowId !== currentWorkflowId) {
|
||||
|
|
@ -140,8 +224,9 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
|
|||
const state: WorkflowState = {
|
||||
currentWorkflowId,
|
||||
workflow: currentWorkflow,
|
||||
messages,
|
||||
logs,
|
||||
messages: filteredMessages,
|
||||
pendingMessages,
|
||||
logs: logs || [],
|
||||
isLoading,
|
||||
error
|
||||
};
|
||||
|
|
|
|||
|
|
@ -232,7 +232,15 @@ export default {
|
|||
'chat_history.resume_tooltip': 'Workflow fortsetzen',
|
||||
'chat_history.delete_tooltip': 'Workflow löschen',
|
||||
'chat_history.deleting': 'Workflow wird gelöscht...',
|
||||
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'Noch keinen Workflow ausgewählt',
|
||||
'chat.messages.no_workflow_selected_description': 'Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow',
|
||||
'chat.messages.loading_progress': 'Lade Fortschritt...',
|
||||
'chat.messages.tasks': 'Aufgaben',
|
||||
'chat.messages.workflow_progress': 'Workflow Fortschritt',
|
||||
'chat.messages.analyzing_workflow': 'Analysiere Workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Nach unten scrollen',
|
||||
// Workflow Status
|
||||
'status.error': 'FEHLER',
|
||||
'status.failed': 'FEHLGESCHLAGEN',
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@ export default {
|
|||
'chat_history.delete_tooltip': 'Delete workflow',
|
||||
'chat_history.deleting': 'Deleting workflow...',
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'No workflow selected',
|
||||
'chat.messages.no_workflow_selected_description': 'Select a workflow from the list or start a new workflow',
|
||||
'chat.messages.loading_progress': 'Loading progress...',
|
||||
'chat.messages.tasks': 'Tasks',
|
||||
'chat.messages.workflow_progress': 'Workflow Progress',
|
||||
'chat.messages.analyzing_workflow': 'Analyzing workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Scroll to bottom',
|
||||
// Workflow Status
|
||||
'status.error': 'ERROR',
|
||||
'status.failed': 'FAILED',
|
||||
|
|
|
|||
|
|
@ -233,6 +233,15 @@ export default {
|
|||
'chat_history.delete_tooltip': 'Supprimer le workflow',
|
||||
'chat_history.deleting': 'Suppression du workflow...',
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'Aucun workflow sélectionné',
|
||||
'chat.messages.no_workflow_selected_description': 'Sélectionnez un workflow dans la liste ou démarrez un nouveau workflow',
|
||||
'chat.messages.loading_progress': 'Chargement du progrès...',
|
||||
'chat.messages.tasks': 'Tâches',
|
||||
'chat.messages.workflow_progress': 'Progression du workflow',
|
||||
'chat.messages.analyzing_workflow': 'Analyse du workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Faire défiler vers le bas',
|
||||
|
||||
// Workflow Status
|
||||
'status.error': 'ERREUR',
|
||||
'status.failed': 'ÉCHEC',
|
||||
|
|
|
|||
Loading…
Reference in a new issue