890 lines
33 KiB
TypeScript
890 lines
33 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
});
|