feat: redesign chat panel to match LOVART style

This commit is contained in:
Your Name 2026-01-24 21:36:05 +08:00
parent 79e5a870e1
commit f5c52bfdc6
3 changed files with 302 additions and 164 deletions

View File

@ -22,8 +22,8 @@ import {
Maximize2, Maximize2,
Move3D, Move3D,
Layers, Layers,
PanelLeftClose, PanelRightClose,
PanelLeft, PanelRight,
Download, Download,
Upload, Upload,
Wand2, Wand2,
@ -369,7 +369,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div> </div>
{/* 2. Floating Header (Top Left) */} {/* 2. Floating Header (Top Left) */}
<div className="absolute top-4 left-4 z-20 flex items-center gap-2 bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm"> <div className="absolute top-20 left-4 z-20 flex items-center gap-2 bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => router.push('/')} className="rounded-lg h-8 w-8"> <Button variant="ghost" size="icon" onClick={() => router.push('/')} className="rounded-lg h-8 w-8">
@ -407,7 +407,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div> </div>
{/* 3. Floating Actions (Top Right) */} {/* 3. Floating Actions (Top Right) */}
<div className="absolute top-4 right-4 z-20 flex items-center gap-2"> <div className="absolute top-20 right-4 z-20 flex items-center gap-2">
<div className="bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm flex items-center gap-1"> <div className="bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm flex items-center gap-1">
{isGenerating && ( {isGenerating && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2 border-r mr-1"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2 border-r mr-1">
@ -444,7 +444,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
onClick={() => setIsPanelOpen(!isPanelOpen)} onClick={() => setIsPanelOpen(!isPanelOpen)}
className={cn("rounded-lg h-8 w-8", isPanelOpen && "bg-muted")} className={cn("rounded-lg h-8 w-8", isPanelOpen && "bg-muted")}
> >
{isPanelOpen ? <PanelLeftClose className="h-4 w-4" /> : <PanelLeft className="h-4 w-4" />} {isPanelOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRight className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{isPanelOpen ? "隐藏面板" : "显示面板"}</TooltipContent> <TooltipContent>{isPanelOpen ? "隐藏面板" : "显示面板"}</TooltipContent>
@ -452,10 +452,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div> </div>
</div> </div>
{/* 4. Floating Sidebar (Left) */} {/* 4. Floating Sidebar (Right) */}
<div className={cn( <div className={cn(
"absolute top-20 left-4 bottom-24 z-10 w-[400px] transition-all duration-300 ease-in-out", "absolute top-36 right-4 bottom-24 z-10 w-[400px] transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "-translate-x-[120%] opacity-0" isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0"
)}> )}>
<div className="h-full w-full rounded-2xl border bg-background/95 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col"> <div className="h-full w-full rounded-2xl border bg-background/95 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col">
<Tabs defaultValue="chat" className="flex flex-col h-full"> <Tabs defaultValue="chat" className="flex flex-col h-full">

View File

@ -4,10 +4,11 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles } from "lucide-react"; 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 { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -26,6 +27,25 @@ interface CanvasChatPanelProps {
initialMessages?: ChatMessage[]; initialMessages?: ChatMessage[];
} }
// 建议卡片数据
const SUGGESTIONS = [
{
title: "Wine List",
desc: "Mimic this effect to genera...",
image: "https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=300&h=200&fit=crop"
},
{
title: "Coffee Shop Branding",
desc: "you are a brand design...",
image: "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=300&h=200&fit=crop"
},
{
title: "Story Board",
desc: "I NEED A STORY BOARD...",
image: "https://images.unsplash.com/photo-1626785774573-4b799314346d?w=300&h=200&fit=crop"
}
];
// 从消息中提取图片 URL // 从消息中提取图片 URL
function extractImageUrls(text: string): string[] { function extractImageUrls(text: string): string[] {
const urls: string[] = []; const urls: string[] = [];
@ -270,34 +290,75 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
const isLoading = status === 'streaming' || status === 'submitted'; const isLoading = status === 'streaming' || status === 'submitted';
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full bg-background/50 backdrop-blur-sm">
{/* 模型选择 */} {/* Header - Minimal with Icons */}
<div className="p-3 border-b"> <div className="flex items-center justify-end gap-1 p-3 border-b bg-background/80 backdrop-blur-md sticky top-0 z-10">
<Select value={selectedModelId} onValueChange={setSelectedModelId}> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<SelectTrigger className="h-8 text-xs"> <Plus className="h-4 w-4" />
<SelectValue placeholder="选择模型" /> </Button>
</SelectTrigger> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<SelectContent> <History className="h-4 w-4" />
{imageModels.map(model => ( </Button>
<SelectItem key={model.id} value={model.id} className="text-xs"> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<div className="flex items-center gap-2"> <Share2 className="h-4 w-4" />
<Sparkles className="h-3 w-3" /> </Button>
{model.displayName} <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
</div> <LayoutGrid className="h-4 w-4" />
</SelectItem> </Button>
))} <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
</SelectContent> <Maximize2 className="h-4 w-4" />
</Select> </Button>
</div> </div>
{/* 消息列表 */} {/* 消息列表 */}
<ScrollArea className="flex-1 p-3"> <ScrollArea className="flex-1 p-4">
<div className="space-y-3"> <div className="space-y-6">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8"> <div className="flex flex-col h-full pt-4">
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" /> {/* Greeting */}
<p>开始对话生成图片</p> <div className="mb-8">
<p className="text-xs mt-1">生成的图片将自动添加到画布</p> <div className="w-12 h-12 bg-black rounded-full flex items-center justify-center mb-4">
<span className="text-white font-bold text-xl">-L°</span>
</div>
<h1 className="text-2xl font-bold mb-2">Hi, 我是你的AI设计师</h1>
<p className="text-muted-foreground">让我们开始今天的创作吧!</p>
</div>
{/* Suggestions */}
<div className="space-y-3 mb-8">
{SUGGESTIONS.map((item, i) => (
<div
key={i}
className="flex items-center justify-between p-3 bg-card rounded-xl border hover:shadow-md transition-all cursor-pointer group"
onClick={() => setInput(prev => prev || item.desc)}
>
<div className="flex-1 min-w-0 mr-3">
<h3 className="font-medium text-sm mb-1 group-hover:text-primary transition-colors">{item.title}</h3>
<p className="text-xs text-muted-foreground truncate">{item.desc}</p>
</div>
<div className="w-16 h-12 rounded-lg overflow-hidden shrink-0">
<img src={item.image} alt={item.title} className="w-full h-full object-cover" />
</div>
</div>
))}
</div>
{/* Refresh */}
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-4 cursor-pointer hover:text-foreground w-fit">
<RefreshCw className="h-3 w-3" />
<span>切换</span>
</div>
{/* Banner */}
<div className="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">
<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>升级会员, Nano Banana Pro 免费365天!</span>
</div>
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" />
</div>
</div> </div>
)} )}
@ -305,67 +366,105 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
<div <div
key={message.id} key={message.id}
className={cn( className={cn(
"text-sm rounded-lg p-2", "flex gap-3",
message.role === 'user' message.role === 'user' ? "flex-row-reverse" : "flex-row"
? "bg-primary text-primary-foreground ml-8"
: "bg-muted mr-8"
)} )}
> >
{/* 附件预览 */} {/* 头像 */}
{message.attachments && message.attachments.length > 0 && ( <Avatar className="h-8 w-8 shrink-0 border shadow-sm">
<div className="flex flex-wrap gap-1 mb-1"> {message.role === 'user' ? (
{message.attachments.map((att: any, i: number) => ( <>
<img <AvatarImage src="/placeholder-user.jpg" />
key={i} <AvatarFallback className="bg-primary text-primary-foreground">
src={att.url} <User className="h-4 w-4" />
alt="" </AvatarFallback>
className="h-12 w-12 object-cover rounded" </>
/> ) : (
))} <>
</div> <AvatarImage src="/placeholder-bot.jpg" />
)} <AvatarFallback className="bg-muted text-foreground">
<Bot className="h-4 w-4" />
</AvatarFallback>
</>
)}
</Avatar>
{/* 消息内容 */} {/* 消息气泡 */}
<div className="whitespace-pre-wrap break-words"> <div className={cn(
{message.parts?.map((part: any, i: number) => { "flex flex-col gap-2 max-w-[85%]",
if (part.type === 'text') { message.role === 'user' ? "items-end" : "items-start"
// 提取并显示图片 )}>
const imageUrls = extractImageUrls(part.text || ''); <div
const textWithoutImages = (part.text || '') className={cn(
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '') "text-sm px-4 py-2.5 shadow-sm",
.trim(); 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>
)}
return ( {/* 消息内容 */}
<div key={i}> <div className="whitespace-pre-wrap break-words leading-relaxed">
{textWithoutImages && <span>{textWithoutImages}</span>} {message.parts?.map((part: any, i: number) => {
{imageUrls.length > 0 && ( if (part.type === 'text') {
<div className="flex flex-wrap gap-2 mt-2"> // 提取并显示图片
{imageUrls.map((url, idx) => ( const imageUrls = extractImageUrls(part.text || '');
<img const textWithoutImages = (part.text || '')
key={idx} .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '')
src={url} .trim();
alt=""
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity" return (
style={{ maxHeight: '200px' }} <div key={i}>
onClick={() => window.open(url, '_blank')} {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> </div>
)} );
</div> }
); return null;
} })}
return null; </div>
})} </div>
</div> </div>
</div> </div>
))} ))}
{isLoading && ( {isLoading && (
<div className="bg-muted rounded-lg p-2 mr-8"> <div className="flex gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <Avatar className="h-8 w-8 shrink-0 border shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" /> <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> </div>
)} )}
@ -374,75 +473,120 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
</div> </div>
</ScrollArea> </ScrollArea>
{/* 附件预览 */} {/* 底部输入区域 - Redesigned */}
{attachments.length > 0 && ( <div className="p-4 bg-background/80 backdrop-blur-md border-t">
<div className="p-2 border-t flex flex-wrap gap-2"> {/* 附件预览 */}
{attachments.map((att, index) => ( {attachments.length > 0 && (
<div key={index} className="relative group"> <div className="flex flex-wrap gap-2 mb-3">
<img {attachments.map((att, index) => (
src={att.url} <div key={index} className="relative group">
alt="" <img
className="h-12 w-12 object-cover rounded" src={att.url}
/> alt=""
<button className="h-14 w-14 object-cover rounded-lg border shadow-sm"
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)} <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"
<X className="h-3 w-3" /> onClick={() => removeAttachment(index)}
</button> >
</div> <X className="h-3 w-3" />
))} </button>
</div> </div>
)} ))}
</div>
{/* 输入区域 */} )}
<div className="p-3 border-t">
<div className="flex gap-2"> <div className="relative bg-card rounded-3xl border shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all">
<div className="flex-1 relative"> <Textarea
<Textarea ref={textareaRef}
ref={textareaRef} value={input}
value={input} onChange={(e) => setInput(e.target.value)}
onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => {
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault();
e.preventDefault(); handleSend();
handleSend(); }
} }}
}} placeholder="请输入你的设计需求"
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"
className="min-h-[60px] max-h-[120px] text-sm resize-none pr-10" disabled={isLoading}
disabled={isLoading} />
/>
<input {/* Bottom Toolbar */}
ref={fileInputRef} <div className="absolute left-2 bottom-2 right-2 flex items-center justify-between">
type="file" <div className="flex items-center gap-2">
accept="image/*" <input
multiple ref={fileInputRef}
className="hidden" type="file"
onChange={handleFileSelect} accept="image/*"
/> multiple
<Button className="hidden"
variant="ghost" onChange={handleFileSelect}
size="icon" />
className="absolute right-1 bottom-1 h-7 w-7" <Button
onClick={() => fileInputRef.current?.click()} variant="ghost"
disabled={isLoading} size="icon"
> className="h-8 w-8 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
<Paperclip className="h-4 w-4" /> onClick={() => fileInputRef.current?.click()}
</Button> 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">
{/* Style/Settings Icons (Visual only for now) */}
<div className="flex items-center gap-1 mr-2 border-r pr-2">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Zap className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Globe className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Box className="h-4 w-4" />
</Button>
</div>
<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>
<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> </div>
</div> </div>

View File

@ -228,26 +228,20 @@ export function CanvasWorkspace() {
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onWheel={handleWheel} onWheel={handleWheel}
> >
{/* 网格背景 - 浅色模式 */} {/* 点阵背景 - 浅色模式 */}
<div <div
className="absolute inset-0 pointer-events-none dark:hidden" className="absolute inset-0 pointer-events-none dark:hidden"
style={{ style={{
backgroundImage: ` backgroundImage: `radial-gradient(rgba(0,0,0,0.1) 1px, transparent 1px)`,
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px)
`,
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`, backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
backgroundPosition: `${viewport.x}px ${viewport.y}px`, backgroundPosition: `${viewport.x}px ${viewport.y}px`,
}} }}
/> />
{/* 深色模式网格 */} {/* 深色模式点阵 */}
<div <div
className="absolute inset-0 pointer-events-none hidden dark:block" className="absolute inset-0 pointer-events-none hidden dark:block"
style={{ style={{
backgroundImage: ` backgroundImage: `radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px)`,
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
`,
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`, backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
backgroundPosition: `${viewport.x}px ${viewport.y}px`, backgroundPosition: `${viewport.x}px ${viewport.y}px`,
}} }}