/** * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, * voice toggle (generic audio capture hook), and data source selection. */ import React, { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef, } from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector'; import { getPageIcon } from '../../../config/pageRegistry'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import api from '../../../api'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext'; export interface TreeItemDrop { id: string; type: 'file' | 'group'; name: string; } /** An attachment chip shown in the input bar. * Groups are kept as-is (show as single chip); file IDs are resolved at send-time. */ export type AttachmentItem = | { type: 'file'; id: string; name: string } | { type: 'group'; id: string; name: string; fileIds: string[] }; /** Parent resolves groups to concrete file IDs using persisted group tree. */ export type ResolveTreeItemsToFileIds = (items: TreeItemDrop[]) => Promise; export interface WorkspaceInputHandle { attachFileIds: (ids: string[]) => void; attachTreeItems: (items: TreeItemDrop[]) => Promise; ingestTreeDataTransfer: (dt: DataTransfer) => Promise; } interface WorkspaceInputProps { instanceId: string; onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void; isProcessing: boolean; onStop: () => void; files: WorkspaceFile[]; dataSources: DataSource[]; featureDataSources?: FeatureDataSource[]; resolveTreeItemsToFileIds: ResolveTreeItemsToFileIds; onFileUploadClick?: () => void; uploading?: boolean; providerSelection?: ProviderSelection; onProviderSelectionChange?: (selection: ProviderSelection) => void; isMobile?: boolean; onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; pendingAttachDsId?: string; onPendingAttachDsConsumed?: () => void; pendingAttachFdsId?: string; onPendingAttachFdsConsumed?: () => void; onPasteAsFile?: (file: File) => void; draftAppend?: string; onDraftAppendConsumed?: () => void; workflowId?: string | null; loadedAttachedDataSourceIds?: string[]; loadedAttachedFeatureDataSourceIds?: string[]; loadedNonce?: number; } function _itemsFromTreeDataTransfer(dt: DataTransfer): TreeItemDrop[] | null { const groupId = dt.getData('application/group-id'); if (groupId) { return [{ id: groupId, type: 'group', name: dt.getData('text/plain') || groupId }]; } const portaG = dt.getData('application/porta-group'); if (portaG) { return [{ id: portaG, type: 'group', name: dt.getData('text/plain') || portaG }]; } const treeItemsJson = dt.getData('application/tree-items'); if (treeItemsJson) { try { const items = JSON.parse(treeItemsJson) as TreeItemDrop[]; return Array.isArray(items) && items.length ? items : null; } catch { return null; } } const fileIdsJson = dt.getData('application/file-ids'); if (fileIdsJson) { try { const ids: string[] = JSON.parse(fileIdsJson); return ids.map(id => ({ id, type: 'file' as const, name: id })); } catch { return null; } } const singleFileId = dt.getData('application/file-id'); if (singleFileId) { const lbl = dt.getData('text/plain'); const name = lbl && lbl !== singleFileId ? lbl : singleFileId; return [{ id: singleFileId, type: 'file', name }]; } return null; } export const WorkspaceInput = forwardRef(function WorkspaceInput({ instanceId, onSend, isProcessing, onStop, files, dataSources, featureDataSources = [], resolveTreeItemsToFileIds, onFileUploadClick, uploading = false, providerSelection, onProviderSelectionChange, isMobile = false, onFeatureSourceDrop, onDataSourceDrop, pendingAttachDsId, onPendingAttachDsConsumed, pendingAttachFdsId, onPendingAttachFdsConsumed, onPasteAsFile, draftAppend, onDraftAppendConsumed, workflowId, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds, loadedNonce, }, ref) { const { t } = useLanguage(); const { languages: voiceCatalogLanguages } = useVoiceCatalog(); const [prompt, setPrompt] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [treeDropOver, setTreeDropOver] = useState(false); const textareaAreaDragDepth = useRef(0); const [voiceActive, setVoiceActive] = useState(false); const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [showLangPicker, setShowLangPicker] = useState(false); const _sttPrefsLoaded = useRef(false); const [attachments, setAttachments] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); const [neutralizeActive, setNeutralizeActive] = useState(false); const textareaRef = useRef(null); const _appendAttachment = useCallback((item: AttachmentItem) => { setAttachments(prev => prev.some(a => a.id === item.id) ? prev : [...prev, item]); }, []); const _appendFileIds = useCallback((ids: string[]) => { if (!ids.length) return; setAttachments(prev => { const existing = new Set(prev.map(a => a.id)); const added = ids.filter(id => !existing.has(id)).map(id => ({ type: 'file' as const, id, name: id })); return added.length ? [...prev, ...added] : prev; }); }, []); useEffect(() => { if (draftAppend) { setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend); onDraftAppendConsumed?.(); } }, [draftAppend, onDraftAppendConsumed]); const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => { if (!instanceId || !workflowId) return; api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, { dataSourceIds: dsIds, featureDataSourceIds: fdsIds, }).catch(err => console.warn('Failed to persist chat attachments:', err)); }, [instanceId, workflowId]); useEffect(() => { if (!pendingAttachDsId) return; setAttachedDataSourceIds(prev => { if (prev.includes(pendingAttachDsId)) return prev; const next = [...prev, pendingAttachDsId]; _persistAttachments(next, attachedFeatureDataSourceIds); return next; }); onPendingAttachDsConsumed?.(); }, [pendingAttachDsId, onPendingAttachDsConsumed, _persistAttachments, attachedFeatureDataSourceIds]); useEffect(() => { if (!pendingAttachFdsId) return; setAttachedFeatureDataSourceIds(prev => { if (prev.includes(pendingAttachFdsId)) return prev; const next = [...prev, pendingAttachFdsId]; _persistAttachments(attachedDataSourceIds, next); return next; }); onPendingAttachFdsConsumed?.(); }, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]); useEffect(() => { if (loadedNonce === undefined) return; setAttachments([]); setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []); setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []); }, [loadedNonce, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds]); const _reconciledDsForNonce = useRef(undefined); const _reconciledFdsForNonce = useRef(undefined); useEffect(() => { if (loadedNonce === undefined) return; if (_reconciledDsForNonce.current === loadedNonce) return; if (dataSources.length === 0) return; _reconciledDsForNonce.current = loadedNonce; const validIds = new Set(dataSources.map(d => d.id)); setAttachedDataSourceIds(prev => { const filtered = prev.filter(id => validIds.has(id)); return filtered.length === prev.length ? prev : filtered; }); }, [loadedNonce, dataSources]); useEffect(() => { if (loadedNonce === undefined) return; if (_reconciledFdsForNonce.current === loadedNonce) return; if (featureDataSources.length === 0) return; _reconciledFdsForNonce.current = loadedNonce; const validIds = new Set(featureDataSources.map(d => d.id)); setAttachedFeatureDataSourceIds(prev => { const filtered = prev.filter(id => validIds.has(id)); return filtered.length === prev.length ? prev : filtered; }); }, [loadedNonce, featureDataSources]); const promptBeforeVoiceRef = useRef(''); const finalizedTextRef = useRef(''); const currentInterimRef = useRef(''); useEffect(() => { if (_sttPrefsLoaded.current) return; _sttPrefsLoaded.current = true; fetch('/api/voice/preferences', { credentials: 'include' }) .then(r => r.ok ? r.json() : null) .then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); }) .catch(() => {}); }, []); const _resolveGroupItem = useCallback(async (item: TreeItemDrop): Promise => { const fileIds = await resolveTreeItemsToFileIds([item]); return { type: 'group', id: item.id, name: item.name, fileIds }; }, [resolveTreeItemsToFileIds]); /** Ingest a DataTransfer and append the right attachment chips. Returns true if handled. */ const _ingestDataTransfer = useCallback(async (dt: DataTransfer): Promise => { // Group with drag-time snapshot of its file IDs const groupId = dt.getData('application/group-id') || dt.getData('application/porta-group'); if (groupId) { const name = dt.getData('text/plain') || groupId; const snapshotJson = dt.getData('application/group-file-ids'); let fileIds: string[] = []; if (snapshotJson) { try { const parsed: unknown = JSON.parse(snapshotJson); if (Array.isArray(parsed)) fileIds = parsed.filter((f): f is string => typeof f === 'string'); } catch { /* ignore */ } } if (!fileIds.length) { fileIds = await resolveTreeItemsToFileIds([{ id: groupId, type: 'group', name }]); } _appendAttachment({ type: 'group', id: groupId, name, fileIds }); return true; } // Generic tree-items (may contain groups or files) const items = _itemsFromTreeDataTransfer(dt); if (!items?.length) return false; await Promise.all(items.map(async item => { if (item.type === 'group') { _appendAttachment(await _resolveGroupItem(item)); } else { _appendAttachment({ type: 'file', id: item.id, name: item.name }); } })); return true; }, [resolveTreeItemsToFileIds, _appendAttachment, _resolveGroupItem]); useImperativeHandle(ref, () => ({ attachFileIds: (ids: string[]) => _appendFileIds(ids), attachTreeItems: async (items: TreeItemDrop[]) => { await Promise.all(items.map(async item => { if (item.type === 'group') { _appendAttachment(await _resolveGroupItem(item)); } else { _appendAttachment({ type: 'file', id: item.id, name: item.name }); } })); }, ingestTreeDataTransfer: (dt: DataTransfer) => _ingestDataTransfer(dt), }), [_appendFileIds, _appendAttachment, _resolveGroupItem, _ingestDataTransfer]); 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 hasFileOrSourceAttachments = attachments.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0; const _canSend = Boolean(prompt.trim()) || attachments.length > 0; const _handleSend = useCallback(() => { if ((!prompt.trim() && attachments.length === 0) || isProcessing) return; const inlineFileIds = _extractFileRefs(prompt); const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds); const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const options = neutralizeActive ? { requireNeutralization: true } : undefined; onSend(prompt.trim(), allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); setPrompt(''); setShowAutocomplete(false); setAttachments([]); }, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, 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 _removeAttachment = useCallback((id: string) => { setAttachments(prev => prev.filter(a => a.id !== id)); }, []); const _removeAttachedDataSource = useCallback((dsId: string) => { setAttachedDataSourceIds(prev => { const next = prev.filter(id => id !== dsId); _persistAttachments(next, attachedFeatureDataSourceIds); return next; }); }, [_persistAttachments, attachedFeatureDataSourceIds]); const _toggleFeatureDataSource = useCallback((fdsId: string) => { setAttachedFeatureDataSourceIds(prev => { const next = prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]; _persistAttachments(attachedDataSourceIds, next); return next; }); }, [_persistAttachments, attachedDataSourceIds]); 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 = hasFileOrSourceAttachments; 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 _isTreeMimeDrag = useCallback((e: React.DragEvent) => { const types = e.dataTransfer.types; return ( types.includes('application/tree-items') || types.includes('application/group-file-ids') || types.includes('application/group-id') || types.includes('application/porta-group') || types.includes('application/file-id') || types.includes('application/file-ids') ); }, []); const _handlePromptDragOver = useCallback((e: React.DragEvent) => { if ( _isTreeMimeDrag(e) || e.dataTransfer.types.includes('application/chat-id') || e.dataTransfer.types.includes('application/feature-source') || e.dataTransfer.types.includes('application/datasource') ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); } }, [_isTreeMimeDrag]); const _handlePromptDragLeave = useCallback((e: React.DragEvent) => { if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) { setTreeDropOver(false); } }, []); const _handleTextareaDragEnter = useCallback((e: React.DragEvent) => { if (!_isTreeMimeDrag(e)) return; e.preventDefault(); textareaAreaDragDepth.current += 1; setTreeDropOver(true); }, [_isTreeMimeDrag]); const _handleTextareaDragLeave = useCallback((e: React.DragEvent) => { if (!_isTreeMimeDrag(e)) return; e.preventDefault(); textareaAreaDragDepth.current = Math.max(0, textareaAreaDragDepth.current - 1); if (textareaAreaDragDepth.current === 0) { setTreeDropOver(false); } }, [_isTreeMimeDrag]); const _handleTextareaDragOver = useCallback((e: React.DragEvent) => { if (!_isTreeMimeDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }, [_isTreeMimeDrag]); const _handlePromptDrop = useCallback(async (e: React.DragEvent) => { textareaAreaDragDepth.current = 0; setTreeDropOver(false); const chatId = e.dataTransfer.getData('application/chat-id'); if (chatId) { e.preventDefault(); e.stopPropagation(); const chatLabel = e.dataTransfer.getData('text/plain'); const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel)); return; } const featureSourceJson = e.dataTransfer.getData('application/feature-source'); if (featureSourceJson && onFeatureSourceDrop) { e.preventDefault(); e.stopPropagation(); const params = JSON.parse(featureSourceJson); onFeatureSourceDrop(params); return; } const dataSourceJson = e.dataTransfer.getData('application/datasource'); if (dataSourceJson && onDataSourceDrop) { e.preventDefault(); e.stopPropagation(); const params = JSON.parse(dataSourceJson); onDataSourceDrop(params); return; } const handled = await _ingestDataTransfer(e.dataTransfer); if (handled) { e.preventDefault(); e.stopPropagation(); textareaRef.current?.focus(); } }, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]); return (
void _handlePromptDrop(e)} > {hasAttachments && (
{attachments.map(att => { const isGroup = att.type === 'group'; const label = isGroup ? att.name : (files.find(f => f.id === att.id)?.fileName || att.name || att.id); const chipBg = isGroup ? '#e8f5e9' : '#e3f2fd'; const chipColor = isGroup ? '#1b5e20' : '#1565c0'; const chipBorder = isGroup ? '1px solid #c8e6c9' : '1px solid #bbdefb'; const countBadge = isGroup ? ` (${att.fileIds.length})` : ''; return ( {isGroup ? '📁' : '📄'} {label}{countBadge} ); })} {attachedDataSourceIds.map(dsId => { const ds = dataSources.find(d => d.id === dsId); return ( 🔗 {ds?.label || ds?.path || dsId} ); })} {attachedFeatureDataSourceIds.map(fdsId => { const fds = featureDataSources.find(d => d.id === fdsId); const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null; return ( {fdsIcon || '\uD83D\uDDC3\uFE0F'} {fds?.label || fdsId} – {fds?.tableName || ''} ); })}
)} {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
))}
)}