frontend_nyla/src/pages/views/workspace/WorkspaceInput.tsx
2026-03-17 19:19:32 +01:00

637 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

/**
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
* voice toggle (generic audio capture hook), and data source selection.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource } from './useWorkspace';
const _STT_LANGUAGES = [
{ code: 'de-DE', label: 'Deutsch' },
{ code: 'en-US', label: 'English (US)' },
{ code: 'en-GB', label: 'English (UK)' },
{ code: 'fr-FR', label: 'Francais' },
{ code: 'it-IT', label: 'Italiano' },
{ code: 'es-ES', label: 'Espanol' },
{ code: 'pt-BR', label: 'Portugues' },
{ code: 'nl-NL', label: 'Nederlands' },
{ code: 'pl-PL', label: 'Polski' },
{ code: 'ru-RU', label: 'Russkij' },
{ code: 'ja-JP', label: 'Japanese' },
{ code: 'zh-CN', label: 'Chinese' },
];
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface TreeItemDrop {
id: string;
type: 'file' | 'folder';
name: string;
}
interface WorkspaceInputProps {
instanceId: string;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[]) => void;
isProcessing: boolean;
onStop: () => void;
files: WorkspaceFile[];
dataSources: DataSource[];
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void;
uploading?: boolean;
selectedProviders?: string[];
onProvidersChange?: (providers: string[]) => void;
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void;
}
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
instanceId: _instanceId,
onSend,
isProcessing,
onStop,
files,
dataSources,
pendingFiles = [],
onRemovePendingFile,
onFileUploadClick,
uploading = false,
selectedProviders = [],
onProvidersChange,
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
}) => {
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState('');
const [treeDropOver, setTreeDropOver] = useState(false);
const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
useEffect(() => {
localStorage.setItem('workspace_stt_lang', voiceLanguage);
}, [voiceLanguage]);
const _extractFileRefs = useCallback(
(text: string): string[] => {
const pattern = /@([\w.\-]+)/g;
const matched: string[] = [];
let match;
while ((match = pattern.exec(text)) !== null) {
const ref = match[1];
const file = files.find(
f => f.fileName === ref || f.fileName.toLowerCase() === ref.toLowerCase(),
);
if (file && !matched.includes(file.id)) {
matched.push(file.id);
}
}
return matched;
},
[files],
);
const _handleSend = useCallback(() => {
const trimmed = prompt.trim();
if (!trimmed || isProcessing) return;
const inlineFileIds = _extractFileRefs(trimmed);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
onSend(trimmed, allFileIds, attachedDataSourceIds);
setPrompt('');
setShowAutocomplete(false);
setShowSourcePicker(false);
setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, onSend]);
const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_handleSend();
}
},
[_handleSend],
);
const _handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setPrompt(value);
const cursorPos = e.target.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/);
if (atMatch) {
setAutocompleteFilter(atMatch[1].toLowerCase());
setShowAutocomplete(true);
} else {
setShowAutocomplete(false);
}
},
[],
);
const _insertFileRef = useCallback(
(fileName: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBefore = prompt.slice(0, cursorPos);
const textAfter = prompt.slice(cursorPos);
const atStart = textBefore.lastIndexOf('@');
const newText = textBefore.slice(0, atStart) + `@${fileName} ` + textAfter;
setPrompt(newText);
setShowAutocomplete(false);
textarea.focus();
},
[prompt],
);
const _removeAttachedFile = useCallback((fileId: string) => {
setAttachedFileIds(prev => prev.filter(id => id !== fileId));
}, []);
const _removeAttachedDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _buildPromptFromRefs = useCallback(() => {
const parts = [
promptBeforeVoiceRef.current,
finalizedTextRef.current,
currentInterimRef.current,
].filter(Boolean);
return parts.join(' ');
}, []);
const voiceStream = useVoiceStream({
onFinal: (text) => {
finalizedTextRef.current = finalizedTextRef.current
? `${finalizedTextRef.current} ${text}`
: text;
currentInterimRef.current = '';
setPrompt(_buildPromptFromRefs());
},
onInterim: (text) => {
currentInterimRef.current = text;
setPrompt(_buildPromptFromRefs());
},
onError: (error) => {
console.warn('Workspace voice stream error', error);
},
});
const _stopVoiceCapture = useCallback(() => {
if (currentInterimRef.current) {
finalizedTextRef.current = finalizedTextRef.current
? `${finalizedTextRef.current} ${currentInterimRef.current}`
: currentInterimRef.current;
currentInterimRef.current = '';
}
setPrompt(_buildPromptFromRefs());
voiceStream.stop();
setVoiceActive(false);
}, [voiceStream, _buildPromptFromRefs]);
const _toggleVoice = useCallback(async () => {
if (voiceActive) {
_stopVoiceCapture();
return;
}
promptBeforeVoiceRef.current = prompt;
finalizedTextRef.current = '';
currentInterimRef.current = '';
try {
setVoiceActive(true);
await voiceStream.start(voiceLanguage);
} catch {
setVoiceActive(false);
}
}, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]);
const filteredFiles = showAutocomplete
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
: [];
const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0;
const _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40;
const _handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!onPasteAsFile) return;
const text = e.clipboardData.getData('text/plain');
if (text && text.length >= 1000) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `pasted-text-${Date.now()}.txt`, { type: 'text/plain' });
onPasteAsFile(file);
}
}, [onPasteAsFile]);
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
}
}, []);
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault();
e.stopPropagation();
setTreeDropOver(false);
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items);
}
}, [onTreeItemsDrop]);
return (
<div
style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
outline: treeDropOver ? '2px dashed #1976d2' : 'none',
background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handlePromptDragOver}
onDragLeave={_handlePromptDragLeave}
onDrop={_handlePromptDrop}
>
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
<div style={{
padding: `6px ${_horizontalPadding}px`,
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<span
key={pf.fileId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500,
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}
>
{pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{onRemovePendingFile && (
<button
onClick={() => onRemovePendingFile(pf.fileId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
}}
>
×
</button>
)}
</span>
))}
</div>
)}
{/* Attachment bar */}
{hasAttachments && (
<div style={{
padding: `6px ${_horizontalPadding}px`,
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: '#fafafa',
}}>
{attachedFileIds.map(fId => {
const file = files.find(f => f.id === fId);
return (
<span
key={fId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
}}
>
📄 {file?.fileName || fId}
<button
onClick={() => _removeAttachedFile(fId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#1565c0', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
{attachedDataSourceIds.map(dsId => {
const ds = dataSources.find(d => d.id === dsId);
return (
<span
key={dsId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}
>
🔗 {ds?.label || dsId}
<button
onClick={() => _removeAttachedDataSource(dsId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#2e7d32', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
</div>
)}
{/* Autocomplete dropdown */}
{showAutocomplete && filteredFiles.length > 0 && (
<div style={{
position: 'absolute',
bottom: '100%',
left: _horizontalPadding,
right: _horizontalPadding,
maxHeight: 200,
overflowY: 'auto',
background: '#fff',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
boxShadow: '0 -2px 8px rgba(0,0,0,0.1)',
zIndex: 10,
}}>
{filteredFiles.slice(0, 10).map(f => (
<div
key={f.id}
onClick={() => _insertFileRef(f.fileName)}
style={{
padding: '8px 12px',
cursor: 'pointer',
fontSize: 13,
borderBottom: '1px solid #f0f0f0',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
>
@{f.fileName}
<span style={{ color: '#999', marginLeft: 8, fontSize: 11 }}>
{f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB
</span>
</div>
))}
</div>
)}
{/* Main input row */}
<div style={{
padding: `8px ${_horizontalPadding}px 12px`,
display: 'flex',
gap: 8,
alignItems: isMobile ? 'stretch' : 'flex-end',
flexWrap: isMobile ? 'wrap' : 'nowrap',
}}>
<textarea
ref={textareaRef}
value={prompt}
onChange={_handleChange}
onKeyDown={_handleKeyDown}
onPaste={_handlePaste}
placeholder="Type a message... Use @filename to reference files"
disabled={isProcessing}
style={{
flex: 1,
minHeight: isMobile ? 44 : 40,
maxHeight: 120,
resize: 'vertical',
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--border-color, #ccc)',
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
flexBasis: isMobile ? '100%' : undefined,
}}
rows={1}
/>
<button
onClick={onFileUploadClick}
disabled={uploading || isProcessing}
title="Datei anhängen"
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)',
color: uploading ? '#1976d2' : '#666',
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
}}
>
{uploading ? '...' : '+'}
</button>
{dataSources.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={isProcessing}
title="Datenquellen anhängen"
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: attachedDataSourceIds.length > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: attachedDataSourceIds.length > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
position: 'relative',
}}
>
🔗
{attachedDataSourceIds.length > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div
key={ds.id}
onClick={() => _toggleDataSource(ds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
</div>
)}
</div>
)}
{onProvidersChange && (
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing}
/>
)}
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
<button
onClick={() => setShowLangPicker(prev => !prev)}
title="Sprache waehlen"
style={{
height: _controlSize, borderRadius: '8px 0 0 8px', border: '1px solid var(--border-color, #ddd)',
borderRight: 'none',
background: 'var(--secondary-bg, #f5f5f5)',
color: '#666', cursor: 'pointer', fontSize: 10, padding: '0 6px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceLanguage.split('-')[0].toUpperCase()}
</button>
<button
onClick={_toggleVoice}
title={voiceActive ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
style={{
width: _controlSize, height: _controlSize, borderRadius: '0 8px 8px 0', border: 'none',
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
color: voiceActive ? '#fff' : '#666',
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceActive ? '■' : '\uD83C\uDFA4'}
</button>
{showLangPicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
maxHeight: 240, overflowY: 'auto', minWidth: 160,
}}>
{_STT_LANGUAGES.map(lang => (
<div
key={lang.code}
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.label} ({lang.code})
</div>
))}
</div>
)}
</div>
{isProcessing ? (
<button
onClick={onStop}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600,
minWidth: isMobile ? 84 : undefined,
}}
>
Stop
</button>
) : (
<button
onClick={_handleSend}
disabled={!prompt.trim()}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
minWidth: isMobile ? 84 : undefined,
}}
>
Send
</button>
)}
</div>
</div>
);
};