637 lines
23 KiB
TypeScript
637 lines
23 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 } from 'react';
|
||
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||
import type { WorkspaceFile, DataSource } from './useWorkspace';
|
||
|
||
const _STT_LANGUAGES = [
|
||
{ code: 'de-DE', label: 'Deutsch' },
|
||
{ code: 'en-US', label: 'English (US)' },
|
||
{ code: 'en-GB', label: 'English (UK)' },
|
||
{ code: 'fr-FR', label: 'Francais' },
|
||
{ code: 'it-IT', label: 'Italiano' },
|
||
{ code: 'es-ES', label: 'Espanol' },
|
||
{ code: 'pt-BR', label: 'Portugues' },
|
||
{ code: 'nl-NL', label: 'Nederlands' },
|
||
{ code: 'pl-PL', label: 'Polski' },
|
||
{ code: 'ru-RU', label: 'Russkij' },
|
||
{ code: 'ja-JP', label: 'Japanese' },
|
||
{ code: 'zh-CN', label: 'Chinese' },
|
||
];
|
||
|
||
interface PendingFile {
|
||
fileId: string;
|
||
fileName: string;
|
||
itemType?: 'file' | 'folder';
|
||
}
|
||
|
||
interface TreeItemDrop {
|
||
id: string;
|
||
type: 'file' | 'folder';
|
||
name: 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;
|
||
isMobile?: boolean;
|
||
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||
onPasteAsFile?: (file: File) => void;
|
||
}
|
||
|
||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||
instanceId: _instanceId,
|
||
onSend,
|
||
isProcessing,
|
||
onStop,
|
||
files,
|
||
dataSources,
|
||
pendingFiles = [],
|
||
onRemovePendingFile,
|
||
onFileUploadClick,
|
||
uploading = false,
|
||
selectedProviders = [],
|
||
onProvidersChange,
|
||
isMobile = false,
|
||
onTreeItemsDrop,
|
||
onPasteAsFile,
|
||
}) => {
|
||
const [prompt, setPrompt] = useState('');
|
||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||
const [treeDropOver, setTreeDropOver] = useState(false);
|
||
const [voiceActive, setVoiceActive] = useState(false);
|
||
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
|
||
const [showLangPicker, setShowLangPicker] = useState(false);
|
||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const promptBeforeVoiceRef = useRef('');
|
||
const finalizedTextRef = useRef('');
|
||
const currentInterimRef = useRef('');
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
||
}, [voiceLanguage]);
|
||
|
||
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);
|
||
setShowSourcePicker(false);
|
||
setAttachedFileIds([]);
|
||
}, [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 [showSourcePicker, setShowSourcePicker] = useState(false);
|
||
|
||
const _toggleDataSource = useCallback((dsId: string) => {
|
||
setAttachedDataSourceIds(prev =>
|
||
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||
);
|
||
}, []);
|
||
|
||
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 = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0;
|
||
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 _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
setTreeDropOver(true);
|
||
}
|
||
}, []);
|
||
|
||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||
|
||
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||
if (treeItemsJson && onTreeItemsDrop) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setTreeDropOver(false);
|
||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||
onTreeItemsDrop(items);
|
||
}
|
||
}, [onTreeItemsDrop]);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||
position: 'relative',
|
||
flexShrink: 0,
|
||
outline: treeDropOver ? '2px dashed #1976d2' : 'none',
|
||
background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
|
||
transition: 'background 0.15s, outline 0.15s',
|
||
}}
|
||
onDragOver={_handlePromptDragOver}
|
||
onDragLeave={_handlePromptDragLeave}
|
||
onDrop={_handlePromptDrop}
|
||
>
|
||
{/* Pending uploaded files */}
|
||
{pendingFiles.length > 0 && (
|
||
<div style={{
|
||
padding: `6px ${_horizontalPadding}px`,
|
||
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: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
||
fontWeight: 500,
|
||
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
||
}}
|
||
>
|
||
{pf.itemType === 'folder' ? '📁' : '📎'} {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 ${_horizontalPadding}px`,
|
||
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: _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}
|
||
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 ${_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}
|
||
placeholder="Type a message... Use @filename to reference files"
|
||
disabled={isProcessing}
|
||
style={{
|
||
flex: 1,
|
||
minHeight: isMobile ? 44 : 40,
|
||
maxHeight: 120,
|
||
resize: 'vertical',
|
||
padding: '10px 14px',
|
||
borderRadius: 8,
|
||
border: '1px solid var(--border-color, #ccc)',
|
||
fontSize: 14,
|
||
fontFamily: 'inherit',
|
||
outline: 'none',
|
||
flexBasis: isMobile ? '100%' : undefined,
|
||
}}
|
||
rows={1}
|
||
/>
|
||
|
||
<button
|
||
onClick={onFileUploadClick}
|
||
disabled={uploading || isProcessing}
|
||
title="Datei anhängen"
|
||
style={{
|
||
width: _controlSize, height: _controlSize, 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>
|
||
|
||
{dataSources.length > 0 && (
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setShowSourcePicker(prev => !prev)}
|
||
disabled={isProcessing}
|
||
title="Datenquellen anhängen"
|
||
style={{
|
||
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
|
||
background: attachedDataSourceIds.length > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||
color: attachedDataSourceIds.length > 0 ? '#2e7d32' : '#666',
|
||
cursor: isProcessing ? 'not-allowed' : 'pointer',
|
||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
opacity: isProcessing ? 0.5 : 1,
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
🔗
|
||
{attachedDataSourceIds.length > 0 && (
|
||
<span style={{
|
||
position: 'absolute', top: -4, right: -4,
|
||
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
|
||
borderRadius: '50%', width: 16, height: 16,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}>
|
||
{attachedDataSourceIds.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{showSourcePicker && (
|
||
<div style={{
|
||
position: 'absolute', bottom: '100%', left: 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,
|
||
minWidth: 240, maxHeight: 260, overflowY: 'auto',
|
||
}}>
|
||
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
|
||
Active Sources auswählen
|
||
</div>
|
||
{dataSources.map(ds => {
|
||
const isSelected = attachedDataSourceIds.includes(ds.id);
|
||
return (
|
||
<div
|
||
key={ds.id}
|
||
onClick={() => _toggleDataSource(ds.id)}
|
||
style={{
|
||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
background: isSelected ? '#e8f5e9' : 'transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
|
||
>
|
||
<span style={{
|
||
width: 16, height: 16, borderRadius: 3,
|
||
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
|
||
background: isSelected ? '#2e7d32' : 'transparent',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
|
||
}}>
|
||
{isSelected ? '✓' : ''}
|
||
</span>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{ds.label || ds.path || ds.id}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{onProvidersChange && (
|
||
<ProviderMultiSelect
|
||
selectedProviders={selectedProviders}
|
||
onChange={onProvidersChange}
|
||
showLabel={false}
|
||
excludeByDefault={['privatellm']}
|
||
disabled={isProcessing}
|
||
/>
|
||
)}
|
||
|
||
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
|
||
<button
|
||
onClick={() => setShowLangPicker(prev => !prev)}
|
||
title="Sprache waehlen"
|
||
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
|
||
onClick={_toggleVoice}
|
||
title={voiceActive ? 'Aufnahme stoppen' : '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,
|
||
}}>
|
||
{_STT_LANGUAGES.map(lang => (
|
||
<div
|
||
key={lang.code}
|
||
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
|
||
style={{
|
||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
|
||
}}
|
||
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
|
||
>
|
||
{lang.label} ({lang.code})
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isProcessing ? (
|
||
<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
|
||
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,
|
||
minWidth: isMobile ? 84 : undefined,
|
||
}}
|
||
>
|
||
Send
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|