839 lines
36 KiB
TypeScript
839 lines
36 KiB
TypeScript
/**
|
||
* 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;
|