420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
"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<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
|
||
ref
|
||
) {
|
||
const [text, setText] = useState("");
|
||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||
const [isFocused, setIsFocused] = useState(false);
|
||
const { ripples, trigger: triggerRipple } = useRipple();
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const sendBtnRef = useRef<HTMLButtonElement>(null);
|
||
const abortRefs = useRef<Map<string, AbortController>>(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<HTMLInputElement>) => {
|
||
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 (
|
||
<div
|
||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||
transition-colors ${
|
||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||
}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
onPasteCapture={handlePasteCapture}
|
||
>
|
||
{/* 数据流光 */}
|
||
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
|
||
|
||
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
|
||
{isDragging && (
|
||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||
松开以添加参考图(可多张)
|
||
</div>
|
||
)}
|
||
|
||
{/* 沿用上次参考图提示 */}
|
||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||
<div className="mb-3 flex items-center gap-2.5">
|
||
<div className="flex gap-1.5">
|
||
{lastRefPreviewUrls.map((url, i) => (
|
||
<img
|
||
key={i}
|
||
src={url}
|
||
alt={`沿用参考图 ${i + 1}`}
|
||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||
shadow-[0_0_8px_rgba(46,139,122,0.1)]"
|
||
/>
|
||
))}
|
||
</div>
|
||
<span className="text-xs text-[var(--text-secondary)]">
|
||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||
</span>
|
||
<button
|
||
onClick={onClearLastRefImage}
|
||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||
cursor-pointer transition-colors"
|
||
title="清除参考图"
|
||
>
|
||
✕ 清除
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 参考图上传状态区(多图) */}
|
||
{uploads.length > 0 && (
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{uploads.map((item) => (
|
||
<div key={item.id} className="relative flex-shrink-0">
|
||
{item.previewUrl && (
|
||
<img
|
||
src={item.previewUrl}
|
||
alt="参考图"
|
||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||
item.status === "uploading" ? "opacity-60" : ""
|
||
}`}
|
||
/>
|
||
)}
|
||
{item.status === "uploading" && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(46,139,122,0.12)" strokeWidth="3" />
|
||
<circle
|
||
cx="18" cy="18" r="14" fill="none"
|
||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||
className="transition-all duration-200"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
{item.status === "done" && (
|
||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||
shadow-[0_0_8px_rgba(46,139,122,0.3)]">
|
||
✓
|
||
</div>
|
||
)}
|
||
{item.status === "error" && (
|
||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||
text-white text-[10px] flex items-center justify-center font-bold">
|
||
!
|
||
</div>
|
||
)}
|
||
{item.status !== "uploading" && (
|
||
<button
|
||
onClick={() => removeUpload(item.id)}
|
||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||
cursor-pointer transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||
{hasActiveUpload && (
|
||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||
shadow-[0_0_6px_rgba(46,139,122,0.3)]" />
|
||
上传中...
|
||
</span>
|
||
)}
|
||
{allDone && (
|
||
<span className="text-xs text-[var(--accent)]">
|
||
{doneUrls.length} 张参考图已就绪
|
||
</span>
|
||
)}
|
||
{uploads.some((u) => u.status === "error") && (
|
||
<span className="text-xs text-[var(--hot)]">
|
||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-end gap-2">
|
||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||
|
||
{/* 上传参考图按钮 */}
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={disabled || hasActiveUpload}
|
||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||
hover:bg-[var(--accent)]/5
|
||
flex items-center justify-center transition-all
|
||
disabled:opacity-50 cursor-pointer btn-hover-lift ${
|
||
doneUrls.length > 0
|
||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||
: "border-[var(--border)]"
|
||
}`}
|
||
title="上传参考图(可多选)"
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||
<polyline points="21 15 16 10 5 21" />
|
||
</svg>
|
||
{doneUrls.length > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||
{doneUrls.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* 文字输入框 — 水面感应 */}
|
||
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onFocus={() => setIsFocused(true)}
|
||
onBlur={() => setIsFocused(false)}
|
||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||
disabled={disabled}
|
||
rows={1}
|
||
className="w-full resize-none rounded-xl border border-[var(--border)]
|
||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||
placeholder:text-[var(--text-secondary)]
|
||
px-4 py-2.5 text-sm leading-relaxed
|
||
focus:outline-none focus:border-[var(--accent)]/50
|
||
disabled:opacity-50 transition-all
|
||
min-h-[42px] max-h-[120px]"
|
||
style={{
|
||
fieldSizing: "content",
|
||
boxShadow: isFocused
|
||
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
|
||
: "none",
|
||
} as React.CSSProperties}
|
||
/>
|
||
<div className="water-focus-line" />
|
||
</div>
|
||
|
||
{/* 发送按钮 — 投石入水 */}
|
||
<motion.button
|
||
ref={sendBtnRef}
|
||
onClick={handleSubmit}
|
||
disabled={!canSend}
|
||
whileTap={canSend ? sendBounce : undefined}
|
||
className={`flex-shrink-0 w-10 h-10 rounded-xl
|
||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||
hover:bg-[var(--accent-hover)]
|
||
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
|
||
flex items-center justify-center transition-all
|
||
disabled:opacity-50 cursor-pointer
|
||
${canSend ? "breath-pulse" : ""}`}
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||
</svg>
|
||
</motion.button>
|
||
</div>
|
||
{/* 涟漪层 */}
|
||
<RippleLayer ripples={ripples} />
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|