ai-chatbot/components/canvas/CanvasChatPanel.tsx
Your Name f289eca90c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
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
2026-01-24 21:19:25 +08:00

451 lines
14 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 } 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 图片格式: ![alt|||originalUrl|||size](thumbnailUrl)
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>
);
}