/** * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, * voice toggle, and data source selection. */ import React, { useState, useCallback, useRef } from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import type { WorkspaceFile, DataSource } from './useWorkspace'; interface PendingFile { fileId: string; fileName: 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; } export const WorkspaceInput: React.FC = ({ instanceId, onSend, isProcessing, onStop, files, dataSources, pendingFiles = [], onRemovePendingFile, onFileUploadClick, uploading = false, selectedProviders = [], onProvidersChange, }) => { const [prompt, setPrompt] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [voiceActive, setVoiceActive] = useState(false); const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const textareaRef = useRef(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); 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); setAttachedFileIds([]); setAttachedDataSourceIds([]); }, [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 _toggleVoice = useCallback(async () => { if (voiceActive) { mediaRecorderRef.current?.stop(); setVoiceActive(false); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); chunksRef.current = []; recorder.ondataavailable = (e) => chunksRef.current.push(e.data); recorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); try { const formData = new FormData(); formData.append('audio', blob, 'voice.webm'); const res = await fetch(`/api/workspace/${instanceId}/voice/transcribe`, { method: 'POST', body: formData, }); const data = await res.json(); if (data.text) { setPrompt(prev => prev + (prev ? ' ' : '') + data.text); } } catch (err) { console.error('Voice transcription failed:', err); } }; recorder.start(); mediaRecorderRef.current = recorder; setVoiceActive(true); } catch (err) { console.error('Microphone access denied:', err); } }, [voiceActive, instanceId]); const filteredFiles = showAutocomplete ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) : []; const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0; return (
{/* Pending uploaded files */} {pendingFiles.length > 0 && (
{pendingFiles.map(pf => ( 📎 {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 */}