MessageInput.tsx 10 KB

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