frontend_nyla/src/hooks/playground/useDashboardInputForm.ts

785 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useApiRequest } from '../useApi';
import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext';
import { useFileContext } from '../../contexts/FileContext';
import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes';
import { usePrompts } from '../usePrompts';
import { usePermissions } from '../usePermissions';
import { deleteFileFromMessageApi } from '../../api/workflowApi';
import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
import { useWorkflows } from './useWorkflows';
import { useDashboardLogTree } from './useDashboardLogTree';
import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils';
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
export interface WorkflowFile {
id: string;
fileId: string;
fileName: string;
fileSize: number;
mimeType: string;
messageId?: string;
source?: 'user_uploaded' | 'ai_created';
}
export function useDashboardInputForm() {
const [inputValue, setInputValue] = useState<string>('');
const [pendingFiles, setPendingFiles] = useState<WorkflowFile[]>([]);
const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false);
const [optimisticMessage, setOptimisticMessage] = useState<WorkflowMessage | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
const { checkPermission, canView } = usePermissions();
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(false);
const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null);
const [promptPermission, setPromptPermission] = useState<any>(null);
const [filePermission, setFilePermission] = useState<any>(null);
const { selectedWorkflowId, selectWorkflow: selectWorkflowFromContext, clearWorkflow: clearWorkflowFromContext } = useWorkflowSelection();
const {
workflowId,
workflowStatus,
currentRound,
isRunning,
isStopping,
startingWorkflow,
messages,
dashboardLogs,
unifiedContentLogs,
latestStats,
startWorkflow,
stopWorkflow,
resetWorkflow,
selectWorkflow,
setWorkflowStatusOptimistic
} = useWorkflowLifecycle();
// Dashboard log tree hook
const {
tree: dashboardTree,
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
updateCurrentRound,
getChildOperations
} = useDashboardLogTree();
// Ref to prevent infinite sync loops
const isSyncingRef = useRef(false);
const fileContext = useFileContext();
const { request } = useApiRequest();
const { prompts, loading: promptsLoading, permissions: promptsPermissions, fetchPromptById } = usePrompts();
useEffect(() => {
if (promptsPermissions) {
setPromptPermission(promptsPermissions);
}
}, [promptsPermissions]);
useEffect(() => {
const checkPermissions = async () => {
try {
const uiPerm = await canView('UI', 'playground');
setPlaygroundUIPermission(uiPerm);
if (uiPerm) {
const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow');
setChatWorkflowPermission(chatWorkflowPerm);
const promptPerm = await checkPermission('DATA', 'Prompt');
setPromptPermission(promptPerm);
const filePerm = await checkPermission('DATA', 'FileItem');
setFilePermission(filePerm);
}
} catch (error) {
}
};
checkPermissions();
}, [canView, checkPermission]);
// Sync context -> lifecycle: When context selection changes, update lifecycle
useEffect(() => {
if (isSyncingRef.current) return;
if (selectedWorkflowId && selectedWorkflowId !== workflowId) {
isSyncingRef.current = true;
selectWorkflow(selectedWorkflowId).finally(() => {
isSyncingRef.current = false;
});
} else if (!selectedWorkflowId && workflowId) {
// If context is cleared but lifecycle still has a workflow, reset lifecycle
isSyncingRef.current = true;
resetWorkflow();
isSyncingRef.current = false;
}
}, [selectedWorkflowId, workflowId, selectWorkflow, resetWorkflow]);
// Sync lifecycle -> context: When lifecycle workflowId changes, update context
useEffect(() => {
if (isSyncingRef.current) return;
if (workflowId && workflowId !== selectedWorkflowId) {
isSyncingRef.current = true;
selectWorkflowFromContext(workflowId);
isSyncingRef.current = false;
} else if (!workflowId && selectedWorkflowId) {
// If lifecycle is cleared but context still has selection, clear context
isSyncingRef.current = true;
clearWorkflowFromContext();
isSyncingRef.current = false;
}
}, [workflowId, selectedWorkflowId, selectWorkflowFromContext, clearWorkflowFromContext]);
useEffect(() => {
const handleSetInput = (event: CustomEvent<{ value: string }>) => {
const newValue = event.detail.value;
if (newValue && typeof newValue === 'string') {
setInputValue(newValue);
}
};
window.addEventListener('dashboardSetInput', handleSetInput as EventListener);
return () => {
window.removeEventListener('dashboardSetInput', handleSetInput as EventListener);
};
}, []);
const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows();
// Track processed log IDs to avoid reprocessing
const processedLogIdsRef = useRef<Set<string>>(new Set());
const lastWorkflowIdRef = useRef<string | null>(null);
const lastDashboardLogsLengthRef = useRef<number>(0);
// Clear processed logs when workflow changes
useEffect(() => {
if (workflowId !== lastWorkflowIdRef.current) {
processedLogIdsRef.current.clear();
lastWorkflowIdRef.current = workflowId || null;
lastDashboardLogsLengthRef.current = 0;
if (!workflowId) {
clearDashboard(true);
}
}
}, [workflowId, clearDashboard]);
// Process dashboard logs when they change (only new logs)
useEffect(() => {
if (!dashboardLogs || dashboardLogs.length === 0) {
lastDashboardLogsLengthRef.current = 0;
return;
}
// Only process if the array length changed (indicating new logs)
if (dashboardLogs.length === lastDashboardLogsLengthRef.current) {
return;
}
// Filter to only new logs that haven't been processed
const newLogs = dashboardLogs.filter(log => {
const logId = log.id || `${log.operationId}-${log.timestamp}`;
if (processedLogIdsRef.current.has(logId)) {
return false;
}
processedLogIdsRef.current.add(logId);
return true;
});
// Only process if there are new logs
if (newLogs.length > 0) {
// Convert API WorkflowLog format to LogTypes WorkflowLog format
const convertedLogs: LogTypesWorkflowLog[] = newLogs.map(log => ({
id: log.id || `${log.operationId || 'unknown'}-${log.timestamp || Date.now()}`,
workflowId: log.workflowId || '',
message: log.message || '',
type: log.type,
timestamp: log.timestamp || Date.now(),
status: log.status,
progress: log.progress,
performance: log.performance,
parentId: log.parentId,
operationId: log.operationId
}));
processDashboardLogs(convertedLogs);
}
lastDashboardLogsLengthRef.current = dashboardLogs.length;
}, [dashboardLogs, processDashboardLogs]);
// Update current round in dashboard tree when it changes
useEffect(() => {
if (currentRound !== undefined) {
updateCurrentRound(currentRound);
}
}, [currentRound, updateCurrentRound]);
const workflowFiles = useMemo(() => {
const fileMap = new Map<string, WorkflowFile>();
const pendingFileIds = new Set(pendingFiles.map(f => f.fileId));
const addFilesFromMessage = (message: WorkflowMessage, messageId: string) => {
const documents = (message as any).documents as MessageDocument[] | undefined;
const files = (message as any).files as any[] | undefined;
if (documents && Array.isArray(documents)) {
documents.forEach((doc: MessageDocument) => {
if (!doc.fileId || doc.fileId.trim() === '') return;
if (!fileMap.has(doc.fileId)) {
const source = pendingFileIds.has(doc.fileId) ? 'user_uploaded' : 'ai_created';
fileMap.set(doc.fileId, {
id: doc.id || doc.fileId,
fileId: doc.fileId,
fileName: doc.fileName || 'Unknown File',
fileSize: doc.fileSize || 0,
mimeType: doc.mimeType || 'application/octet-stream',
messageId: doc.messageId || messageId,
source
});
}
});
}
if (files && Array.isArray(files)) {
files.forEach((file: any) => {
const fileId = file.id || file.fileId;
if (!fileId || fileId.trim() === '') return;
if (!fileMap.has(fileId)) {
const source = pendingFileIds.has(fileId) ? 'user_uploaded' : 'ai_created';
fileMap.set(fileId, {
id: fileId,
fileId: fileId,
fileName: file.fileName || file.name || 'Unknown File',
fileSize: file.fileSize || file.size || 0,
mimeType: file.mimeType || file.mime_type || 'application/octet-stream',
messageId: messageId,
source
});
}
});
}
};
if (messages && messages.length > 0) {
messages.forEach((message: WorkflowMessage) => {
addFilesFromMessage(message, message.id);
});
}
if (optimisticMessage) {
addFilesFromMessage(optimisticMessage, optimisticMessage.id || 'optimistic');
}
return Array.from(fileMap.values());
}, [messages, pendingFiles, optimisticMessage]);
useEffect(() => {
if (!messages || messages.length === 0) return;
const messageTexts = new Set<string>();
messages.forEach((message: WorkflowMessage) => {
if (message.message) {
messageTexts.add(message.message.trim());
}
});
if (optimisticMessage && optimisticMessage.message) {
const optimisticText = optimisticMessage.message.trim();
const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage);
const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) =>
msg.message && msg.message.trim() === optimisticText
);
if (matchingMessage) {
const matchingFileIds = extractFileIdsFromMessage(matchingMessage);
if (optimisticFileIds.size > 0) {
const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId =>
matchingFileIds.has(fileId)
);
if (allFilesConfirmed && matchingFileIds.size > 0) {
setOptimisticMessage(null);
}
} else {
if (messageTexts.has(optimisticText)) {
setOptimisticMessage(null);
}
}
}
}
}, [messages, optimisticMessage]);
const displayMessages = useMemo(() => {
const optimisticText = optimisticMessage?.message?.trim();
const processedMessages = (messages || []).map((message: WorkflowMessage) => {
const files = (message as any).files as any[] | undefined;
const documents = (message as any).documents as MessageDocument[] | undefined;
if (files && Array.isArray(files) && (!documents || documents.length === 0)) {
return {
...message,
documents: convertFilesToDocuments(files, message.id)
};
}
return message;
});
let replacedMessageTimestamp: number | undefined;
const filteredMessages = processedMessages.filter((message: WorkflowMessage) => {
const isUserMessage = message.role?.toLowerCase() === 'user';
const messageText = message.message?.trim();
if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) {
const documents = (message as any).documents as MessageDocument[] | undefined;
const files = (message as any).files as any[] | undefined;
const hasDocuments = documents && Array.isArray(documents) && documents.length > 0;
const hasFiles = files && Array.isArray(files) && files.length > 0;
if (hasDocuments || hasFiles) {
return true;
}
if (message.publishedAt !== undefined) {
replacedMessageTimestamp = message.publishedAt;
}
return false;
}
return true;
});
const allMessages = [...filteredMessages];
if (optimisticMessage) {
const optimisticWithTimestamp = replacedMessageTimestamp !== undefined
? { ...optimisticMessage, publishedAt: replacedMessageTimestamp }
: optimisticMessage;
allMessages.push(optimisticWithTimestamp);
}
return allMessages.sort(sortMessages);
}, [messages, optimisticMessage, workflowId]);
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
const result = await fileContext.handleFileUpload(file, workflowId || undefined);
if (result.success && result.fileData) {
const responseData = result.fileData;
const fileData = responseData.file || responseData;
const fileId = fileData?.id;
if (fileId) {
const newFile: WorkflowFile = {
id: fileId,
fileId: fileId,
fileName: fileData.fileName || file.name,
fileSize: fileData.fileSize || file.size,
mimeType: fileData.mimeType || file.type || 'application/octet-stream',
source: 'user_uploaded'
};
setPendingFiles(prev => {
if (prev.some(f => f.fileId === fileId)) {
return prev;
}
return [...prev, newFile];
});
}
}
return {
success: result.success || false,
data: result.fileData || null
};
}, [workflowId, fileContext]);
const handleFileAttach = useCallback(async (fileId: string): Promise<void> => {
const isInPending = pendingFiles.some(f => f.fileId === fileId);
if (isInPending) {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
} else {
let workflowFile: WorkflowFile | null = null;
const userFile = fileContext.files.find(f => f.id === fileId);
if (userFile) {
workflowFile = {
id: userFile.id,
fileId: userFile.id,
fileName: userFile.file_name,
fileSize: userFile.size || 0,
mimeType: userFile.mime_type || 'application/octet-stream',
source: 'user_uploaded'
};
} else {
const existingWorkflowFile = workflowFiles.find(f => f.fileId === fileId);
if (existingWorkflowFile) {
workflowFile = {
...existingWorkflowFile,
id: existingWorkflowFile.id || existingWorkflowFile.fileId,
fileId: existingWorkflowFile.fileId,
fileName: existingWorkflowFile.fileName || 'Unknown File',
fileSize: existingWorkflowFile.fileSize || 0,
mimeType: existingWorkflowFile.mimeType || 'application/octet-stream',
source: existingWorkflowFile.source || 'user_uploaded'
};
}
}
if (workflowFile) {
setPendingFiles(prev => {
if (prev.some(f => f.fileId === fileId)) {
return prev;
}
return [...prev, workflowFile!];
});
}
}
}, [pendingFiles, fileContext.files, workflowFiles]);
const handleFileUploadAndAttach = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
return await handleFileUpload(file);
}, [handleFileUpload]);
const handleFileRemove = useCallback(async (file: WorkflowFile) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
}, []);
const handleFileDelete = useCallback(async (file: WorkflowFile) => {
if (!file.fileId) return;
const success = await fileContext.handleFileDelete(file.fileId, () => {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
});
if (success) {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
if (workflowId) {
const messagesWithFile = messages.filter((msg: WorkflowMessage) => {
const docs = (msg as any).documents as MessageDocument[] | undefined;
return docs?.some(doc => doc.fileId === file.fileId);
});
for (const message of messagesWithFile) {
try {
await deleteFileFromMessageApi(request, workflowId, message.id, file.fileId);
} catch (error) {
}
}
}
}
}, [workflowId, messages, fileContext, request]);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
const handleSubmit = useCallback(async () => {
if (isRunning && workflowId) {
try {
const result = await stopWorkflow();
if (result.success) {
resetWorkflow();
}
} catch (error) {
}
return;
}
const trimmedInput = inputValue.trim();
if (!trimmedInput || startingWorkflow) {
return;
}
try {
const filesToSend = pendingFiles.filter(file => file.fileId);
const fileIdsToSend = filesToSend.map(f => f.fileId).filter((id): id is string => !!id);
const sentFileIdsSet = new Set(fileIdsToSend);
// Optimistically render user message immediately
const optimisticMsg: WorkflowMessage = {
id: `optimistic-${Date.now()}`,
workflowId: workflowId || '',
message: trimmedInput,
role: 'user',
publishedAt: Date.now(),
documents: filesToSend.map(file => ({
id: file.id || file.fileId,
fileId: file.fileId,
fileName: file.fileName,
fileSize: file.fileSize,
mimeType: file.mimeType,
messageId: `optimistic-${Date.now()}`,
roundNumber: 0,
taskNumber: 0,
actionNumber: 0,
actionId: ''
}))
};
setOptimisticMessage(optimisticMsg);
// Optimistically update workflow status to 'running' immediately
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('running');
}
setPendingFiles(prev => prev.filter(file =>
!file.fileId || !sentFileIdsSet.has(file.fileId)
));
if (!chatWorkflowPermission || chatWorkflowPermission.create === 'n') {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
return;
}
const selectedMode = workflowMode || 'Dynamic';
const apiWorkflowMode: 'Dynamic' | 'Automation' = selectedMode;
const workflowOptions: { workflowId?: string; workflowMode: 'Dynamic' | 'Automation' } = {
workflowMode: apiWorkflowMode
};
if (workflowId) {
workflowOptions.workflowId = workflowId;
}
const requestBody = {
prompt: trimmedInput,
listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined,
userLanguage: 'en'
};
const result = await startWorkflow(requestBody, workflowOptions);
if (result.success) {
setInputValue('');
const wasNewWorkflow = !workflowId;
if (wasNewWorkflow && result.data) {
const workflow = result.data as Workflow;
// Dispatch event first to trigger refetch in useWorkflows
window.dispatchEvent(new CustomEvent('workflowCreated', {
detail: { workflow }
}));
// Refetch workflows list to ensure dropdown is updated
await refetchWorkflows();
// Update context first (this will trigger the sync effect to update lifecycle)
selectWorkflowFromContext(workflow.id);
// Also directly update lifecycle to ensure immediate state update
await selectWorkflow(workflow.id);
} else if (workflowId) {
// For resumed workflows, ensure context is synced and update lifecycle
selectWorkflowFromContext(workflowId);
await selectWorkflow(workflowId);
}
} else {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
}
} catch (error) {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
}
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, setWorkflowStatusOptimistic]);
useEffect(() => {
const handleWorkflowCleared = () => {
// Reset all workflow-related state
setPendingFiles([]);
setOptimisticMessage(null);
// Reset workflow lifecycle state
resetWorkflow();
// Clear context selection
clearWorkflowFromContext();
};
window.addEventListener('workflowCleared', handleWorkflowCleared);
return () => {
window.removeEventListener('workflowCleared', handleWorkflowCleared);
};
}, [resetWorkflow, clearWorkflowFromContext]);
const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
clearWorkflowFromContext();
resetWorkflow();
setPendingFiles([]);
setOptimisticMessage(null);
return;
}
const workflowIdToSelect = typeof item.id === 'string' ? item.id : String(item.id);
selectWorkflowFromContext(workflowIdToSelect);
if (selectWorkflow) {
await selectWorkflow(workflowIdToSelect);
}
}, [selectWorkflow, resetWorkflow, selectWorkflowFromContext, clearWorkflowFromContext]);
const handlePromptSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
setSelectedPromptId(null);
return;
}
const promptId = typeof item.id === 'string' ? item.id : String(item.id);
if (!promptPermission || promptPermission.read === 'n') {
return;
}
try {
const prompt = await fetchPromptById(promptId);
if (prompt && prompt.content) {
setSelectedPromptId(promptId);
setInputValue(prompt.content);
}
} catch (error: any) {
}
}, [fetchPromptById, promptPermission]);
const handleWorkflowModeSelect = useCallback((item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
setWorkflowMode(null);
return;
}
const modeValue = item.value || item.id;
const modeString = typeof modeValue === 'string' ? modeValue : String(modeValue);
if (modeString === 'Dynamic' || modeString === 'Automation') {
const mode = modeString as 'Dynamic' | 'Automation';
setWorkflowMode(mode);
}
}, []);
const workflowItems = useMemo(() => {
console.log('🔄 useDashboardInputForm: Computing workflowItems from workflows:', workflows);
if (!workflows || !Array.isArray(workflows)) {
console.warn('⚠️ useDashboardInputForm: workflows is not an array:', workflows);
return [];
}
if (workflows.length === 0) {
console.log(' useDashboardInputForm: workflows array is empty');
return [];
}
const items = workflows.map(workflow => ({
id: workflow.id,
label: workflow.name || workflow.id,
value: workflow,
metadata: {
status: workflow.status,
workflowMode: workflow.workflowMode
}
}));
console.log(`✅ useDashboardInputForm: Created ${items.length} workflow items:`, items);
return items;
}, [workflows]);
const promptItems = useMemo(() => {
if (!promptPermission || promptPermission.view === false || promptPermission.read === 'n') {
return [];
}
return prompts.map(prompt => ({
id: prompt.id,
label: prompt.name || prompt.id,
value: prompt,
metadata: {
content: prompt.content
}
}));
}, [prompts, promptPermission]);
const workflowModeItems = useMemo(() => [
{
id: 'Automation',
label: 'Automation',
value: 'Automation' as const,
metadata: {
description: 'Automated workflow processing'
}
},
{
id: 'Dynamic',
label: 'Dynamic',
value: 'Dynamic' as const,
metadata: {
description: 'Iterative dynamic-style processing'
}
}
], []);
return {
data: [],
loading: false,
error: null,
inputValue,
onInputChange,
handleSubmit,
isSubmitting: startingWorkflow || isStopping,
workflowId: workflowId || undefined,
workflowStatus,
currentRound,
isRunning,
messages: displayMessages || [],
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
dashboardTree, // Dashboard log tree (logs with operationId)
onToggleOperationExpanded: toggleOperationExpanded,
getChildOperations,
workflowItems,
selectedWorkflowId: workflowId || selectedWorkflowId || null,
onWorkflowSelect: handleWorkflowSelect,
workflowsLoading,
promptItems,
selectedPromptId,
onPromptSelect: handlePromptSelect,
promptsLoading,
promptPermission,
workflowModeItems,
selectedWorkflowMode: workflowMode,
onWorkflowModeSelect: handleWorkflowModeSelect,
playgroundUIPermission,
chatWorkflowPermission,
filePermission,
workflowFiles,
pendingFiles,
handleFileUpload,
handleFileDelete,
handleFileRemove,
uploadingFile: fileContext.uploadingFile,
deletingFiles: fileContext.deletingFiles,
previewingFiles: fileContext.previewingFiles,
isFileAttachmentPopupOpen,
setIsFileAttachmentPopupOpen,
allUserFiles: fileContext.files || [],
handleFileAttach,
handleFileUploadAndAttach,
latestStats
};
}
export function createDashboardHook() {
return () => useDashboardInputForm();
}