"use client"; import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react"; import { motion } from "framer-motion"; import { ModelSelector } from "./model-selector"; import { DataStream } from "@/components/ui/data-stream"; import { useRipple, RippleLayer } from "@/components/ui/ripple-effect"; import { uploadRefImage, type UploadProgress } from "@/lib/api"; import { sendBounce } from "@/components/ui/motion-presets"; type UploadStatus = "idle" | "uploading" | "done" | "error"; interface UploadItem { id: string; status: UploadStatus; previewUrl: string | null; serverUrl: string | null; progress: number; errorMsg: string | null; } interface ChatInputProps { onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void; disabled: boolean; /** 上次使用的参考图服务端路径列表,用于自动沿用 */ lastRefServerUrls?: string[]; /** 上次参考图的完整可预览 URL 列表 */ lastRefPreviewUrls?: string[]; /** 用户主动清除沿用参考图时的回调 */ onClearLastRefImage?: () => void; /** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */ onFileDrop?: () => void; /** 是否正在生成图片(用于数据流光加速) */ isGeneratingImage?: boolean; } export interface ChatInputHandle { uploadFile: (file: File) => void; } let _uploadCounter = 0; function imageFilesFromClipboard(data: DataTransfer | null): File[] { if (!data?.items?.length) return []; const out: File[] = []; for (let i = 0; i < data.items.length; i++) { const it = data.items[i]; if (it.kind === "file" && it.type.startsWith("image/")) { const f = it.getAsFile(); if (f) out.push(f); } } return out; } export const ChatInput = forwardRef(function ChatInput( { onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage }, ref ) { const [text, setText] = useState(""); const [uploads, setUploads] = useState([]); const [isDragging, setIsDragging] = useState(false); const [selectedModel, setSelectedModel] = useState(""); const [isFocused, setIsFocused] = useState(false); const { ripples, trigger: triggerRipple } = useRipple(); const fileInputRef = useRef(null); const sendBtnRef = useRef(null); const abortRefs = useRef>(new Map()); const hasActiveUpload = uploads.some((u) => u.status === "uploading"); const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error"); const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!); const canSend = text.trim() && !disabled && !hasActiveUpload; const handleSubmit = () => { const trimmed = text.trim(); if (!trimmed || disabled) return; if (hasActiveUpload) return; // 投石入水涟漪 if (sendBtnRef.current) { const rect = sendBtnRef.current.getBoundingClientRect(); const parent = sendBtnRef.current.offsetParent as HTMLElement | null; const parentRect = parent?.getBoundingClientRect() ?? rect; triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2); } const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []); onSend(trimmed, refUrls, selectedModel || null); setText(""); clearAllUploads(); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const clearAllUploads = useCallback(() => { abortRefs.current.forEach((c) => c.abort()); abortRefs.current.clear(); setUploads((prev) => { prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); }); return []; }); if (fileInputRef.current) fileInputRef.current.value = ""; }, []); const removeUpload = useCallback((id: string) => { abortRefs.current.get(id)?.abort(); abortRefs.current.delete(id); setUploads((prev) => { const item = prev.find((u) => u.id === id); if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl); return prev.filter((u) => u.id !== id); }); }, []); const startUpload = useCallback(async (file: File) => { const id = `upload-${++_uploadCounter}`; const controller = new AbortController(); abortRefs.current.set(id, controller); const previewUrl = URL.createObjectURL(file); const newItem: UploadItem = { id, status: "uploading", previewUrl, serverUrl: null, progress: 0, errorMsg: null, }; setUploads((prev) => [...prev, newItem]); try { const result = await uploadRefImage( file, (p: UploadProgress) => { setUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u)) ); }, controller.signal ); setUploads((prev) => prev.map((u) => u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u ) ); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; const msg = err instanceof Error ? err.message : "上传失败"; setUploads((prev) => prev.map((u) => u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u ) ); } }, []); useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]); const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files) { Array.from(files).forEach((f) => startUpload(f)); } if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = () => setIsDragging(false); const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); onFileDrop?.(); const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); files.forEach((f) => startUpload(f)); }; const handlePasteCapture = useCallback( (e: ClipboardEvent) => { if (disabled) return; const files = imageFilesFromClipboard(e.clipboardData); if (files.length === 0) return; e.preventDefault(); e.stopPropagation(); files.forEach((f) => startUpload(f)); }, [disabled, startUpload] ); return (
{/* 数据流光 */}
{isDragging && (
松开以添加参考图(可多张)
)} {/* 沿用上次参考图提示 */} {uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
{lastRefPreviewUrls.map((url, i) => ( {`沿用参考图 ))}
沿用上次参考图({lastRefPreviewUrls.length} 张)
)} {/* 参考图上传状态区(多图) */} {uploads.length > 0 && (
{uploads.map((item) => (
{item.previewUrl && ( 参考图 )} {item.status === "uploading" && (
)} {item.status === "done" && (
)} {item.status === "error" && (
!
)} {item.status !== "uploading" && ( )}
))}
{hasActiveUpload && ( 上传中... )} {allDone && ( {doneUrls.length} 张参考图已就绪 )} {uploads.some((u) => u.status === "error") && ( {uploads.filter((u) => u.status === "error").length} 张上传失败 )}
)}
{/* 上传参考图按钮 */} {/* 文字输入框 — 水面感应 */}