MessageInput.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. "use client";
  2. import { FormEvent, useEffect, useRef, useState, useCallback } from "react";
  3. import { Button } from "@/components/ui/button";
  4. import { Input } from "@/components/ui/input";
  5. import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
  6. import { X, Paperclip, Image as ImageIcon } from "lucide-react";
  7. import { toast } from "@/hooks/useToast";
  8. import { useI18n } from "@/lib/i18n/provider";
  9. interface MessageInputProps {
  10. value: string;
  11. onChange: (value: string) => void;
  12. onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
  13. sending: boolean;
  14. conversationId?: number; // 对话ID,用于文件上传
  15. }
  16. interface FilePreview {
  17. file: File;
  18. preview?: string; // 图片预览URL
  19. }
  20. export function MessageInput({
  21. value,
  22. onChange,
  23. onSubmit,
  24. sending,
  25. conversationId,
  26. }: MessageInputProps) {
  27. const { t } = useI18n();
  28. // 输入框引用,用于发送消息后自动聚焦
  29. const inputRef = useRef<HTMLInputElement>(null);
  30. // 文件输入框引用
  31. const fileInputRef = useRef<HTMLInputElement>(null);
  32. // 记录上一次的 sending 状态,用于判断是否刚刚完成发送
  33. const prevSendingRef = useRef<boolean>(false);
  34. // 文件预览状态
  35. const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
  36. // 上传中状态
  37. const [uploading, setUploading] = useState(false);
  38. // 当发送状态从 true 变为 false 时(发送完成),自动聚焦到输入框
  39. useEffect(() => {
  40. // 如果上一次是发送中(true),现在是发送完成(false),说明刚刚发送完成
  41. if (prevSendingRef.current && !sending && inputRef.current) {
  42. // 使用 setTimeout 确保 DOM 更新完成后再聚焦
  43. // 这样可以避免在某些情况下聚焦失败
  44. setTimeout(() => {
  45. inputRef.current?.focus();
  46. }, 0);
  47. }
  48. // 更新上一次的 sending 状态
  49. prevSendingRef.current = sending;
  50. }, [sending]);
  51. // 处理文件选择
  52. const handleFileSelect = useCallback(
  53. async (file: File) => {
  54. // 验证文件大小(10MB)
  55. const MAX_FILE_SIZE = 10 * 1024 * 1024;
  56. if (file.size > MAX_FILE_SIZE) {
  57. toast.error(t("agent.input.fileTooLarge"));
  58. return;
  59. }
  60. // 验证文件类型
  61. const ext = file.name.toLowerCase().split(".").pop();
  62. const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
  63. if (!ext || !allowedExts.includes(ext)) {
  64. toast.error(t("agent.input.fileTypeNotSupported"));
  65. return;
  66. }
  67. // 如果是图片,生成预览
  68. let preview: string | undefined;
  69. if (file.type.startsWith("image/")) {
  70. preview = URL.createObjectURL(file);
  71. }
  72. setFilePreview({ file, preview });
  73. },
  74. []
  75. );
  76. // 处理文件输入框变化
  77. const handleFileInputChange = useCallback(
  78. (event: React.ChangeEvent<HTMLInputElement>) => {
  79. const file = event.target.files?.[0];
  80. if (file) {
  81. handleFileSelect(file);
  82. }
  83. // 清空文件输入框,允许重复选择同一文件
  84. if (fileInputRef.current) {
  85. fileInputRef.current.value = "";
  86. }
  87. },
  88. [handleFileSelect]
  89. );
  90. // 处理拖拽上传
  91. const handleDragOver = useCallback((event: React.DragEvent) => {
  92. event.preventDefault();
  93. event.stopPropagation();
  94. }, []);
  95. const handleDrop = useCallback(
  96. (event: React.DragEvent) => {
  97. event.preventDefault();
  98. event.stopPropagation();
  99. const file = event.dataTransfer.files?.[0];
  100. if (file) {
  101. handleFileSelect(file);
  102. }
  103. },
  104. [handleFileSelect]
  105. );
  106. // 处理粘贴图片
  107. useEffect(() => {
  108. const handlePaste = (event: ClipboardEvent) => {
  109. const items = event.clipboardData?.items;
  110. if (!items) return;
  111. for (let i = 0; i < items.length; i++) {
  112. const item = items[i];
  113. if (item.type.startsWith("image/")) {
  114. const file = item.getAsFile();
  115. if (file) {
  116. event.preventDefault();
  117. handleFileSelect(file);
  118. break;
  119. }
  120. }
  121. }
  122. };
  123. const input = inputRef.current;
  124. if (input) {
  125. input.addEventListener("paste", handlePaste);
  126. return () => {
  127. input.removeEventListener("paste", handlePaste);
  128. };
  129. }
  130. }, [handleFileSelect]);
  131. // 移除文件预览
  132. const handleRemoveFile = useCallback(() => {
  133. if (filePreview?.preview) {
  134. URL.revokeObjectURL(filePreview.preview);
  135. }
  136. setFilePreview(null);
  137. }, [filePreview]);
  138. // 格式化文件大小
  139. const formatFileSize = (bytes: number): string => {
  140. if (bytes < 1024) return bytes + " B";
  141. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  142. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  143. };
  144. // 处理提交
  145. const handleSubmit = async (event: FormEvent) => {
  146. event.preventDefault();
  147. if (sending || uploading) {
  148. return;
  149. }
  150. // 验证:必须有内容或文件
  151. if (!value.trim() && !filePreview) {
  152. return;
  153. }
  154. try {
  155. let fileInfo: UploadFileResult | undefined;
  156. // 如果有文件,先上传文件
  157. if (filePreview) {
  158. setUploading(true);
  159. try {
  160. fileInfo = await uploadFile(filePreview.file, conversationId);
  161. } catch (error) {
  162. toast.error((error as Error).message || t("agent.input.uploadFailed"));
  163. setUploading(false);
  164. return;
  165. }
  166. setUploading(false);
  167. }
  168. // 发送消息(包含文件信息)
  169. await onSubmit(fileInfo);
  170. // 清空输入和文件预览
  171. onChange("");
  172. handleRemoveFile();
  173. } catch (error) {
  174. console.error("发送消息失败:", error);
  175. }
  176. };
  177. // 清理预览URL
  178. useEffect(() => {
  179. return () => {
  180. if (filePreview?.preview) {
  181. URL.revokeObjectURL(filePreview.preview);
  182. }
  183. };
  184. }, [filePreview]);
  185. return (
  186. <div
  187. className="bg-gradient-to-t from-background to-muted/30 flex-shrink-0 border-t border-border/50"
  188. onDragOver={handleDragOver}
  189. onDrop={handleDrop}
  190. >
  191. {/* 文件预览区域 */}
  192. {filePreview && (
  193. <div className="px-4 pt-3 pb-2 flex items-start gap-2">
  194. <div className="flex-1 min-w-0">
  195. {filePreview.preview ? (
  196. // 图片预览
  197. <div className="relative inline-block">
  198. <img
  199. src={filePreview.preview}
  200. alt="预览"
  201. className="max-w-[200px] max-h-[200px] rounded-lg object-cover border border-border shadow-sm"
  202. />
  203. <div className="mt-1 text-xs text-muted-foreground">
  204. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  205. </div>
  206. </div>
  207. ) : (
  208. // 文档预览
  209. <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border/50">
  210. <Paperclip className="w-4 h-4 text-muted-foreground" />
  211. <div className="flex-1 min-w-0">
  212. <div className="text-sm font-medium truncate">{filePreview.file.name}</div>
  213. <div className="text-xs text-muted-foreground">
  214. {formatFileSize(filePreview.file.size)}
  215. </div>
  216. </div>
  217. </div>
  218. )}
  219. </div>
  220. <Button
  221. type="button"
  222. variant="ghost"
  223. size="sm"
  224. onClick={handleRemoveFile}
  225. className="flex-shrink-0 hover:bg-destructive/10 hover:text-destructive"
  226. disabled={sending || uploading}
  227. >
  228. <X className="w-4 h-4" />
  229. </Button>
  230. </div>
  231. )}
  232. {/* 输入区域 */}
  233. <form
  234. onSubmit={handleSubmit}
  235. className="px-4 py-3 flex items-center gap-2 pb-[max(0.75rem,env(safe-area-inset-bottom,0px))]"
  236. >
  237. <input
  238. ref={fileInputRef}
  239. type="file"
  240. accept="image/*,.pdf,.doc,.docx,.txt"
  241. onChange={handleFileInputChange}
  242. className="hidden"
  243. />
  244. <Button
  245. type="button"
  246. variant="ghost"
  247. size="sm"
  248. onClick={() => fileInputRef.current?.click()}
  249. disabled={sending || uploading}
  250. title={t("agent.input.upload")}
  251. className="hover:bg-primary/10 hover:text-primary transition-colors"
  252. >
  253. <Paperclip className="w-4 h-4" />
  254. </Button>
  255. <Input
  256. ref={inputRef}
  257. type="text"
  258. placeholder={
  259. filePreview
  260. ? t("agent.input.placeholder.withAttachment")
  261. : t("agent.input.placeholder")
  262. }
  263. value={value}
  264. onChange={(event) => onChange(event.target.value)}
  265. className="flex-1 border-border/50 focus:border-primary/50 focus:ring-primary/20"
  266. disabled={sending || uploading}
  267. />
  268. <Button
  269. type="submit"
  270. disabled={sending || uploading || (!value.trim() && !filePreview)}
  271. variant="default"
  272. size="default"
  273. className="bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
  274. >
  275. {uploading
  276. ? t("agent.input.uploading")
  277. : sending
  278. ? t("agent.input.sending")
  279. : t("agent.input.send")}
  280. </Button>
  281. </form>
  282. </div>
  283. );
  284. }