frontend_nyla/src/pages/workflows/PlaygroundPage.tsx
2026-02-12 00:42:09 +01:00

839 lines
36 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.

/**
* 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<HTMLInputElement>(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<MediaRecorder | null>(null);
// Voice language selection (defaults to user profile language)
const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
// 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<HTMLInputElement>) => {
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 (
<div className={styles.emptyState}>
<FaComment className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Nachrichten</h3>
<p className={styles.emptyDescription}>
Starten Sie einen neuen Workflow oder wählen Sie einen bestehenden aus.
</p>
</div>
);
}
return (
<Messages
messages={messages as any as Message[]}
variant="chat"
showDocuments={true}
showMetadata={false}
onFileDelete={handleFileDelete}
onFileRemove={handleFileRemove}
onFileView={handleFileView}
onFileDownload={handleFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
onMessageDelete={handleMessageDelete}
deletingMessages={deletingMessages}
emptyMessage="Keine Nachrichten"
/>
);
};
// 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 (
<div className={styles.emptyState} style={{ padding: '2rem' }}>
<FaTasks className={styles.emptyIcon} style={{ fontSize: '2rem' }} />
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
Keine aktiven Operationen
</p>
</div>
);
}
const renderOperation = (operationId: string, depth: number = 0, roundOperations?: Map<string, any>) => {
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 (
<div
key={operationId}
style={{
paddingLeft: `${depth * 1}rem`,
paddingTop: '0.5rem',
paddingBottom: '0.5rem',
borderBottom: depth === 0 ? '1px solid var(--border-color)' : 'none',
}}
>
<div
onClick={() => onToggleOperationExpanded(operationId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: childOps.length > 0 ? 'pointer' : 'default',
}}
>
{childOps.length > 0 && (
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: operation.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
)}
<span style={{
flex: 1,
fontSize: '0.8125rem',
color: 'var(--text-primary)',
fontWeight: depth === 0 ? 500 : 400,
}}>
{operation.operationName || operationId.slice(0, 20)}
</span>
{operation.latestProgress !== null && operation.latestProgress < 1 && (
<span style={{
fontSize: '0.6875rem',
color: 'var(--text-secondary)',
}}>
{Math.round(operation.latestProgress * 100)}%
</span>
)}
{operation.latestStatus && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-bg, #dcfce7)'
: operation.latestStatus === 'running'
? 'var(--info-bg, #dbeafe)'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-bg, #fee2e2)'
: 'var(--bg-secondary)',
color: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-color, #16a34a)'
: operation.latestStatus === 'running'
? 'var(--info-color, #2563eb)'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-color, #dc2626)'
: 'var(--text-secondary)',
}}>
{operation.latestStatus}
</span>
)}
</div>
{operation.expanded && childOps.length > 0 && (
<div style={{ marginTop: '0.25rem' }}>
{childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
</div>
)}
</div>
);
};
// If we have rounds, render them
if (hasRounds) {
const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]);
return (
<div>
{sortedRounds.map(([roundNumber, round]) => (
<div key={`round-${roundNumber}`} style={{ marginBottom: '0.5rem' }}>
{/* Round Header */}
<div
onClick={() => 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',
}}
>
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: round.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
<span style={{
flex: 1,
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--text-primary)',
}}>
Runde {roundNumber}
</span>
{round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--success-bg, #dcfce7)',
color: 'var(--success-color, #16a34a)',
}}>
abgeschlossen
</span>
)}
{roundNumber === currentRound && !round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--info-bg, #dbeafe)',
color: 'var(--info-color, #2563eb)',
}}>
aktiv
</span>
)}
</div>
{/* Round Operations */}
{round.expanded && (
<div style={{
paddingLeft: '0.5rem',
borderLeft: '2px solid var(--border-color)',
marginLeft: '0.5rem',
}}>
{round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))}
</div>
)}
</div>
))}
</div>
);
}
// Fallback: render without rounds (for backward compatibility)
return (
<div>
{dashboardTree.rootOperations.map(opId => renderOperation(opId))}
</div>
);
};
// Permission check - also show while loading
if (playgroundUIPermission === false) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Kein Zugriff</h3>
<p className={styles.emptyDescription}>
Sie haben keine Berechtigung für den Chat Playground.
</p>
</div>
</div>
);
}
// Show loading state while permission is being checked (undefined)
if (playgroundUIPermission === undefined) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<p>Lade...</p>
</div>
</div>
);
}
return (
<div className={styles.playgroundContainer}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* Page Header */}
<header className={styles.pageHeader}>
<div className={styles.headerLeft}>
<div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */}
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
</span>
)}
</div>
</div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>
<div className={styles.headerControls}>
<select
className={styles.selectDropdown}
value={workflowId || ''}
onChange={(e) => handleWorkflowChange(e.target.value || null)}
disabled={isRunning}
>
<option value="">Neuer Workflow</option>
{workflowItems?.map((item: any) => (
<option key={item.id} value={item.id}>
{item.label || item.id}
</option>
))}
</select>
</div>
</header>
{/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
<div
ref={containerRef}
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''} ${isDragOver ? styles.dragOver : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className={styles.dragOverlay}>
<div className={styles.dragOverlayContent}>
<FaFile style={{ fontSize: '3rem', marginBottom: '1rem' }} />
<p>Dateien hier ablegen</p>
</div>
</div>
)}
{/* Left Panel - Chat Messages */}
<div
className={styles.leftPanel}
style={{ width: `${leftWidth}%` }}
>
<div className={styles.contentSection}>
<div className={styles.contentHeader}>
<h3 className={styles.panelTitle}>
<FaComment style={{ marginRight: '0.5rem' }} />
Nachrichten
</h3>
</div>
<div className={styles.contentArea}>
{renderMessages()}
</div>
</div>
</div>
{/* Resize Divider */}
<div
className={`${styles.resizeDivider} ${isDragging ? styles.dragging : ''}`}
onMouseDown={handleMouseDown}
>
<div className={styles.dividerHandle} />
</div>
{/* Right Panel - Dashboard */}
<div
className={styles.rightPanel}
style={{ width: `${100 - leftWidth}%` }}
>
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<FaTasks style={{ marginRight: '0.5rem' }} />
Dashboard
</h3>
</div>
<div className={styles.panelContent}>
{renderDashboard()}
</div>
</div>
</div>
{/* Input Footer */}
<div
className={`${styles.inputFooter} ${isDragOver ? styles.dragOverFooter : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Prompts Selection Row */}
<div className={styles.promptsRow}>
<div className={styles.promptsSelect}>
<FaFileAlt style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginRight: '0.5rem' }} />
<select
className={styles.promptDropdown}
value={selectedPromptId}
onChange={(e) => handlePromptSelect(e.target.value)}
disabled={isRunning}
>
<option value="">Prompt-Vorlage wählen...</option>
{prompts?.map((prompt: any) => (
<option key={prompt.id} value={prompt.id}>
{prompt.name || prompt.content?.substring(0, 50) + '...'}
</option>
))}
</select>
</div>
</div>
{/* Pending files */}
{pendingFiles && pendingFiles.length > 0 && (
<div className={styles.pendingFiles}>
{pendingFiles.map((file: any) => (
<div key={file.fileId} className={styles.pendingFile}>
<FaFile style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }} />
<span className={styles.pendingFileName}>{file.fileName}</span>
<button
className={styles.removeFileButton}
onClick={() => handleFileRemove(file)}
title="Entfernen"
>
×
</button>
</div>
))}
</div>
)}
{/* Input row */}
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>
<div className={styles.textareaWrapper}>
<textarea
className={styles.inputTextarea}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
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) {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<div className={styles.inputControls}>
<div className={styles.fileButtons}>
<button
className={styles.iconButton}
onClick={handleFileClick}
disabled={false}
title="Datei anhängen"
>
<FaPlus />
</button>
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
/>
<VoiceLanguageSelect
value={voiceLanguage}
onChange={setVoiceLanguage}
disabled={isRecording}
compact={true}
title="Sprache für Spracherkennung"
/>
<button
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
onClick={handleVoiceClick}
disabled={false}
title={isRecording ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
>
{isRecording ? <FaSquare /> : <FaMicrophone />}
</button>
</div>
<div className={styles.actionButtons}>
{/* Stop button - only visible when running */}
{isRunning && (
<button
type="button"
className={styles.stopButton}
onClick={handleStop}
disabled={isStopping}
title="Workflow stoppen"
>
<FaStop />
{isStopping ? 'Stoppt...' : 'Stop'}
</button>
)}
{/* Send button - always visible with dynamic text */}
<button
type="button"
className={styles.primaryButton}
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
>
<FaPaperPlane />
{isSubmitting
? 'Senden...'
: isRunning
? 'Neue Eingabe'
: !workflowId
? 'Starten'
: 'Senden'
}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PlaygroundPage;