Spaces:
Sleeping
Sleeping
File size: 7,478 Bytes
05f86a6 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Paperclip, XCircle } from 'lucide-react';
import LoadingSpinner from './LoadingSpinner';
interface ChatInputProps {
onSendMessage: (message: string, imageFile?: File | null) => void;
isLoading: boolean;
isApiKeyMissing: boolean;
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const ChatInput: React.FC<ChatInputProps> = ({ onSendMessage, isLoading, isApiKeyMissing }) => {
const [inputValue, setInputValue] = useState('');
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedImage) {
const objectUrl = URL.createObjectURL(selectedImage);
setImagePreviewUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}
setImagePreviewUrl(null);
}, [selectedImage]);
const handleImageFile = (file: File | null) => {
if (file && ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setSelectedImage(file);
} else if (file) {
alert('不支持的文件类型。请选择 JPG, PNG, GIF, 或 WEBP 格式的图片。');
setSelectedImage(null);
} else {
setSelectedImage(null);
}
// Reset file input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const removeImage = () => {
setSelectedImage(null);
setImagePreviewUrl(null);
};
const triggerSendMessage = () => {
if ((inputValue.trim() || selectedImage) && !isLoading && !isApiKeyMissing) {
onSendMessage(inputValue.trim(), selectedImage);
setInputValue('');
removeImage(); // Clear image after sending
if (textareaRef.current) { // Reset textarea height after sending
textareaRef.current.style.height = 'auto';
}
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
triggerSendMessage();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault(); // Prevent new line
triggerSendMessage();
}
};
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
if (ACCEPTED_IMAGE_TYPES.includes(items[i].type)) {
const file = items[i].getAsFile();
if (file) {
handleImageFile(file);
e.preventDefault(); // Prevent pasting file path as text
break;
}
}
}
}
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
setIsDraggingOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleImageFile(e.dataTransfer.files[0]);
e.dataTransfer.clearData();
}
}, []);
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
setIsDraggingOver(false);
};
const handleFileButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleImageFile(e.target.files[0]);
}
};
const isDisabled = isLoading || isApiKeyMissing;
return (
<form onSubmit={handleSubmit} className="p-4 bg-gray-800 border-t border-gray-700">
{imagePreviewUrl && selectedImage && (
<div className="mb-2 p-2 bg-gray-700 rounded-md relative max-w-xs">
<img src={imagePreviewUrl} alt={selectedImage.name || "图片预览"} className="max-h-24 max-w-full rounded" />
<button
type="button"
onClick={removeImage}
className="absolute top-1 right-1 bg-black bg-opacity-50 text-white rounded-full p-0.5 hover:bg-opacity-75"
aria-label="移除图片"
>
<XCircle size={20} />
</button>
<div className="text-xs text-gray-300 mt-1 truncate">{selectedImage.name} ({(selectedImage.size / 1024).toFixed(1)} KB)</div>
</div>
)}
<div className="flex items-end space-x-2">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} // Added keydown handler
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
placeholder={isApiKeyMissing ? "API密钥未配置,聊天功能已禁用。" : (isDraggingOver ? "将图片拖放到此处" : "输入您的消息 (Ctrl+Enter 发送) 或粘贴/拖放图片...")}
className={`flex-grow p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 outline-none placeholder-gray-400 disabled:opacity-50 resize-none min-h-[48px] max-h-[150px] ${isDraggingOver ? 'ring-2 ring-sky-500 border-sky-500' : ''}`}
rows={1} // Start with 1 row, auto-expands
disabled={isDisabled}
aria-label="聊天输入框"
onInput={(e) => { // Auto-resize textarea
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelected}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
className="hidden"
aria-label="选择图片文件"
/>
<button
type="button"
onClick={handleFileButtonClick}
className="p-3 bg-gray-600 hover:bg-gray-500 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
disabled={isDisabled}
aria-label="添加图片附件"
title="添加图片"
>
<Paperclip size={24} />
</button>
<button
type="submit"
className="p-3 bg-sky-600 hover:bg-sky-700 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
disabled={isDisabled || (!inputValue.trim() && !selectedImage)}
aria-label={isLoading ? "发送中" : "发送消息"}
>
{isLoading ? <LoadingSpinner size="w-6 h-6" color="text-white" /> : <Send size={24} />}
</button>
</div>
</form>
);
};
export default ChatInput;
|