/** * PlaygroundPage (Chat Playground) * * 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, useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useDashboardInputForm } from '../../hooks/usePlayground'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { usePrompts } from '../../hooks/usePrompts'; import { useCurrentInstance } from '../../hooks/useCurrentInstance'; import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents'; import { ProviderMultiSelect } from '../../components/ProviderSelector'; import type { Message } from '../../components/UiComponents/Messages/MessagesTypes'; 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'); // Get feature instance context const { instance } = useCurrentInstance(); const instanceId = instance?.id || ''; // Main hook for input form and data const hookData = useDashboardInputForm(instanceId); const { inputValue, onInputChange, isRunning, isStopping, handleSubmit, handleStop, isSubmitting, workflowStatus, messages, dashboardTree, onToggleOperationExpanded, onToggleRoundExpanded, currentRound, workflowId, onWorkflowSelect, workflowItems, pendingFiles, handleFileRemove, handleFileDelete, handleFileView, handleFileDownload, latestStats, playgroundUIPermission, deletingFiles, previewingFiles, downloadingFiles, handleMessageDelete, deletingMessages, selectedProviders, onProvidersChange, } = hookData; const { prompts, refetch: refetchPrompts } = usePrompts(); const { showError, showSuccess } = useToast(); // Resizable panels hook const { leftWidth, isDragging, handleMouseDown, containerRef, } = useResizablePanels({ storageKey: 'playground-panel-width', defaultLeftWidth: 70, minLeftWidth: 40, maxLeftWidth: 85, }); // 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); // Voice language selection (defaults to user profile language) const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage(); // 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); 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('audioFile', audioBlob, 'voice_recording.webm'); formData.append('language', voiceLanguage); // Call speech-to-text API (Google Cloud) const response = await api.post('/voice-google/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) { onWorkflowSelect(null); } else { const item = workflowItems?.find((w: any) => w.id === id); if (item) { onWorkflowSelect(item); } } }; // Handle file upload click const handleFileClick = () => { fileInputRef.current?.click(); }; // Handle file change const handleFileChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (files && hookData.handleFileUpload) { for (const file of Array.from(files)) { await hookData.handleFileUpload(file); } } // Reset input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Render messages using the Messages component with document support const renderMessages = () => { if (!messages || messages.length === 0) { return (

Keine Nachrichten

Starten Sie einen neuen Workflow oder wählen Sie einen bestehenden aus.

); } return ( ); }; // Render dashboard tree with rounds const renderDashboard = () => { // 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 (

Keine aktiven Operationen

); } 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()) .filter(([_, op]) => op.parentId === operationId) .map(([id]) => id); return (
onToggleOperationExpanded(operationId)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: childOps.length > 0 ? 'pointer' : 'default', }} > {childOps.length > 0 && ( â–¶ )} {operation.operationName || operationId.slice(0, 20)} {operation.latestProgress !== null && operation.latestProgress < 1 && ( {Math.round(operation.latestProgress * 100)}% )} {operation.latestStatus && ( {operation.latestStatus} )}
{operation.expanded && childOps.length > 0 && (
{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))}
); }; // Permission check - also show while loading if (playgroundUIPermission === false) { return (

Kein Zugriff

Sie haben keine Berechtigung fĂĽr den Chat Playground.

); } // Show loading state while permission is being checked (undefined) if (playgroundUIPermission === undefined) { return (

Lade...

); } return (
{/* Hidden file input */} {/* Page Header */}

Chat Playground

{/* Stats display in header */}
↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)} {(latestStats?.processingTime ?? 0) > 0 && ( ⏱️ {formatDuration(latestStats?.processingTime || 0)} )} {(latestStats?.priceUsd ?? 0) > 0 && ( 💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} )}

Workflow-AusfĂĽhrung und Chat-Interaktion

{/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
{/* Drag overlay */} {isDragOver && (

Dateien hier ablegen

)} {/* Left Panel - Chat Messages */}

Nachrichten

{renderMessages()}
{/* Resize Divider */}
{/* Right Panel - Dashboard */}

Dashboard

{renderDashboard()}
{/* Input Footer */}
{/* Prompts Selection Row */}
{/* Pending files */} {pendingFiles && pendingFiles.length > 0 && (
{pendingFiles.map((file: any) => (
{file.fileName}
))}
)} {/* Input row */}