diff --git a/.env.exaple b/.env.exaple new file mode 100644 index 0000000..5876800 --- /dev/null +++ b/.env.exaple @@ -0,0 +1,6 @@ +# APP ID +NEXT_PUBLIC_APP_ID= +# APP API key +NEXT_PUBLIC_APP_KEY= +# API url prefix +NEXT_PUBLIC_API_URL= \ No newline at end of file diff --git a/app/components/chat/chat-input.tsx b/app/components/chat/chat-input.tsx new file mode 100644 index 0000000..7f7d276 --- /dev/null +++ b/app/components/chat/chat-input.tsx @@ -0,0 +1,256 @@ +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([]); + const textareaRef = useRef(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) => { + // 处理输入法状态 + 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 ( +
+ {files.map((file) => ( +
+ 预览 + +
+ ))} +
+ ); + }; + + /** + * 渲染上传按钮 + */ + const renderUploadButton = () => { + if (!visionConfig?.enabled) return null; + + const isDisabled = disabled || (visionConfig.number_limits ? files.length >= visionConfig.number_limits : false); + + return ( + + +