409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
||
* 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<WorkspaceInputProps> = ({
|
||
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<string[]>([]);
|
||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const chunksRef = useRef<Blob[]>([]);
|
||
|
||
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<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 _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 (
|
||
<div style={{
|
||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||
position: 'relative',
|
||
flexShrink: 0,
|
||
}}>
|
||
{/* Pending uploaded files */}
|
||
{pendingFiles.length > 0 && (
|
||
<div style={{
|
||
padding: '6px 24px',
|
||
display: 'flex',
|
||
gap: 6,
|
||
flexWrap: 'wrap',
|
||
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
||
background: 'var(--bg-secondary, #fafafa)',
|
||
}}>
|
||
{pendingFiles.map(pf => (
|
||
<span
|
||
key={pf.fileId}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||
background: '#fff3e0', color: '#e65100', fontWeight: 500,
|
||
border: '1px solid #ffe0b2',
|
||
}}
|
||
>
|
||
📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
|
||
{onRemovePendingFile && (
|
||
<button
|
||
onClick={() => onRemovePendingFile(pf.fileId)}
|
||
style={{
|
||
border: 'none', background: 'none', cursor: 'pointer',
|
||
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Attachment bar */}
|
||
{hasAttachments && (
|
||
<div style={{
|
||
padding: '6px 24px',
|
||
display: 'flex',
|
||
gap: 6,
|
||
flexWrap: 'wrap',
|
||
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
||
background: '#fafafa',
|
||
}}>
|
||
{attachedFileIds.map(fId => {
|
||
const file = files.find(f => f.id === fId);
|
||
return (
|
||
<span
|
||
key={fId}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
|
||
}}
|
||
>
|
||
📄 {file?.fileName || fId}
|
||
<button
|
||
onClick={() => _removeAttachedFile(fId)}
|
||
style={{
|
||
border: 'none', background: 'none', cursor: 'pointer',
|
||
fontSize: 12, color: '#1565c0', 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 || dsId}
|
||
<button
|
||
onClick={() => _removeAttachedDataSource(dsId)}
|
||
style={{
|
||
border: 'none', background: 'none', cursor: 'pointer',
|
||
fontSize: 12, color: '#2e7d32', padding: 0, lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Autocomplete dropdown */}
|
||
{showAutocomplete && filteredFiles.length > 0 && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: 24,
|
||
right: 24,
|
||
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}
|
||
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>
|
||
)}
|
||
|
||
{/* Main input row */}
|
||
<div style={{ padding: '8px 24px 12px', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={prompt}
|
||
onChange={_handleChange}
|
||
onKeyDown={_handleKeyDown}
|
||
placeholder="Type a message... Use @filename to reference files"
|
||
disabled={isProcessing}
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 40,
|
||
maxHeight: 120,
|
||
resize: 'vertical',
|
||
padding: '10px 14px',
|
||
borderRadius: 8,
|
||
border: '1px solid var(--border-color, #ccc)',
|
||
fontSize: 14,
|
||
fontFamily: 'inherit',
|
||
outline: 'none',
|
||
}}
|
||
rows={1}
|
||
/>
|
||
|
||
<button
|
||
onClick={onFileUploadClick}
|
||
disabled={uploading || isProcessing}
|
||
title="Datei anhängen"
|
||
style={{
|
||
width: 40, height: 40, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
|
||
background: 'var(--secondary-bg, #f5f5f5)',
|
||
color: uploading ? '#1976d2' : '#666',
|
||
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
|
||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
opacity: isProcessing ? 0.5 : 1,
|
||
}}
|
||
>
|
||
{uploading ? '...' : '+'}
|
||
</button>
|
||
|
||
{onProvidersChange && (
|
||
<ProviderMultiSelect
|
||
selectedProviders={selectedProviders}
|
||
onChange={onProvidersChange}
|
||
showLabel={false}
|
||
excludeByDefault={['privatellm']}
|
||
disabled={isProcessing}
|
||
/>
|
||
)}
|
||
|
||
<button
|
||
onClick={_toggleVoice}
|
||
title={voiceActive ? 'Stop recording' : 'Voice input'}
|
||
style={{
|
||
width: 40, height: 40, borderRadius: 8, border: 'none',
|
||
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
|
||
color: voiceActive ? '#fff' : '#666',
|
||
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
>
|
||
{voiceActive ? '■' : '🎤'}
|
||
</button>
|
||
|
||
{isProcessing ? (
|
||
<button
|
||
onClick={onStop}
|
||
style={{
|
||
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600,
|
||
}}
|
||
>
|
||
Stop
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={_handleSend}
|
||
disabled={!prompt.trim()}
|
||
style={{
|
||
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||
background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
|
||
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
|
||
}}
|
||
>
|
||
Send
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|