/** * 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 = ({ 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([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const textareaRef = useRef(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) => { 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) => { 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 (
{/* Pending uploaded files */} {pendingFiles.length > 0 && (
{pendingFiles.map(pf => ( {pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} {onRemovePendingFile && ( )} ))}
)} {/* Attachment bar */} {hasAttachments && (
{attachedFileIds.map(fId => { const file = files.find(f => f.id === fId); return ( 📄 {file?.fileName || fId} ); })} {attachedDataSourceIds.map(dsId => { const ds = dataSources.find(d => d.id === dsId); return ( 🔗 {ds?.label || dsId} ); })}
)} {/* Autocomplete dropdown */} {showAutocomplete && filteredFiles.length > 0 && (
{filteredFiles.slice(0, 10).map(f => (
_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} {f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB
))}
)} {/* Main input row */}