diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 809e1f2..5a9c76e 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -62,6 +62,7 @@ export function useDashboardInputForm() { processDashboardLogs, clearDashboard, toggleOperationExpanded, + toggleRoundExpanded, updateCurrentRound, getChildOperations } = useDashboardLogTree(); @@ -480,19 +481,46 @@ export function useDashboardInputForm() { setInputValue(value); }, []); + // Separate stop handler - only stops the workflow without sending new input + const handleStop = useCallback(async () => { + if (!workflowId) return { success: false, error: 'No workflow to stop' }; + + try { + const result = await stopWorkflow(); + return result; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to stop workflow' }; + } + }, [workflowId, stopWorkflow]); + const handleSubmit = useCallback(async () => { - if (isRunning && workflowId) { + const trimmedInput = inputValue.trim(); + + // If running and no new input, just stop + if (isRunning && workflowId && !trimmedInput) { try { - const result = await stopWorkflow(); - if (result.success) { - resetWorkflow(); - } + await stopWorkflow(); } catch (error) { + // Ignore stop errors } return; } - const trimmedInput = inputValue.trim(); + // If running with new input, stop first then continue with new input + if (isRunning && workflowId && trimmedInput) { + try { + // Stop the current workflow + await stopWorkflow(); + // Continue below to send new input + } catch (error) { + // Ignore stop errors, try to continue anyway + } + } + + // No input and not running = nothing to do + if (!trimmedInput || startingWorkflow) { + return; + } if (!trimmedInput || startingWorkflow) { return; } @@ -737,7 +765,9 @@ export function useDashboardInputForm() { inputValue, onInputChange, handleSubmit, + handleStop, isSubmitting: startingWorkflow || isStopping, + isStopping, workflowId: workflowId || undefined, workflowStatus, currentRound, @@ -746,6 +776,7 @@ export function useDashboardInputForm() { logs: unifiedContentLogs || [], // Unified content logs (without operationId) dashboardTree, // Dashboard log tree (logs with operationId) onToggleOperationExpanded: toggleOperationExpanded, + onToggleRoundExpanded: toggleRoundExpanded, getChildOperations, workflowItems, selectedWorkflowId: workflowId || selectedWorkflowId || null, diff --git a/src/hooks/playground/useDashboardLogTree.ts b/src/hooks/playground/useDashboardLogTree.ts index b698229..31258dc 100644 --- a/src/hooks/playground/useDashboardLogTree.ts +++ b/src/hooks/playground/useDashboardLogTree.ts @@ -9,6 +9,14 @@ interface OperationData { latestStatus: string | null; operationName: string | null; // Stable name from first log latestMessage: string | null; // Latest status message that updates + roundNumber: number | null; // Track which round this operation belongs to +} + +interface RoundData { + operations: Map; + rootOperations: string[]; + expanded: boolean; + isCompleted: boolean; } interface DashboardLogTree { @@ -16,6 +24,7 @@ interface DashboardLogTree { rootOperations: string[]; logExpandedStates: Map; currentRound: number | null; + rounds: Map; } export function useDashboardLogTree() { @@ -23,7 +32,8 @@ export function useDashboardLogTree() { operations: new Map(), rootOperations: [], logExpandedStates: new Map(), - currentRound: null + currentRound: null, + rounds: new Map() }); const treeRef = useRef(tree); @@ -42,7 +52,8 @@ export function useDashboardLogTree() { operations: new Map(prevTree.operations), rootOperations: [...prevTree.rootOperations], logExpandedStates: new Map(prevTree.logExpandedStates), - currentRound: prevTree.currentRound + currentRound: prevTree.currentRound, + rounds: new Map(prevTree.rounds) }; // Process each log @@ -53,6 +64,14 @@ export function useDashboardLogTree() { const operationId = log.operationId; const logId = generateLogId(log); + const logRoundNumber = (log as any).roundNumber as number | null | undefined; + + // Update current round tracking + if (logRoundNumber !== null && logRoundNumber !== undefined) { + if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) { + newTree.currentRound = logRoundNumber; + } + } // Get or create operation const existingOperation = newTree.operations.get(operationId); @@ -106,6 +125,11 @@ export function useDashboardLogTree() { const latestStatus = log.status !== undefined && log.status !== null ? log.status : existingOperation?.latestStatus ?? null; + + // Get round number for this operation (from log or existing) + const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined + ? logRoundNumber + : existingOperation?.roundNumber ?? null; // Create new operation object to ensure React detects the change const operation: OperationData = { @@ -115,14 +139,74 @@ export function useDashboardLogTree() { latestProgress, latestStatus, operationName, - latestMessage + latestMessage, + roundNumber }; newTree.operations.set(operationId, operation); + + // Add operation to its round + if (roundNumber !== null) { + if (!newTree.rounds.has(roundNumber)) { + newTree.rounds.set(roundNumber, { + operations: new Map(), + rootOperations: [], + expanded: true, // New rounds start expanded + isCompleted: false + }); + } + const round = newTree.rounds.get(roundNumber)!; + round.operations.set(operationId, operation); + } }); - // Rebuild root operations list (operations without parentId) - // Use Set to ensure uniqueness, then convert back to array + // Rebuild root operations list per round + newTree.rounds.forEach((round, roundNumber) => { + const rootOpsSet = new Set(); + round.operations.forEach((op, opId) => { + if (op.parentId === null) { + rootOpsSet.add(opId); + } else { + // Check if parent is in a different round - then this is a root in THIS round + const parentOp = newTree.operations.get(op.parentId); + if (!parentOp || parentOp.roundNumber !== roundNumber) { + rootOpsSet.add(opId); + } + } + }); + + // Sort by timestamp + round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => { + const opA = round.operations.get(opIdA); + const opB = round.operations.get(opIdB); + if (!opA || !opB) return 0; + + const logsA = Array.from(opA.logs.values()); + const logsB = Array.from(opB.logs.values()); + + if (logsA.length === 0 && logsB.length === 0) return 0; + if (logsA.length === 0) return 1; + if (logsB.length === 0) return -1; + + const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); + const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); + + return earliestA - earliestB; + }); + + // Update completion status + const allOpsCompleted = Array.from(round.operations.values()).every(op => + op.latestStatus === 'completed' || op.latestStatus === 'success' + ); + round.isCompleted = allOpsCompleted; + + // Auto-collapse completed rounds (except current) + if (round.isCompleted && roundNumber !== newTree.currentRound) { + round.expanded = false; + } + }); + + // Rebuild global root operations list (operations without parentId) const rootOpsSet = new Set(); newTree.operations.forEach((op, opId) => { if (op.parentId === null) { @@ -135,18 +219,17 @@ export function useDashboardLogTree() { const opB = newTree.operations.get(opIdB); if (!opA || !opB) return 0; - // Get earliest log timestamp for each operation const logsA = Array.from(opA.logs.values()); const logsB = Array.from(opB.logs.values()); if (logsA.length === 0 && logsB.length === 0) return 0; - if (logsA.length === 0) return 1; // Put operations without logs at the end + if (logsA.length === 0) return 1; if (logsB.length === 0) return -1; const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); - return earliestA - earliestB; // Ascending order (oldest first) + return earliestA - earliestB; }); return newTree; @@ -158,7 +241,8 @@ export function useDashboardLogTree() { operations: new Map(), rootOperations: [], logExpandedStates: new Map(), - currentRound: resetRound ? null : treeRef.current.currentRound + currentRound: resetRound ? null : treeRef.current.currentRound, + rounds: new Map() }); }, []); @@ -187,13 +271,24 @@ export function useDashboardLogTree() { const updateCurrentRound = useCallback((round: number | null) => { setTree(prevTree => { - // Clear dashboard if round changes + // Only update current round, keep all rounds data + // Auto-collapse previous rounds when new round starts if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) { + const newRounds = new Map(prevTree.rounds); + + // Collapse the old current round + const oldRound = newRounds.get(prevTree.currentRound); + if (oldRound) { + newRounds.set(prevTree.currentRound, { + ...oldRound, + expanded: false + }); + } + return { - operations: new Map(), - rootOperations: [], - logExpandedStates: new Map(), - currentRound: round + ...prevTree, + currentRound: round, + rounds: newRounds }; } @@ -203,6 +298,26 @@ export function useDashboardLogTree() { }; }); }, []); + + const toggleRoundExpanded = useCallback((roundNumber: number) => { + setTree(prevTree => { + const round = prevTree.rounds.get(roundNumber); + if (!round) { + return prevTree; + } + + const newRounds = new Map(prevTree.rounds); + newRounds.set(roundNumber, { + ...round, + expanded: !round.expanded + }); + + return { + ...prevTree, + rounds: newRounds + }; + }); + }, []); const getChildOperations = useCallback((parentId: string | null): string[] => { const currentTree = treeRef.current; @@ -231,6 +346,7 @@ export function useDashboardLogTree() { processDashboardLogs, clearDashboard, toggleOperationExpanded, + toggleRoundExpanded, updateCurrentRound, getChildOperations }; diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 9e2b997..6690934 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -32,6 +32,10 @@ export function useWorkflowLifecycle() { const statusRef = useRef('idle'); const statusChangedFromRunningAtRef = useRef(null); const lastRenderedTimestampRef = useRef(null); + // Track processed stat IDs to avoid double-counting + const processedStatIdsRef = useRef>(new Set()); + // Track cumulative stats + const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }); const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations(); const { request } = useApiRequest(); const pollingController = useWorkflowPolling(); @@ -268,21 +272,52 @@ export function useWorkflowLifecycle() { return [...allLogs].sort(sortLogs); }); - // Process stats and keep the latest one (highest createdAt) + // Process stats - aggregate only NEW stat entries (avoid double-counting) const statsItems = timeline.filter(item => item.type === 'stat'); if (statsItems.length > 0) { - // Sort by createdAt descending to get the latest - const sortedStats = [...statsItems].sort((a, b) => b.createdAt - a.createdAt); - const latestStatItem = sortedStats[0]; - const statData = latestStatItem.item || latestStatItem; + let hasNewStats = false; - if (statData && (statData.priceUsd !== undefined || statData.processingTime !== undefined || - statData.bytesSent !== undefined || statData.bytesReceived !== undefined)) { + statsItems.forEach(statItem => { + const statData = statItem.item || statItem; + const statId = statData?.id || statItem.id; + + // Skip if already processed + if (statId && processedStatIdsRef.current.has(statId)) { + return; + } + + if (statData) { + hasNewStats = true; + + // Mark as processed + if (statId) { + processedStatIdsRef.current.add(statId); + } + + // Add to cumulative stats + if (statData.priceUsd !== undefined && statData.priceUsd !== null) { + cumulativeStatsRef.current.priceUsd += statData.priceUsd; + } + if (statData.processingTime !== undefined && statData.processingTime !== null) { + cumulativeStatsRef.current.processingTime += statData.processingTime; + } + if (statData.bytesSent !== undefined && statData.bytesSent !== null) { + cumulativeStatsRef.current.bytesSent += statData.bytesSent; + } + if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) { + cumulativeStatsRef.current.bytesReceived += statData.bytesReceived; + } + } + }); + + // Update state with cumulative totals + if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 || + cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) { setLatestStats({ - priceUsd: statData.priceUsd, - processingTime: statData.processingTime, - bytesSent: statData.bytesSent, - bytesReceived: statData.bytesReceived + priceUsd: cumulativeStatsRef.current.priceUsd, + processingTime: cumulativeStatsRef.current.processingTime, + bytesSent: cumulativeStatsRef.current.bytesSent, + bytesReceived: cumulativeStatsRef.current.bytesReceived }); } } @@ -366,6 +401,9 @@ export function useWorkflowLifecycle() { setDashboardLogs([]); setUnifiedContentLogs([]); setLatestStats(null); + // Reset stats tracking + processedStatIdsRef.current.clear(); + cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; return; } @@ -426,6 +464,9 @@ export function useWorkflowLifecycle() { setDashboardLogs(prev => prev.length > 0 ? [] : prev); setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev); setLatestStats(null); + // Reset stats tracking + processedStatIdsRef.current.clear(); + cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; setCurrentRound(prev => prev !== undefined ? undefined : prev); if (statusChangedFromRunningAt !== null) { setStatusChangedFromRunningAt(null); @@ -516,6 +557,9 @@ export function useWorkflowLifecycle() { updateWorkflowStatus('idle'); setCurrentRound(undefined); setLatestStats(null); + // Reset stats tracking + processedStatIdsRef.current.clear(); + cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; setStatusChangedFromRunningAt(null); statusChangedFromRunningAtRef.current = null; lastRenderedTimestampRef.current = null; @@ -525,8 +569,10 @@ export function useWorkflowLifecycle() { const selectWorkflow = useCallback(async (workflowIdToSelect: string) => { try { setWorkflowId(workflowIdToSelect); - // Reset lastRenderedTimestamp for new workflow selection + // Reset lastRenderedTimestamp and stats for new workflow selection lastRenderedTimestampRef.current = null; + processedStatIdsRef.current.clear(); + cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null); diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 6f20195..ae44fda 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -56,6 +56,60 @@ export function useAutomations() { const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); + // Fallback attributes for automation form + const fallbackAttributes: AttributeDefinition[] = [ + { + name: 'label', + type: 'text', + label: 'Name', + required: true, + editable: true, + visible: true, + sortable: true, + searchable: true, + width: 200, + }, + { + name: 'schedule', + type: 'text', + label: 'Zeitplan (Cron)', + required: false, + editable: true, + visible: true, + description: 'z.B. "0 8 * * *" für täglich 8:00 Uhr', + width: 150, + }, + { + name: 'template', + type: 'textarea', + label: 'Template', + required: false, + editable: true, + visible: true, + width: 200, + }, + { + name: 'active', + type: 'checkbox', + label: 'Aktiv', + required: false, + editable: true, + visible: true, + default: true, + width: 80, + }, + { + name: 'status', + type: 'text', + label: 'Status', + required: false, + editable: false, + visible: true, + readonly: true, + width: 100, + }, + ]; + // Fetch attributes from backend const fetchAttributes = useCallback(async () => { try { @@ -76,12 +130,17 @@ export function useAutomations() { } } + // Use fallback if no attributes returned + if (attrs.length === 0) { + attrs = fallbackAttributes; + } + setAttributes(attrs); return attrs; } catch (error: any) { - console.error('Error fetching automation attributes:', error); - setAttributes([]); - return []; + console.error('Error fetching automation attributes, using fallback:', error); + setAttributes(fallbackAttributes); + return fallbackAttributes; } }, []); diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index d961dbc..9812b26 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -289,6 +289,22 @@ export function useUserFiles() { fetchPermissions(); }, [fetchAttributes, fetchPermissions]); + // Listen for file upload events and refresh the list + useEffect(() => { + const handleFileUploaded = (event: CustomEvent) => { + console.log('📁 File uploaded event received, refreshing list...', event.detail); + // Small delay to ensure backend has persisted the file + setTimeout(() => { + fetchFiles(); + }, 100); + }; + + window.addEventListener('fileUploaded', handleFileUploaded as EventListener); + return () => { + window.removeEventListener('fileUploaded', handleFileUploaded as EventListener); + }; + }, [fetchFiles]); + return { data: files, loading, @@ -503,6 +519,9 @@ export function useFileOperations() { showWarning(t('warning.duplicate_file.title'), message); } + // Dispatch event to notify other components about the new file + window.dispatchEvent(new CustomEvent('fileUploaded', { detail: fileData })); + return { success: true, fileData }; } catch (error: any) { console.error('Upload failed:', error); diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index ec020b8..0cc9116 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -407,3 +407,233 @@ :global(.dark-theme) .modalOverlay { background: rgba(0, 0, 0, 0.7); } + +/* Danger button */ +.dangerButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #dc3545; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.dangerButton:hover { + background: #c82333; +} + +/* Template list */ +.templateList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.templateItem { + border: 1px solid var(--border-color, #e0e0e0); + padding: 1rem; + border-radius: 8px; + background: var(--bg-secondary, #f9f9f9); +} + +.templateHeader { + margin-bottom: 0.5rem; +} + +.templateTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.templateDescription { + margin: 0 0 1rem 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* Execution status */ +.executionStatus { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-secondary, #f9f9f9); + border-radius: 6px; +} + +.statusBadge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.statusBadge.starting, +.statusBadge.running { + background: #e3f2fd; + color: #1976d2; +} + +.statusBadge.completed { + background: #e8f5e9; + color: #388e3c; +} + +.statusBadge.stopped { + background: #fff3e0; + color: #f57c00; +} + +.statusBadge.error, +.statusBadge.failed { + background: #ffebee; + color: #d32f2f; +} + +.workflowId { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.workflowId code { + background: var(--bg-tertiary, #eee); + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-family: monospace; +} + +/* Execution logs */ +.executionLogs { + background: var(--bg-tertiary, #f5f5f5); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + padding: 1rem; +} + +.logEntry { + display: flex; + gap: 0.5rem; + padding: 0.25rem 0; + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.logEntry:last-child { + border-bottom: none; +} + +.logTime { + color: var(--text-secondary); + flex-shrink: 0; +} + +.logStatus { + color: var(--primary-color, #f25843); +} + +.logMessage { + flex: 1; + word-break: break-word; +} + +.logProgress { + color: var(--text-secondary); + flex-shrink: 0; +} + +/* Logs history */ +.logsHistory { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.logHistoryItem { + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + padding: 0.75rem; + background: var(--bg-secondary, #f9f9f9); +} + +.logHistoryItem.completed { + border-left: 3px solid #388e3c; +} + +.logHistoryItem.error, +.logHistoryItem.failed { + border-left: 3px solid #d32f2f; +} + +.logHistoryItem.stopped { + border-left: 3px solid #f57c00; +} + +.logHistoryHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.logHistoryDate { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + +.logHistoryMessages { + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.logHistoryMessage { + padding: 0.125rem 0; +} + +/* Status icons */ +.successIcon { + color: #388e3c; +} + +.errorIcon { + color: #d32f2f; +} + +.warningIcon { + color: #f57c00; +} + +.spinningIcon { + animation: spin 1s linear infinite; +} + +/* Empty actions */ +.emptyActions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Spinning animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +:global(.spinning) { + animation: spin 1s linear infinite; +} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index f725e69..b42f2f0 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -10,6 +10,7 @@ import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa'; +import { useToast } from '../../contexts/ToastContext'; import styles from '../admin/Admin.module.css'; interface UserFile { @@ -22,6 +23,7 @@ interface UserFile { export const FilesPage: React.FC = () => { const fileInputRef = useRef(null); + const { showSuccess, showError } = useToast(); // Data hook const { @@ -141,15 +143,36 @@ export const FilesPage: React.FC = () => { // Handle file selection const handleFileSelect = async (e: React.ChangeEvent) => { const selectedFiles = e.target.files; - if (selectedFiles) { + if (selectedFiles && selectedFiles.length > 0) { + let successCount = 0; + let errorCount = 0; + for (const file of Array.from(selectedFiles)) { - await handleFileUpload(file); + const result = await handleFileUpload(file); + if (result?.success) { + successCount++; + } else { + errorCount++; + } } - refetch(); - // Reset input + + // Reset input first if (fileInputRef.current) { fileInputRef.current.value = ''; } + + // Refresh table to show new files + await refetch(); + + // Show feedback + if (successCount > 0) { + showSuccess( + 'Upload erfolgreich', + `${successCount} Datei(en) hochgeladen${errorCount > 0 ? `, ${errorCount} fehlgeschlagen` : ''}` + ); + } else if (errorCount > 0) { + showError('Upload fehlgeschlagen', `${errorCount} Datei(en) konnten nicht hochgeladen werden`); + } } }; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 47849ff..7297b7b 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -51,19 +51,24 @@ export const PromptsPage: React.FC = () => { refetch(); }, []); - // Generate columns from attributes + // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { - return (attributes || []).map(attr => ({ - key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, - sortable: attr.sortable !== false, - filterable: attr.filterable !== false, - searchable: attr.searchable !== false, - width: attr.name === 'content' ? 300 : attr.width || 150, - minWidth: attr.minWidth || 100, - maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, - })); + // Fields to hide in table view + const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete']; + + return (attributes || []) + .filter(attr => !hiddenColumns.includes(attr.name)) + .map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.name === 'content' ? 300 : attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, + })); }, [attributes]); // Check permissions diff --git a/src/pages/workflows/AutomationsPage.tsx b/src/pages/workflows/AutomationsPage.tsx index b5353d5..ee83a73 100644 --- a/src/pages/workflows/AutomationsPage.tsx +++ b/src/pages/workflows/AutomationsPage.tsx @@ -2,25 +2,26 @@ * AutomationsPage * * Page for viewing and managing workflow automations using FormGeneratorTable. - * Follows the pattern established in AdminUsersPage/WorkflowsPage. + * Includes template selection, execution modal with live logs, and execution history. */ -import React, { useState, useMemo, useEffect } from 'react'; -import { useAutomations, useAutomationOperations } from '../../hooks/useAutomations'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff } from 'react-icons/fa'; +import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa'; +import { useToast } from '../../contexts/ToastContext'; +import { useApiRequest } from '../../hooks/useApi'; import styles from '../admin/Admin.module.css'; -interface Automation { + + +interface WorkflowLog { id: string; - label: string; - schedule?: string; - active: boolean; + timestamp: number; + message: string; status?: string; - template?: string; - placeholders?: any; - [key: string]: any; + progress?: number; } export const AutomationsPage: React.FC = () => { @@ -45,32 +46,90 @@ export const AutomationsPage: React.FC = () => { handleAutomationExecute, handleAutomationToggleActive, handleInlineUpdate, + fetchTemplates, deletingAutomations, executingAutomations, - creatingAutomation, } = useAutomationOperations(); + const { showSuccess, showError, showInfo } = useToast(); + const { request } = useApiRequest(); + + // Modal states const [showCreateModal, setShowCreateModal] = useState(false); const [editingAutomation, setEditingAutomation] = useState(null); + const [showTemplateModal, setShowTemplateModal] = useState(false); + const [templates, setTemplates] = useState([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + + // Execution modal state + const [executionModal, setExecutionModal] = useState<{ + visible: boolean; + automationId: string | null; + automationLabel: string; + workflowId: string | null; + status: 'starting' | 'running' | 'completed' | 'stopped' | 'error'; + logs: WorkflowLog[]; + }>({ + visible: false, + automationId: null, + automationLabel: '', + workflowId: null, + status: 'starting', + logs: [], + }); + + // Logs modal state + const [logsModal, setLogsModal] = useState<{ + visible: boolean; + automation: Automation | null; + }>({ + visible: false, + automation: null, + }); + + // Refs for polling + const pollIntervalRef = useRef(null); + const lastLogIdRef = useRef(null); + const logContainerRef = useRef(null); // Initial fetch useEffect(() => { refetch(); }, []); - // Generate columns from attributes + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, []); + + // Auto-scroll logs + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [executionModal.logs]); + + // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { - return (attributes || []).map(attr => ({ - key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, - sortable: attr.sortable !== false, - filterable: attr.filterable !== false, - searchable: attr.searchable !== false, - width: attr.width || 150, - minWidth: attr.minWidth || 100, - maxWidth: attr.maxWidth || 400, - })); + const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'template', 'executionLogs']; + + return (attributes || []) + .filter(attr => !hiddenColumns.includes(attr.name)) + .map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); }, [attributes]); // Check permissions @@ -91,6 +150,7 @@ export const AutomationsPage: React.FC = () => { const result = await handleAutomationCreate(data as any); if (result) { setShowCreateModal(false); + showSuccess('Automatisierung erstellt'); refetch(); } }; @@ -98,9 +158,10 @@ export const AutomationsPage: React.FC = () => { // Handle edit submit const handleEditSubmit = async (data: Partial) => { if (!editingAutomation) return; - const success = await handleAutomationUpdate(editingAutomation.id, data); + const success = await handleAutomationUpdate(editingAutomation.id, data as any); if (success) { setEditingAutomation(null); + showSuccess('Automatisierung aktualisiert'); refetch(); } }; @@ -110,41 +171,260 @@ export const AutomationsPage: React.FC = () => { if (window.confirm(`Möchten Sie die Automatisierung "${automation.label}" wirklich löschen?`)) { const success = await handleAutomationDelete(automation.id); if (success) { + showSuccess('Automatisierung gelöscht'); refetch(); } } }; - // Handle execute automation - const handleExecute = async (automation: Automation) => { + // Load templates + const handleLoadTemplates = async () => { + setLoadingTemplates(true); try { - await handleAutomationExecute(automation.id); - // Show success feedback (could use toast) - console.log('Automation started:', automation.label); - } catch (err: any) { - console.error('Error executing automation:', err); + const loadedTemplates = await fetchTemplates(); + setTemplates(loadedTemplates); + if (loadedTemplates.length === 0) { + showInfo('Keine Vorlagen verfügbar'); + } else { + setShowTemplateModal(true); + } + } catch (err) { + showError('Fehler beim Laden der Vorlagen'); + } finally { + setLoadingTemplates(false); } }; + // Handle template selection + const handleTemplateSelect = async (template: AutomationTemplate) => { + setShowTemplateModal(false); + + // Pre-fill form with template data + const prefillData: Partial = { + label: template.template?.overview || 'Neue Automatisierung', + template: JSON.stringify(template.template, null, 2), + placeholders: template.parameters || {}, + active: false, + schedule: '0 */4 * * *', + }; + + // Create automation directly + const result = await handleAutomationCreate(prefillData as any); + if (result) { + showSuccess('Automatisierung aus Vorlage erstellt'); + refetch(); + } + }; + + // Poll workflow logs + const pollWorkflowLogs = useCallback(async (workflowId: string) => { + try { + const response = await request({ + url: `/api/workflows/${workflowId}/logs`, + method: 'get', + params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {}, + }); + + const logs: WorkflowLog[] = response?.items || response || []; + + if (logs.length > 0) { + setExecutionModal(prev => ({ + ...prev, + logs: [...prev.logs, ...logs], + })); + lastLogIdRef.current = logs[logs.length - 1].id; + } + + // Check workflow status + const statusResponse = await request({ + url: `/api/workflows/${workflowId}`, + method: 'get', + }); + + const workflowStatus = statusResponse?.status; + + if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') { + // Stop polling + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + setExecutionModal(prev => ({ + ...prev, + status: workflowStatus === 'completed' ? 'completed' : + workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped', + })); + + if (workflowStatus === 'completed') { + showSuccess('Automatisierung erfolgreich abgeschlossen'); + } else if (workflowStatus === 'error' || workflowStatus === 'failed') { + showError('Automatisierung fehlgeschlagen'); + } else { + showInfo('Automatisierung gestoppt'); + } + + refetch(); + } + } catch (err) { + console.error('Error polling workflow logs:', err); + } + }, [request, refetch, showSuccess, showError, showInfo]); + + // Handle execute automation with modal + const handleExecute = async (automation: Automation) => { + // Reset and show modal + lastLogIdRef.current = null; + setExecutionModal({ + visible: true, + automationId: automation.id, + automationLabel: automation.label, + workflowId: null, + status: 'starting', + logs: [{ + id: 'init', + timestamp: Date.now() / 1000, + message: 'Automatisierung wird gestartet...', + }], + }); + + try { + const result = await handleAutomationExecute(automation.id); + const workflowId = result?.id; + + if (workflowId) { + setExecutionModal(prev => ({ + ...prev, + workflowId, + status: 'running', + logs: [...prev.logs, { + id: 'started', + timestamp: Date.now() / 1000, + message: `Workflow ${workflowId} gestartet`, + status: 'running', + }], + })); + + // Start polling + pollIntervalRef.current = setInterval(() => { + pollWorkflowLogs(workflowId); + }, 2000); + } + } catch (err: any) { + setExecutionModal(prev => ({ + ...prev, + status: 'error', + logs: [...prev.logs, { + id: 'error', + timestamp: Date.now() / 1000, + message: `Fehler: ${err.message || 'Unbekannter Fehler'}`, + status: 'error', + }], + })); + showError(`Fehler beim Ausführen: ${err.message}`); + } + }; + + // Handle stop workflow + const handleStopWorkflow = async () => { + if (!executionModal.workflowId) return; + + try { + await request({ + url: `/api/workflows/${executionModal.workflowId}/stop`, + method: 'post', + }); + + setExecutionModal(prev => ({ + ...prev, + logs: [...prev.logs, { + id: 'stopping', + timestamp: Date.now() / 1000, + message: 'Workflow wird gestoppt...', + }], + })); + } catch (err: any) { + showError(`Fehler beim Stoppen: ${err.message}`); + } + }; + + // Close execution modal + const closeExecutionModal = () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setExecutionModal({ + visible: false, + automationId: null, + automationLabel: '', + workflowId: null, + status: 'starting', + logs: [], + }); + }; + // Handle toggle active const handleToggleActive = async (automation: Automation) => { - // Optimistic update updateOptimistically(automation.id, { active: !automation.active }); const success = await handleAutomationToggleActive(automation.id, automation.active); - if (!success) { - // Revert on failure + if (success) { + showSuccess(automation.active ? 'Automatisierung deaktiviert' : 'Automatisierung aktiviert'); + } else { updateOptimistically(automation.id, { active: automation.active }); + showError('Fehler beim Ändern des Status'); } }; + // Show logs modal + const handleShowLogs = async (automation: Automation) => { + const fullAutomation = await fetchAutomationById(automation.id); + setLogsModal({ + visible: true, + automation: fullAutomation as Automation || automation, + }); + }; + // Form attributes for create/edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status']; + const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status', 'executionLogs']; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); + // Format timestamp + const formatTimestamp = (timestamp: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString('de-DE'); + }; + + // Format time only + const formatTime = (timestamp: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('de-DE'); + }; + + // Get status icon + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'error': + case 'failed': + return ; + case 'running': + case 'starting': + return ; + case 'stopped': + return ; + default: + return null; + } + }; + if (error) { return (
@@ -175,12 +455,21 @@ export const AutomationsPage: React.FC = () => { Aktualisieren {canCreate && ( - + <> + + + )}
@@ -199,17 +488,25 @@ export const AutomationsPage: React.FC = () => { Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.

{canCreate && ( - +
+ + +
)} ) : ( { ...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', - loading: (row: Automation) => deletingAutomations.has(row.id), + loading: (row: any) => deletingAutomations.has(row.id), }] : []), ]} customActions={[ @@ -236,14 +533,20 @@ export const AutomationsPage: React.FC = () => { icon: , onClick: handleExecute, title: 'Ausführen', - loading: (row: Automation) => executingAutomations.has(row.id), + loading: (row: any) => executingAutomations.has(row.id), }, { id: 'toggleActive', - icon: (row: Automation) => row.active ? : , + icon: (row: any) => row.active ? : , onClick: handleToggleActive, - title: (row: Automation) => row.active ? 'Deaktivieren' : 'Aktivieren', + title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren', } as any, + { + id: 'logs', + icon: , + onClick: handleShowLogs, + title: 'Ausführungsverlauf', + }, ]} onDelete={handleDelete} hookData={{ @@ -265,11 +568,8 @@ export const AutomationsPage: React.FC = () => {
e.stopPropagation()}>

Neue Automatisierung

-
@@ -299,11 +599,8 @@ export const AutomationsPage: React.FC = () => {
e.stopPropagation()}>

Automatisierung bearbeiten

-
@@ -327,6 +624,166 @@ export const AutomationsPage: React.FC = () => {
)} + + {/* Template Selection Modal */} + {showTemplateModal && ( +
setShowTemplateModal(false)}> +
e.stopPropagation()}> +
+

Vorlage auswählen

+ +
+
+
+ {templates.map((template, index) => ( +
+
+

+ {template.template?.overview || `Vorlage ${index + 1}`} +

+
+

+ {template.template?.tasks?.[0]?.description || + template.template?.tasks?.[0]?.objective || + 'Keine Beschreibung'} +

+ +
+ ))} +
+
+
+ +
+
+
+ )} + + {/* Execution Modal */} + {executionModal.visible && ( +
+
e.stopPropagation()} style={{ maxWidth: '700px' }}> +
+

+ {getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel} +

+ +
+
+
+ + {executionModal.status === 'starting' && 'Wird gestartet...'} + {executionModal.status === 'running' && 'Läuft...'} + {executionModal.status === 'completed' && 'Abgeschlossen'} + {executionModal.status === 'stopped' && 'Gestoppt'} + {executionModal.status === 'error' && 'Fehler'} + + {executionModal.workflowId && ( + + Workflow: {executionModal.workflowId} + + )} +
+
+ {executionModal.logs.map((log, index) => ( +
+ [{formatTime(log.timestamp)}] + {log.status && {log.status}:} + {log.message} + {log.progress !== undefined && log.progress !== null && log.progress < 1 && ( + ({Math.round(log.progress * 100)}%) + )} +
+ ))} +
+
+
+ {executionModal.status === 'running' && ( + + )} + +
+
+
+ )} + + {/* Logs History Modal */} + {logsModal.visible && logsModal.automation && ( +
setLogsModal({ visible: false, automation: null })}> +
e.stopPropagation()} style={{ maxWidth: '700px' }}> +
+

+ Ausführungsverlauf: {logsModal.automation.label} +

+ +
+
+ {(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? ( +
+

Keine Ausführungen vorhanden

+
+ ) : ( +
+ {[...logsModal.automation.executionLogs].reverse().map((log, index) => ( +
+
+ {formatTimestamp(log.timestamp)} + + {log.status || 'Unbekannt'} + + {log.workflowId && ( + + Workflow: {log.workflowId} + + )} +
+ {log.messages && log.messages.length > 0 && ( +
+ {log.messages.map((msg, msgIndex) => ( +
{msg}
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+ +
+
+
+ )}
); }; diff --git a/src/pages/workflows/PlaygroundPage.module.css b/src/pages/workflows/PlaygroundPage.module.css index 418c9ef..cbb271b 100644 --- a/src/pages/workflows/PlaygroundPage.module.css +++ b/src/pages/workflows/PlaygroundPage.module.css @@ -491,3 +491,96 @@ transform: rotate(360deg); } } + +/* Drag & Drop Styles */ +.dragOver { + position: relative; +} + +.dragOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--primary-rgb, 242, 88, 67), 0.1); + border: 2px dashed var(--primary-color, #f25843); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + pointer-events: none; +} + +.dragOverlayContent { + text-align: center; + color: var(--primary-color, #f25843); + font-size: 1rem; + font-weight: 500; +} + +.dragOverFooter { + border-color: var(--primary-color, #f25843); + background: rgba(var(--primary-rgb, 242, 88, 67), 0.05); +} + +/* Prompts Row */ +.promptsRow { + display: flex; + align-items: center; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0.75rem; +} + +.promptsSelect { + display: flex; + align-items: center; + flex: 1; + max-width: 400px; +} + +.promptDropdown { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--surface-color); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; +} + +.promptDropdown:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.promptDropdown:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Voice Recording Button */ +.iconButton.recording { + background: var(--danger-color, #e53e3e); + border-color: var(--danger-color, #e53e3e); + color: white; + animation: pulse 1.5s infinite; +} + +.iconButton.recording:hover { + background: #c53030; + border-color: #c53030; + color: white; +} + +@keyframes pulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(229, 62, 62, 0.4); + } + 50% { + box-shadow: 0 0 0 8px rgba(229, 62, 62, 0); + } +} diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 3fbe661..5c45d3b 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -3,27 +3,41 @@ * * Global page for workflow execution and chat interaction. * Features a resizable two-column layout with chat on the left and dashboard on the right. + * Includes: Drag & Drop file upload, Prompts selection, Voice input */ -import React, { useRef } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useDashboardInputForm } from '../../hooks/usePlayground'; import { useUserWorkflows } from '../../hooks/useWorkflows'; import { useResizablePanels } from '../../hooks/useResizablePanels'; -import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus } from 'react-icons/fa'; +import { usePrompts } from '../../hooks/usePrompts'; +import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa'; +import { useToast } from '../../contexts/ToastContext'; +import api from '../../api'; import styles from './PlaygroundPage.module.css'; export const PlaygroundPage: React.FC = () => { + // Read workflowId from URL query parameters + const [searchParams] = useSearchParams(); + const urlWorkflowId = searchParams.get('workflowId'); + // Main hook for input form and data const hookData = useDashboardInputForm(); const { inputValue, onInputChange, isRunning, + isStopping, handleSubmit, + handleStop, isSubmitting, + workflowStatus, messages, dashboardTree, onToggleOperationExpanded, + onToggleRoundExpanded, + currentRound, workflowId, onWorkflowSelect, workflowItems, @@ -34,6 +48,8 @@ export const PlaygroundPage: React.FC = () => { } = hookData; const { data: workflows } = useUserWorkflows(); + const { prompts, refetch: refetchPrompts } = usePrompts(); + const { showError, showSuccess } = useToast(); // Resizable panels hook const { @@ -51,6 +67,214 @@ export const PlaygroundPage: React.FC = () => { // File input ref for hidden file input const fileInputRef = useRef(null); + // Drag & Drop state + const [isDragOver, setIsDragOver] = useState(false); + const dragCounterRef = useRef(0); + + // Voice recording state + const [isRecording, setIsRecording] = useState(false); + const [mediaRecorder, setMediaRecorder] = useState(null); + const [audioChunks, setAudioChunks] = useState([]); + + // Prompts dropdown state + const [selectedPromptId, setSelectedPromptId] = useState(''); + + // Load prompts on mount + useEffect(() => { + refetchPrompts(); + }, []); + + // Load workflow from URL parameter + const urlWorkflowLoadedRef = useRef(false); + + // Debug: Log URL parameter status + useEffect(() => { + console.log('🔍 PlaygroundPage URL debug:', { + urlWorkflowId, + currentWorkflowId: workflowId, + hasOnWorkflowSelect: !!onWorkflowSelect, + alreadyLoaded: urlWorkflowLoadedRef.current, + fullUrl: window.location.href + }); + }, [urlWorkflowId, workflowId, onWorkflowSelect]); + + useEffect(() => { + // Only load once on mount, and only if we have a URL workflowId + if (urlWorkflowId && !urlWorkflowLoadedRef.current && onWorkflowSelect) { + urlWorkflowLoadedRef.current = true; + console.log('🔗 Loading workflow from URL:', urlWorkflowId); + // Small delay to ensure hooks are initialized + setTimeout(() => { + onWorkflowSelect({ id: urlWorkflowId, label: '', value: urlWorkflowId }); + }, 100); + } + }, [urlWorkflowId, onWorkflowSelect]); + + // Format bytes helper + const formatBytes = (bytes: number): string => { + 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`; + }; + + // Format duration helper (for stats) + const formatDuration = (seconds: number): string => { + if (!seconds || seconds < 0) return '0s'; + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; + }; + + // Handle prompt selection + const handlePromptSelect = (promptId: string) => { + setSelectedPromptId(promptId); + if (promptId) { + const prompt = prompts?.find((p: any) => p.id === promptId); + if (prompt && prompt.content) { + // Append prompt content to input + const currentText = inputValue || ''; + const newText = currentText ? `${currentText}\n\n${prompt.content}` : prompt.content; + onInputChange(newText); + } + } + }; + + // Drag & Drop handlers + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current++; + if (e.dataTransfer.types.includes('Files')) { + setIsDragOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files.length > 0 && hookData.handleFileUpload) { + for (const file of Array.from(files)) { + await hookData.handleFileUpload(file); + } + } + }, [hookData.handleFileUpload]); + + // Voice recording handlers + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // Find supported MIME type + const mimeTypes = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/mp4', + ]; + let mimeType = ''; + for (const type of mimeTypes) { + if (MediaRecorder.isTypeSupported(type)) { + mimeType = type; + break; + } + } + + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + const chunks: Blob[] = []; + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + recorder.onstop = async () => { + // Stop all tracks + stream.getTracks().forEach(track => track.stop()); + + // Process recording + if (chunks.length > 0) { + const audioBlob = new Blob(chunks, { type: mimeType || 'audio/webm' }); + await processVoiceRecording(audioBlob); + } + }; + + recorder.start(); + setMediaRecorder(recorder); + setAudioChunks([]); + setIsRecording(true); + } catch (error: any) { + console.error('Error starting recording:', error); + showError('Mikrofonzugriff verweigert', 'Bitte erlauben Sie den Mikrofonzugriff in Ihren Browser-Einstellungen.'); + } + }; + + const stopRecording = () => { + if (mediaRecorder && mediaRecorder.state === 'recording') { + mediaRecorder.stop(); + setIsRecording(false); + setMediaRecorder(null); + } + }; + + const processVoiceRecording = async (audioBlob: Blob) => { + try { + // Create FormData for speech-to-text API + const formData = new FormData(); + formData.append('file', audioBlob, 'voice_recording.webm'); + formData.append('language', 'de-DE'); + + // Call speech-to-text API + const response = await api.post('/api/ai/speech-to-text', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + if (response.data?.success && response.data?.text) { + const transcribedText = response.data.text.trim(); + // Append transcribed text to input + const currentText = inputValue || ''; + const newText = currentText ? `${currentText} ${transcribedText}` : transcribedText; + onInputChange(newText); + showSuccess('Transkription erfolgreich', 'Text wurde hinzugefügt.'); + } else { + showError('Transkription fehlgeschlagen', response.data?.error || 'Unbekannter Fehler'); + } + } catch (error: any) { + console.error('Error processing voice recording:', error); + showError('Transkription fehlgeschlagen', error.message || 'Fehler bei der Sprachverarbeitung'); + } + }; + + const handleVoiceClick = () => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }; + // Simple wrapper for workflow selection const handleWorkflowChange = (id: string | null) => { if (!id) { @@ -144,9 +368,13 @@ export const PlaygroundPage: React.FC = () => { ); }; - // Render dashboard tree + // Render dashboard tree with rounds const renderDashboard = () => { - if (!dashboardTree || dashboardTree.rootOperations.length === 0) { + // Check if we have rounds data + const hasRounds = dashboardTree && dashboardTree.rounds && dashboardTree.rounds.size > 0; + const hasOperations = dashboardTree && dashboardTree.rootOperations.length > 0; + + if (!hasRounds && !hasOperations) { return (
@@ -157,8 +385,8 @@ export const PlaygroundPage: React.FC = () => { ); } - const renderOperation = (operationId: string, depth: number = 0) => { - const operation = dashboardTree.operations.get(operationId); + const renderOperation = (operationId: string, depth: number = 0, roundOperations?: Map) => { + const operation = roundOperations?.get(operationId) || dashboardTree.operations.get(operationId); if (!operation) return null; const childOps = Array.from(dashboardTree.operations.entries()) @@ -202,23 +430,31 @@ export const PlaygroundPage: React.FC = () => { }}> {operation.operationName || operationId.slice(0, 20)} + {operation.latestProgress !== null && operation.latestProgress < 1 && ( + + {Math.round(operation.latestProgress * 100)}% + + )} {operation.latestStatus && ( @@ -228,13 +464,93 @@ export const PlaygroundPage: React.FC = () => {
{operation.expanded && childOps.length > 0 && (
- {childOps.map(childId => renderOperation(childId, depth + 1))} + {childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
)}
); }; + // If we have rounds, render them + if (hasRounds) { + const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]); + + return ( +
+ {sortedRounds.map(([roundNumber, round]) => ( +
+ {/* Round Header */} +
onToggleRoundExpanded(roundNumber)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0.75rem', + background: roundNumber === currentRound + ? 'var(--primary-bg, #eff6ff)' + : 'var(--bg-secondary)', + borderRadius: '6px', + cursor: 'pointer', + marginBottom: round.expanded ? '0.5rem' : '0', + }} + > + + ▶ + + + Runde {roundNumber} + + {round.isCompleted && ( + + abgeschlossen + + )} + {roundNumber === currentRound && !round.isCompleted && ( + + aktiv + + )} +
+ {/* Round Operations */} + {round.expanded && ( +
+ {round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))} +
+ )} +
+ ))} +
+ ); + } + + // Fallback: render without rounds (for backward compatibility) return (
{dashboardTree.rootOperations.map(opId => renderOperation(opId))} @@ -242,8 +558,11 @@ export const PlaygroundPage: React.FC = () => { ); }; - // Permission check - if (!playgroundUIPermission) { + // Debug: Log permission status + console.log('🔐 PlaygroundPage permission check:', { playgroundUIPermission }); + + // Permission check - also show while loading + if (playgroundUIPermission === false) { return (
@@ -255,6 +574,17 @@ export const PlaygroundPage: React.FC = () => {
); } + + // Show loading state while permission is being checked (undefined) + if (playgroundUIPermission === undefined) { + return ( +
+
+

Lade...

+
+
+ ); + } return (
@@ -290,11 +620,24 @@ export const PlaygroundPage: React.FC = () => {
- {/* Main Content - Resizable Two-Column Layout */} + {/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
+ {/* Drag overlay */} + {isDragOver && ( +
+
+ +

Dateien hier ablegen

+
+
+ )} {/* Left Panel - Chat Messages */}
{
{/* Input Footer */} -
+
+ {/* Prompts Selection Row */} +
+
+ + +
+
+ {/* Pending files */} {pendingFiles && pendingFiles.length > 0 && (
@@ -360,21 +729,29 @@ export const PlaygroundPage: React.FC = () => { )} {/* Stats bar */} - {latestStats && ( + {latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
- {latestStats.promptTokens !== undefined && ( + {(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
- Tokens: + Daten: - {latestStats.promptTokens + (latestStats.completionTokens || 0)} + {formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
)} - {latestStats.totalCost !== undefined && ( + {latestStats.processingTime !== undefined && latestStats.processingTime > 0 && ( +
+ Zeit: + + {formatDuration(latestStats.processingTime)} + +
+ )} + {latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
Kosten: - ${latestStats.totalCost.toFixed(4)} + ${latestStats.priceUsd.toFixed(4)}
)} @@ -389,8 +766,20 @@ export const PlaygroundPage: React.FC = () => { className={styles.inputTextarea} value={inputValue} onChange={(e) => onInputChange(e.target.value)} - placeholder="Geben Sie Ihre Nachricht ein..." - disabled={isRunning && !workflowId} + placeholder={ + isRunning + ? "Workflow läuft. Neue Eingabe zum Unterbrechen und Neustarten..." + : workflowStatus === 'completed' + ? "Workflow abgeschlossen. Neue Eingabe zum Fortsetzen..." + : workflowStatus === 'failed' + ? "Workflow fehlgeschlagen. Neue Eingabe zum Wiederholen..." + : workflowStatus === 'stopped' + ? "Workflow gestoppt. Neue Eingabe zum Fortfahren..." + : !workflowId + ? "Geben Sie einen Prompt ein, um zu starten..." + : "Geben Sie Ihre Nachricht ein oder ziehen Sie Dateien hierher..." + } + disabled={false} rows={3} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -405,34 +794,51 @@ export const PlaygroundPage: React.FC = () => { +
- {isRunning ? ( + {/* Stop button - only visible when running */} + {isRunning && ( - ) : ( - )} + {/* Send button - always visible with dynamic text */} +
diff --git a/src/pages/workflows/WorkflowsPage.tsx b/src/pages/workflows/WorkflowsPage.tsx index 66e4608..dff712b 100644 --- a/src/pages/workflows/WorkflowsPage.tsx +++ b/src/pages/workflows/WorkflowsPage.tsx @@ -5,7 +5,7 @@ * Follows the pattern established in AdminUsersPage. */ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useUserWorkflows, useWorkflowOperations } from '../../hooks/useWorkflows'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; @@ -49,6 +49,11 @@ export const WorkflowsPage: React.FC = () => { const [editingWorkflow, setEditingWorkflow] = useState(null); + // Initial fetch on mount + useEffect(() => { + refetch(); + }, []); + // Generate columns from attributes const columns = useMemo(() => { return (attributes || []).map(attr => ({ @@ -80,7 +85,7 @@ export const WorkflowsPage: React.FC = () => { // Handle continue workflow - navigate to playground const handleContinueWorkflow = (workflow: Workflow) => { - navigate(`/playground?workflowId=${workflow.id}`); + navigate(`/workflows/playground?workflowId=${workflow.id}`); }; // Handle edit submit