1. 图片上传成功后立即显示在画布中 2. 同时触发自动命名和设置缩略图 3. 移除发送时重复添加图片的逻辑 Co-authored-by: Cursor <cursoragent@cursor.com>
735 lines
26 KiB
TypeScript
735 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useChat } from "@ai-sdk/react";
|
||
import { DefaultChatTransport } from "ai";
|
||
import { useEffect, useRef, useState, useCallback } from "react";
|
||
import useSWR from "swr";
|
||
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, Share2, LayoutGrid, Maximize2, RefreshCw, Zap, Globe, Box, ArrowUp } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { toast } from "sonner";
|
||
import { useCanvasStore } from "@/store/canvas";
|
||
import { cn } from "@/lib/utils";
|
||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||
|
||
interface CanvasChatPanelProps {
|
||
chatId: string;
|
||
initialMessages?: ChatMessage[];
|
||
onClose?: () => void;
|
||
onOpenFileList?: () => void;
|
||
}
|
||
|
||
// 建议卡片数据 - 已移除
|
||
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
|
||
|
||
// 从消息中提取图片 URL
|
||
function extractImageUrls(text: string): string[] {
|
||
const urls: string[] = [];
|
||
|
||
// 匹配 markdown 图片格式: 
|
||
const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\([^)]+\)/g;
|
||
let match;
|
||
while ((match = markdownRegex.exec(text)) !== null) {
|
||
if (match[1]) {
|
||
urls.push(match[1]);
|
||
}
|
||
}
|
||
|
||
// 匹配普通图片 URL
|
||
const urlRegex = /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp)/gi;
|
||
const urlMatches = text.match(urlRegex);
|
||
if (urlMatches) {
|
||
urls.push(...urlMatches.filter(url => !urls.includes(url)));
|
||
}
|
||
|
||
return urls;
|
||
}
|
||
|
||
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
|
||
const [input, setInput] = useState("");
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
// 🆕 图片参数状态
|
||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
});
|
||
|
||
// 🆕 保存图片参数到 localStorage
|
||
const handleImageParamsChange = (params: ImageGenerationParams) => {
|
||
setImageParams(params);
|
||
try {
|
||
localStorage.setItem("canvas-image-params", JSON.stringify(params));
|
||
} catch (e) {
|
||
console.error("Failed to save image params:", e);
|
||
}
|
||
};
|
||
|
||
// 加载图片参数
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem("canvas-image-params");
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
setImageParams(prev => ({ ...prev, ...parsed }));
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load image params:", e);
|
||
}
|
||
}, []);
|
||
|
||
// Canvas store
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const nodes = useCanvasStore((state) => state.nodes);
|
||
|
||
// 获取模型列表
|
||
const { data: modelsData } = useSWR<{
|
||
models: Array<{
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
providerType: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
}>;
|
||
}>("/api/models", fetcher);
|
||
|
||
// 获取网站设置(包含广告横幅配置)
|
||
const { data: siteSettings } = useSWR<{
|
||
bannerEnabled?: boolean;
|
||
bannerText?: string;
|
||
bannerLink?: string | null;
|
||
}>("/api/site-settings", fetcher);
|
||
|
||
// 过滤支持图片生成的模型
|
||
const imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || [];
|
||
|
||
// 默认选择第一个图片模型
|
||
useEffect(() => {
|
||
if (imageModels.length > 0 && !selectedModelId) {
|
||
setSelectedModelId(imageModels[0].id);
|
||
}
|
||
}, [imageModels, selectedModelId]);
|
||
|
||
const currentModelIdRef = useRef(selectedModelId);
|
||
const imageParamsRef = useRef(imageParams);
|
||
|
||
useEffect(() => {
|
||
currentModelIdRef.current = selectedModelId;
|
||
}, [selectedModelId]);
|
||
|
||
useEffect(() => {
|
||
imageParamsRef.current = imageParams;
|
||
}, [imageParams]);
|
||
|
||
const {
|
||
messages,
|
||
setMessages,
|
||
sendMessage,
|
||
status,
|
||
stop,
|
||
} = useChat<ChatMessage>({
|
||
id: chatId,
|
||
messages: initialMessages,
|
||
experimental_throttle: 100,
|
||
generateId: generateUUID,
|
||
transport: new DefaultChatTransport({
|
||
api: "/api/chat",
|
||
fetch: fetchWithErrorHandlers,
|
||
prepareSendMessagesRequest(request) {
|
||
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
|
||
const rawId = request.id.startsWith('canvas-')
|
||
? request.id.replace('canvas-', '')
|
||
: request.id;
|
||
|
||
console.log("[CanvasChat] Preparing request:", {
|
||
id: rawId,
|
||
modelId: currentModelIdRef.current,
|
||
imageParams: imageParamsRef.current,
|
||
lastMessage: request.messages.at(-1),
|
||
});
|
||
return {
|
||
body: {
|
||
id: rawId,
|
||
message: request.messages.at(-1),
|
||
selectedChatModel: currentModelIdRef.current,
|
||
selectedVisibilityType: "private",
|
||
videoParams: undefined, // 必须传递,即使是 undefined
|
||
imageParams: imageParamsRef.current || undefined, // 传递图片参数
|
||
...request.body,
|
||
},
|
||
};
|
||
},
|
||
}),
|
||
onData: (dataPart: any) => {
|
||
// 处理后端返回的自定义消息
|
||
if (dataPart.type === "data-appendMessage") {
|
||
try {
|
||
const messageData = typeof dataPart.data === 'string'
|
||
? JSON.parse(dataPart.data)
|
||
: dataPart.data;
|
||
|
||
setMessages((prevMessages) => {
|
||
if (prevMessages.some(m => m.id === messageData.id)) {
|
||
return prevMessages;
|
||
}
|
||
|
||
const newMessage: any = {
|
||
id: messageData.id,
|
||
role: messageData.role,
|
||
content: messageData.parts?.[0]?.text || "",
|
||
createdAt: new Date(),
|
||
parts: messageData.parts || [],
|
||
attachments: [],
|
||
};
|
||
|
||
return [...prevMessages, newMessage];
|
||
});
|
||
} catch (e) {
|
||
console.error("[CanvasChat] Failed to parse data-appendMessage:", e);
|
||
}
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error(error.message || "生成失败");
|
||
},
|
||
});
|
||
|
||
// 监听消息变化,提取图片并添加到画布
|
||
const processedMessageIds = useRef<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
messages.forEach(message => {
|
||
if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) {
|
||
const content = message.parts?.map((p: any) => p.text || '').join('') || '';
|
||
const imageUrls = extractImageUrls(content);
|
||
|
||
if (imageUrls.length > 0) {
|
||
processedMessageIds.current.add(message.id);
|
||
|
||
// 为每张图片创建画布节点
|
||
imageUrls.forEach((url, index) => {
|
||
// 计算位置(避免重叠)
|
||
const existingNodes = nodes.length;
|
||
const x = 100 + ((existingNodes + index) % 4) * 320;
|
||
const y = 100 + Math.floor((existingNodes + index) / 4) * 320;
|
||
|
||
addNode({
|
||
type: 'image',
|
||
position: { x, y },
|
||
size: { width: 300, height: 300 },
|
||
data: {
|
||
src: url,
|
||
status: 'completed',
|
||
prompt: '对话生成',
|
||
},
|
||
connections: [],
|
||
});
|
||
});
|
||
|
||
if (imageUrls.length > 0) {
|
||
toast.success(`已将 ${imageUrls.length} 张图片添加到画布`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}, [messages, addNode, nodes.length]);
|
||
|
||
// 滚动到底部
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages]);
|
||
|
||
// 标记是否已经为当前画布设置过标题(避免重复调用)
|
||
const hasSetCanvasTitle = useRef(false);
|
||
|
||
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
|
||
const handleSend = useCallback(async () => {
|
||
if (!input.trim() && attachments.length === 0) return;
|
||
if (!selectedModelId) {
|
||
toast.error("请选择一个模型");
|
||
return;
|
||
}
|
||
|
||
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
|
||
const parts: any[] = [];
|
||
|
||
// 添加文件部分(作为 file 类型的 part)
|
||
attachments.forEach((a) => {
|
||
parts.push({
|
||
type: "file" as const,
|
||
mediaType: a.contentType,
|
||
name: a.name,
|
||
url: a.url,
|
||
});
|
||
});
|
||
|
||
// 添加文本部分
|
||
const textContent = input.trim() || (attachments.length > 0 ? "请处理这张图片" : "");
|
||
if (textContent) {
|
||
parts.push({ type: "text" as const, text: textContent });
|
||
}
|
||
|
||
// 使用与 multimodal-input 相同的格式发送消息
|
||
sendMessage({
|
||
role: "user",
|
||
parts: parts,
|
||
});
|
||
|
||
setInput("");
|
||
setAttachments([]);
|
||
}, [input, attachments, selectedModelId, sendMessage]);
|
||
|
||
// 处理文件选择 - 上传后立即添加到画布
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files) return;
|
||
|
||
let firstImageUrl: string | null = null;
|
||
|
||
for (const file of Array.from(files)) {
|
||
if (!file.type.startsWith('image/')) {
|
||
toast.error('请选择图片文件');
|
||
continue;
|
||
}
|
||
|
||
if (file.size > 8 * 1024 * 1024) {
|
||
toast.error('图片大小不能超过 8MB');
|
||
continue;
|
||
}
|
||
|
||
// 上传文件
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetch('/api/files/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('上传失败');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = data.url;
|
||
|
||
// 记录第一张图片的 URL(用于自动命名和缩略图)
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = imageUrl;
|
||
}
|
||
|
||
// 添加到附件列表
|
||
setAttachments(prev => [...prev, {
|
||
url: imageUrl,
|
||
name: file.name,
|
||
contentType: file.type,
|
||
}]);
|
||
|
||
// 立即将图片添加到画布
|
||
const existingNodes = nodes.length;
|
||
const x = 100 + (existingNodes % 4) * 320;
|
||
const y = 100 + Math.floor(existingNodes / 4) * 320;
|
||
|
||
addNode({
|
||
type: 'image',
|
||
position: { x, y },
|
||
size: { width: 300, height: 300 },
|
||
data: {
|
||
src: imageUrl,
|
||
status: 'completed',
|
||
prompt: '用户上传',
|
||
},
|
||
connections: [],
|
||
});
|
||
|
||
toast.success('图片已添加到画布');
|
||
|
||
} catch (error) {
|
||
toast.error('图片上传失败');
|
||
}
|
||
}
|
||
|
||
// 如果是第一次上传图片,自动识别并设置画布标题和缩略图
|
||
if (firstImageUrl && !hasSetCanvasTitle.current) {
|
||
hasSetCanvasTitle.current = true;
|
||
|
||
// 异步调用 AI 识别图片主体并更新画布
|
||
(async () => {
|
||
try {
|
||
// 调用 auto-title API 识别图片主体
|
||
const response = await fetch('/api/canvas/auto-title', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl: firstImageUrl }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const title = data.title || '未命名画布';
|
||
|
||
// 提取纯 UUID(去掉 canvas- 前缀)
|
||
const rawId = chatId.startsWith('canvas-')
|
||
? chatId.replace('canvas-', '')
|
||
: chatId;
|
||
|
||
// 更新画布标题和缩略图
|
||
await fetch(`/api/canvas/${rawId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: title,
|
||
thumbnail: firstImageUrl,
|
||
}),
|
||
});
|
||
|
||
console.log('[CanvasChat] 画布已自动命名为:', title);
|
||
toast.success(`画布已命名为: ${title}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('[CanvasChat] 自动命名失败:', error);
|
||
}
|
||
})();
|
||
}
|
||
|
||
e.target.value = '';
|
||
};
|
||
|
||
// 移除附件
|
||
const removeAttachment = (index: number) => {
|
||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const isLoading = status === 'streaming' || status === 'submitted';
|
||
|
||
return (
|
||
<div className="flex flex-col h-full bg-background/50 backdrop-blur-sm relative">
|
||
{/* Header - Minimal with Icons */}
|
||
<div className="absolute top-0 right-0 z-20 p-4 flex flex-col items-end gap-2">
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
||
<History className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
||
<Share2 className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-muted"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
console.log('[CanvasChatPanel] File list button clicked');
|
||
if (onOpenFileList) {
|
||
onOpenFileList();
|
||
}
|
||
}}
|
||
title="已生成文件列表"
|
||
>
|
||
<LayoutGrid className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
||
<Maximize2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{onClose && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={onClose}
|
||
className="h-7 px-3 text-xs rounded-full bg-muted/80 backdrop-blur-sm hover:bg-muted shadow-sm"
|
||
>
|
||
收起
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 消息列表 */}
|
||
<ScrollArea className="flex-1 p-4 pt-16">
|
||
<div className="space-y-6">
|
||
{messages.length === 0 && (
|
||
<div className="flex flex-col h-full pt-4">
|
||
{/* Greeting */}
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl font-bold mb-2">Hi, 我是你的AI设计师</h1>
|
||
<p className="text-muted-foreground">让我们开始今天的创作吧!</p>
|
||
</div>
|
||
|
||
{/* Suggestions - Removed */}
|
||
|
||
{/* Refresh - Removed */}
|
||
|
||
{/* Banner - 从系统设置中获取 */}
|
||
{(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
|
||
<div
|
||
className={cn(
|
||
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
|
||
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||
)}
|
||
onClick={() => {
|
||
if (siteSettings?.bannerLink) {
|
||
window.open(siteSettings.bannerLink, '_blank');
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
|
||
<Box className="h-4 w-4" />
|
||
</div>
|
||
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
|
||
</div>
|
||
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={cn(
|
||
"flex gap-3",
|
||
message.role === 'user' ? "flex-row-reverse" : "flex-row"
|
||
)}
|
||
>
|
||
{/* 头像 */}
|
||
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
|
||
{message.role === 'user' ? (
|
||
<>
|
||
<AvatarImage src="/placeholder-user.jpg" />
|
||
<AvatarFallback className="bg-primary text-primary-foreground">
|
||
<User className="h-4 w-4" />
|
||
</AvatarFallback>
|
||
</>
|
||
) : (
|
||
<>
|
||
<AvatarImage src="/placeholder-bot.jpg" />
|
||
<AvatarFallback className="bg-muted text-foreground">
|
||
<Bot className="h-4 w-4" />
|
||
</AvatarFallback>
|
||
</>
|
||
)}
|
||
</Avatar>
|
||
|
||
{/* 消息气泡 */}
|
||
<div className={cn(
|
||
"flex flex-col gap-2 max-w-[85%]",
|
||
message.role === 'user' ? "items-end" : "items-start"
|
||
)}>
|
||
<div
|
||
className={cn(
|
||
"text-sm px-4 py-2.5 shadow-sm",
|
||
message.role === 'user'
|
||
? "bg-primary text-primary-foreground rounded-2xl rounded-tr-sm"
|
||
: "bg-card border text-card-foreground rounded-2xl rounded-tl-sm"
|
||
)}
|
||
>
|
||
{/* 附件预览 */}
|
||
{message.attachments && message.attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-2">
|
||
{message.attachments.map((att: any, i: number) => (
|
||
<img
|
||
key={i}
|
||
src={att.url}
|
||
alt=""
|
||
className="h-16 w-16 object-cover rounded-lg border bg-background"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 消息内容 */}
|
||
<div className="whitespace-pre-wrap break-words leading-relaxed">
|
||
{message.parts?.map((part: any, i: number) => {
|
||
if (part.type === 'text') {
|
||
// 提取并显示图片
|
||
const imageUrls = extractImageUrls(part.text || '');
|
||
const textWithoutImages = (part.text || '')
|
||
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '')
|
||
.trim();
|
||
|
||
return (
|
||
<div key={i}>
|
||
{textWithoutImages && <span>{textWithoutImages}</span>}
|
||
{imageUrls.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mt-3">
|
||
{imageUrls.map((url, idx) => (
|
||
<div key={idx} className="relative group rounded-xl overflow-hidden border bg-background shadow-sm transition-all hover:shadow-md">
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
className="max-w-full h-auto cursor-pointer hover:scale-105 transition-transform duration-300"
|
||
style={{ maxHeight: '200px' }}
|
||
onClick={() => window.open(url, '_blank')}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{isLoading && (
|
||
<div className="flex gap-3">
|
||
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
|
||
<AvatarFallback className="bg-muted text-foreground">
|
||
<Bot className="h-4 w-4" />
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="bg-card border text-card-foreground rounded-2xl rounded-tl-sm px-4 py-2.5 shadow-sm flex items-center gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||
<span className="text-sm text-muted-foreground">正在思考中...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
{/* 底部输入区域 - Redesigned */}
|
||
<div className="p-4 bg-background/80 backdrop-blur-md border-t">
|
||
{/* 附件预览 */}
|
||
{attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{attachments.map((att, index) => (
|
||
<div key={index} className="relative group">
|
||
<img
|
||
src={att.url}
|
||
alt=""
|
||
className="h-14 w-14 object-cover rounded-lg border shadow-sm"
|
||
/>
|
||
<button
|
||
className="absolute -top-1.5 -right-1.5 bg-destructive text-destructive-foreground rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-all shadow-sm hover:scale-110"
|
||
onClick={() => removeAttachment(index)}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background">
|
||
<Textarea
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
}}
|
||
placeholder="请输入你的设计需求"
|
||
className="min-h-[80px] max-h-[150px] w-full text-sm resize-none bg-transparent border-none focus-visible:ring-0 p-4 pb-12 placeholder:text-muted-foreground/50"
|
||
disabled={isLoading}
|
||
/>
|
||
|
||
{/* Bottom Toolbar */}
|
||
<div className="absolute left-2 bottom-2 right-2 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isLoading}
|
||
>
|
||
<Paperclip className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* Agent Selector (Model) */}
|
||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||
<SelectTrigger className="h-8 text-xs bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100 hover:border-blue-200 transition-colors rounded-full px-3 gap-1 w-auto">
|
||
<Sparkles className="h-3 w-3" />
|
||
<span className="truncate max-w-[80px]">
|
||
{imageModels.find(m => m.id === selectedModelId)?.displayName || "Agent"}
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{imageModels.map(model => (
|
||
<SelectItem key={model.id} value={model.id} className="text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<Sparkles className="h-3 w-3 text-primary" />
|
||
{model.displayName}
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Image Params Bar */}
|
||
<ImageParamsBar
|
||
modelName={imageModels.find(m => m.id === selectedModelId)?.displayName}
|
||
initialParams={imageParams}
|
||
onParamsChange={handleImageParamsChange}
|
||
inline={true}
|
||
providerType={imageModels.find(m => m.id === selectedModelId)?.providerType}
|
||
/>
|
||
|
||
<Button
|
||
size="icon"
|
||
className={cn(
|
||
"h-8 w-8 rounded-full transition-all duration-200",
|
||
input.trim() || attachments.length > 0
|
||
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
|
||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||
)}
|
||
onClick={handleSend}
|
||
disabled={isLoading || (!input.trim() && attachments.length === 0)}
|
||
>
|
||
{isLoading ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<ArrowUp className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|