256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||
import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd';
|
||
import { SendOutlined, StopOutlined, PaperClipOutlined, PictureOutlined } from '@ant-design/icons';
|
||
import type { VisionFile } from '../../types/dify_chat';
|
||
import '../../styles/components/chat-with-llm/chat-input.css';
|
||
|
||
const { TextArea } = Input;
|
||
|
||
interface ChatInputProps {
|
||
onSendMessage: (message: string, files?: VisionFile[]) => void;
|
||
disabled?: boolean;
|
||
placeholder?: string;
|
||
onStop?: () => void;
|
||
isResponding?: boolean;
|
||
visionConfig?: {
|
||
enabled: boolean;
|
||
number_limits?: number;
|
||
image_file_size_limit?: number;
|
||
transfer_methods?: string[];
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 聊天输入组件
|
||
*/
|
||
export default function ChatInput({
|
||
onSendMessage,
|
||
disabled = false,
|
||
placeholder = '输入消息...',
|
||
onStop,
|
||
isResponding = false,
|
||
visionConfig,
|
||
}: ChatInputProps) {
|
||
const [message, setMessage] = useState('');
|
||
const [files, setFiles] = useState<VisionFile[]>([]);
|
||
const textareaRef = useRef<any>(null);
|
||
const isComposing = useRef(false);
|
||
|
||
/**
|
||
* 提交消息
|
||
*/
|
||
const handleSubmit = () => {
|
||
if (!message.trim() || disabled) return;
|
||
|
||
onSendMessage(message, files.length > 0 ? files : undefined);
|
||
setMessage('');
|
||
setFiles([]);
|
||
|
||
// 聚焦回输入框
|
||
setTimeout(() => {
|
||
textareaRef.current?.focus();
|
||
}, 10);
|
||
};
|
||
|
||
/**
|
||
* 处理键盘事件
|
||
*/
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
// 处理输入法状态
|
||
if (e.nativeEvent.isComposing) {
|
||
isComposing.current = true;
|
||
return;
|
||
}
|
||
|
||
// Enter发送,Shift+Enter换行
|
||
if (e.key === 'Enter' && !e.shiftKey && !isComposing.current) {
|
||
e.preventDefault();
|
||
handleSubmit();
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理输入法结束
|
||
*/
|
||
const handleCompositionEnd = () => {
|
||
isComposing.current = false;
|
||
};
|
||
|
||
/**
|
||
* 停止响应
|
||
*/
|
||
const handleStop = () => {
|
||
onStop?.();
|
||
};
|
||
|
||
/**
|
||
* 处理文件上传
|
||
*/
|
||
const handleFileUpload = (file: File) => {
|
||
// 检查文件数量限制
|
||
if (visionConfig?.number_limits && files.length >= visionConfig.number_limits) {
|
||
antdMessage.error(`最多只能上传 ${visionConfig.number_limits} 个文件`);
|
||
return false;
|
||
}
|
||
|
||
// 检查文件大小限制
|
||
if (visionConfig?.image_file_size_limit) {
|
||
const limitMB = visionConfig.image_file_size_limit;
|
||
const fileSizeMB = file.size / (1024 * 1024);
|
||
if (fileSizeMB > limitMB) {
|
||
antdMessage.error(`文件大小不能超过 ${limitMB}MB`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 检查文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
antdMessage.error('只支持图片文件');
|
||
return false;
|
||
}
|
||
|
||
// 创建文件对象
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const newFile: VisionFile = {
|
||
id: `file-${Date.now()}-${Math.random()}`,
|
||
type: 'image',
|
||
transfer_method: 'local_file' as any,
|
||
url: e.target?.result as string,
|
||
upload_file_id: '',
|
||
};
|
||
|
||
setFiles(prev => [...prev, newFile]);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
|
||
return false; // 阻止默认上传行为
|
||
};
|
||
|
||
/**
|
||
* 移除文件
|
||
*/
|
||
const handleRemoveFile = (fileId: string) => {
|
||
setFiles(prev => prev.filter(file => file.id !== fileId));
|
||
};
|
||
|
||
/**
|
||
* 渲染文件预览
|
||
*/
|
||
const renderFilePreview = () => {
|
||
if (files.length === 0) return null;
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-2 mb-2 p-2 bg-gray-50 rounded">
|
||
{files.map((file) => (
|
||
<div key={file.id} className="relative group">
|
||
<img
|
||
src={file.url}
|
||
alt="预览"
|
||
className="w-16 h-16 object-cover rounded border"
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={() => handleRemoveFile(file.id!)}
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 渲染上传按钮
|
||
*/
|
||
const renderUploadButton = () => {
|
||
if (!visionConfig?.enabled) return null;
|
||
|
||
const isDisabled = disabled || (visionConfig.number_limits ? files.length >= visionConfig.number_limits : false);
|
||
|
||
return (
|
||
<Upload
|
||
beforeUpload={handleFileUpload}
|
||
showUploadList={false}
|
||
accept="image/*"
|
||
multiple={false}
|
||
>
|
||
<Tooltip title="上传图片">
|
||
<Button
|
||
type="text"
|
||
icon={<PictureOutlined />}
|
||
size="small"
|
||
disabled={isDisabled}
|
||
className="text-gray-500 hover:text-blue-500"
|
||
/>
|
||
</Tooltip>
|
||
</Upload>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="border-t border-gray-200 bg-white p-4">
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* 文件预览 */}
|
||
{renderFilePreview()}
|
||
|
||
{/* 输入区域 */}
|
||
<div className="relative">
|
||
<div className="flex items-end gap-2">
|
||
{/* 上传按钮 */}
|
||
<div className="flex items-end pb-1">
|
||
{renderUploadButton()}
|
||
</div>
|
||
|
||
{/* 文本输入 */}
|
||
<div className="flex-1">
|
||
<TextArea
|
||
ref={textareaRef}
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onCompositionEnd={handleCompositionEnd}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||
className="resize-none"
|
||
style={{ paddingRight: '50px' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 发送/停止按钮 */}
|
||
<div className="flex items-end pb-1">
|
||
{isResponding ? (
|
||
<Tooltip title="停止生成">
|
||
<Button
|
||
type="primary"
|
||
danger
|
||
icon={<StopOutlined />}
|
||
onClick={handleStop}
|
||
disabled={disabled}
|
||
size="large"
|
||
/>
|
||
</Tooltip>
|
||
) : (
|
||
<Tooltip title="发送消息 (Enter)">
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
onClick={handleSubmit}
|
||
disabled={disabled || !message.trim()}
|
||
size="large"
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 字符计数和提示 */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|