frontend_nyla/src/pages/views/workspace/WorkspaceInput.tsx

890 lines
33 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,
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<string[]>;
export interface WorkspaceInputHandle {
attachFileIds: (ids: string[]) => void;
attachTreeItems: (items: TreeItemDrop[]) => Promise<void>;
ingestTreeDataTransfer: (dt: DataTransfer) => Promise<boolean>;
}
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<WorkspaceInputHandle, WorkspaceInputProps>(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<AttachmentItem[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(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<number | undefined>(undefined);
const _reconciledFdsForNonce = useRef<number | undefined>(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<AttachmentItem> => {
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<boolean> => {
// 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<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 _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<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 _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 (
<div
style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
background: treeDropOver ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.08))' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handlePromptDragOver}
onDragLeave={_handlePromptDragLeave}
onDrop={e => void _handlePromptDrop(e)}
>
{hasAttachments && (
<div style={{
padding: `8px ${_horizontalPadding}px`,
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: '#fafafa',
}}>
{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 (
<span
key={att.id}
title={isGroup ? `${att.fileIds.length} Datei(en) in dieser Gruppe` : label}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: chipBg, color: chipColor, fontWeight: 500,
border: chipBorder,
}}
>
{isGroup ? '📁' : '📄'} {label}{countBadge}
<button
type="button"
onClick={() => _removeAttachment(att.id)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: chipColor, 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 || ds?.path || dsId}
<button
type="button"
onClick={() => _removeAttachedDataSource(dsId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#2e7d32', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
{attachedFeatureDataSourceIds.map(fdsId => {
const fds = featureDataSources.find(d => d.id === fdsId);
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
return (
<span
key={fdsId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}
>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId} {fds?.tableName || ''}
<button
type="button"
onClick={() => _toggleFeatureDataSource(fdsId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#7b1fa2', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
</div>
)}
{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}
role="presentation"
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>
)}
<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}
onDragEnter={_handleTextareaDragEnter}
onDragLeave={_handleTextareaDragLeave}
onDragOver={_handleTextareaDragOver}
onDrop={e => void _handlePromptDrop(e)}
placeholder={
attachments.length > 0
? t('Nachricht eingeben … ({n} Anhang/Anhänge)', { n: String(attachments.length) })
: t('Geben Sie eine Nachricht ein — Dateien hierher ziehen oder @file verwenden')
}
disabled={isProcessing}
style={{
flex: 1,
minHeight: isMobile ? 52 : 48,
maxHeight: 120,
resize: 'vertical',
padding: '10px 14px',
borderRadius: 8,
border: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : '1px solid var(--border-color, #ccc)',
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
flexBasis: isMobile ? '100%' : undefined,
boxSizing: 'border-box',
}}
rows={1}
/>
<button
type="button"
onClick={onFileUploadClick}
disabled={uploading || isProcessing}
title={t('Datei anhängen')}
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)',
color: uploading ? 'var(--primary-color, #F25843)' : 'var(--text-secondary, #666)',
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
}}
>
{uploading ? '...' : '+'}
</button>
{onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect
selection={providerSelection}
onChange={onProviderSelectionChange}
showLabel={false}
disabled={isProcessing}
/>
)}
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
<button
type="button"
onClick={() => setShowLangPicker(prev => !prev)}
title={t('Sprache wählen')}
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
type="button"
onClick={_toggleVoice}
title={voiceActive ? t('Aufnahme stoppen') : t('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,
}}>
{voiceCatalogLanguages.map(lang => (
<div
key={lang.bcp47}
role="presentation"
onClick={() => {
setVoiceLanguage(lang.bcp47);
setShowLangPicker(false);
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.bcp47 }) }).catch(() => {});
}}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.bcp47 === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
color: lang.bcp47 === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
onMouseEnter={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
onMouseLeave={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
</div>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => setNeutralizeActive(v => !v)}
title={neutralizeActive ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid',
borderColor: neutralizeActive ? '#166534' : 'var(--border-color, #d1d5db)',
background: neutralizeActive ? '#dcfce7' : 'transparent',
cursor: 'pointer', fontSize: '1rem', lineHeight: 1,
opacity: neutralizeActive ? 1 : 0.5,
transition: 'all 0.15s',
}}
>
🔒
</button>
{isProcessing ? (
<button
type="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
type="button"
onClick={_handleSend}
disabled={!_canSend}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: _canSend ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)',
color: '#fff', cursor: _canSend ? 'pointer' : 'default', fontWeight: 600,
minWidth: isMobile ? 84 : undefined,
}}
>
Send
</button>
)}
</div>
</div>
);
});