feat: integrate chat panel into canvas sidebar
- Add CanvasChatPanel component for AI conversation in canvas - Auto-sync generated images from chat to canvas nodes - Add tabbed interface to switch between AI Chat and Quick Tools - Widen sidebar to 384px to accommodate chat interface - Extract image URLs from messages and create canvas nodes automatically
This commit is contained in:
parent
aa4fbdd158
commit
f289eca90c
@ -60,7 +60,10 @@ import { toast } from "sonner";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -560,167 +563,188 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
|
||||
{/* 主体区域 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧配置面板 - 参考 lobe-chat-pro */}
|
||||
{/* 左侧面板 - 标签页切换 AI对话/快捷工具 */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-r bg-card transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isPanelOpen ? "w-80" : "w-0"
|
||||
"border-r bg-card transition-all duration-300 ease-in-out overflow-hidden flex flex-col",
|
||||
isPanelOpen ? "w-96" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isPanelOpen && (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* AI 图片生成 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">AI 图片生成</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">提示词</Label>
|
||||
<Textarea
|
||||
placeholder="描述你想要生成的图片..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">负面提示词</Label>
|
||||
<Textarea
|
||||
placeholder="描述你不想出现的内容..."
|
||||
value={negativePrompt}
|
||||
onChange={(e) => setNegativePrompt(e.target.value)}
|
||||
className="min-h-[60px] text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">尺寸比例</Label>
|
||||
<Select value={aspectRatio} onValueChange={setAspectRatio}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIZE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label} ({preset.width}×{preset.height})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">生成数量</Label>
|
||||
<span className="text-xs text-muted-foreground">{imageCount}</span>
|
||||
<Tabs defaultValue="chat" className="flex flex-col h-full">
|
||||
<TabsList className="grid w-full grid-cols-2 shrink-0 m-2 mb-0" style={{ width: 'calc(100% - 16px)' }}>
|
||||
<TabsTrigger value="chat" className="text-xs">
|
||||
<MessageSquare className="h-3.5 w-3.5 mr-1.5" />
|
||||
AI 对话
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tools" className="text-xs">
|
||||
<Wand2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
快捷工具
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* AI 对话标签页 */}
|
||||
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 mt-2">
|
||||
<CanvasChatPanel chatId={`canvas-${id}`} />
|
||||
</TabsContent>
|
||||
|
||||
{/* 快捷工具标签页 */}
|
||||
<TabsContent value="tools" className="flex-1 overflow-hidden m-0 mt-2">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* AI 图片生成 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">AI 图片生成</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">提示词</Label>
|
||||
<Textarea
|
||||
placeholder="描述你想要生成的图片..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">负面提示词</Label>
|
||||
<Textarea
|
||||
placeholder="描述你不想出现的内容..."
|
||||
value={negativePrompt}
|
||||
onChange={(e) => setNegativePrompt(e.target.value)}
|
||||
className="min-h-[60px] text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">尺寸比例</Label>
|
||||
<Select value={aspectRatio} onValueChange={setAspectRatio}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIZE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label} ({preset.width}×{preset.height})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">生成数量</Label>
|
||||
<span className="text-xs text-muted-foreground">{imageCount}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[imageCount]}
|
||||
onValueChange={([v]) => setImageCount(v)}
|
||||
min={1}
|
||||
max={4}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
开始生成
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Slider
|
||||
value={[imageCount]}
|
||||
onValueChange={([v]) => setImageCount(v)}
|
||||
min={1}
|
||||
max={4}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
<Separator />
|
||||
|
||||
{/* 图片编辑工具 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">图片编辑工具</h3>
|
||||
</div>
|
||||
|
||||
{selectedImageNode ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
抠图
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
变清晰
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Expand className="h-4 w-4 mr-2" />
|
||||
扩展
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
调整
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Move3D className="h-4 w-4 mr-2" />
|
||||
角度
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
场景
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
开始生成
|
||||
</>
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
选中图片节点以使用编辑工具
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 图片编辑工具 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">图片编辑工具</h3>
|
||||
</div>
|
||||
|
||||
{selectedImageNode ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
抠图
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
变清晰
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Expand className="h-4 w-4 mr-2" />
|
||||
扩展
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
调整
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Move3D className="h-4 w-4 mr-2" />
|
||||
角度
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
场景
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
选中图片节点以使用编辑工具
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3X3 className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">快捷操作</h3>
|
||||
{/* 快捷操作 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3X3 className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">快捷操作</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddImageNode}>
|
||||
<ImagePlus className="h-4 w-4 mr-2" />
|
||||
添加图片
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddTextNode}>
|
||||
<Type className="h-4 w-4 mr-2" />
|
||||
添加文本
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传图片
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddImageNode}>
|
||||
<ImagePlus className="h-4 w-4 mr-2" />
|
||||
添加图片
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddTextNode}>
|
||||
<Type className="h-4 w-4 mr-2" />
|
||||
添加文本
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传图片
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 justify-start">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
450
components/canvas/CanvasChatPanel.tsx
Normal file
450
components/canvas/CanvasChatPanel.tsx
Normal file
@ -0,0 +1,450 @@
|
||||
"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 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
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";
|
||||
|
||||
interface CanvasChatPanelProps {
|
||||
chatId: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
// 从消息中提取图片 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 = [] }: 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);
|
||||
|
||||
// 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 imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || [];
|
||||
|
||||
// 默认选择第一个图片模型
|
||||
useEffect(() => {
|
||||
if (imageModels.length > 0 && !selectedModelId) {
|
||||
setSelectedModelId(imageModels[0].id);
|
||||
}
|
||||
}, [imageModels, selectedModelId]);
|
||||
|
||||
const currentModelIdRef = useRef(selectedModelId);
|
||||
useEffect(() => {
|
||||
currentModelIdRef.current = selectedModelId;
|
||||
}, [selectedModelId]);
|
||||
|
||||
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) {
|
||||
return {
|
||||
body: {
|
||||
id: request.id,
|
||||
message: request.messages.at(-1),
|
||||
selectedChatModel: currentModelIdRef.current,
|
||||
selectedVisibilityType: "private",
|
||||
...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 handleSend = useCallback(() => {
|
||||
if (!input.trim() && attachments.length === 0) return;
|
||||
if (!selectedModelId) {
|
||||
toast.error("请选择一个模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const messageAttachments = attachments.map(a => ({
|
||||
contentType: a.contentType,
|
||||
url: a.url,
|
||||
name: a.name,
|
||||
}));
|
||||
|
||||
sendMessage({
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text", text: input }],
|
||||
attachments: messageAttachments,
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
}, [input, attachments, selectedModelId, sendMessage]);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
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();
|
||||
|
||||
setAttachments(prev => [...prev, {
|
||||
url: data.url,
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
}]);
|
||||
} catch (error) {
|
||||
toast.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">
|
||||
{/* 模型选择 */}
|
||||
<div className="p-3 border-b">
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</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" />
|
||||
{model.displayName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
<div className="space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>开始对话生成图片</p>
|
||||
<p className="text-xs mt-1">生成的图片将自动添加到画布</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"text-sm rounded-lg p-2",
|
||||
message.role === 'user'
|
||||
? "bg-primary text-primary-foreground ml-8"
|
||||
: "bg-muted mr-8"
|
||||
)}
|
||||
>
|
||||
{/* 附件预览 */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{message.attachments.map((att: any, i: number) => (
|
||||
<img
|
||||
key={i}
|
||||
src={att.url}
|
||||
alt=""
|
||||
className="h-12 w-12 object-cover rounded"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{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-2">
|
||||
{imageUrls.map((url, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={url}
|
||||
alt=""
|
||||
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
style={{ maxHeight: '200px' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted rounded-lg p-2 mr-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
生成中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 附件预览 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="p-2 border-t flex flex-wrap gap-2">
|
||||
{attachments.map((att, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={att.url}
|
||||
alt=""
|
||||
className="h-12 w-12 object-cover rounded"
|
||||
/>
|
||||
<button
|
||||
className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => removeAttachment(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="p-3 border-t">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<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-[60px] max-h-[120px] text-sm resize-none pr-10"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 bottom-1 h-7 w-7"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-[60px] w-10"
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || (!input.trim() && attachments.length === 0)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user