feat: redesign chat panel to match LOVART style
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

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,
Move3D,
Layers,
PanelLeftClose,
PanelLeft,
PanelRightClose,
PanelRight,
Download,
Upload,
Wand2,
@ -369,7 +369,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
{/* 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>
<TooltipTrigger asChild>
<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>
{/* 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">
{isGenerating && (
<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)}
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>
</TooltipTrigger>
<TooltipContent>{isPanelOpen ? "隐藏面板" : "显示面板"}</TooltipContent>
@ -452,10 +452,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
</div>
{/* 4. Floating Sidebar (Left) */}
{/* 4. Floating Sidebar (Right) */}
<div className={cn(
"absolute top-20 left-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"
"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"
)}>
<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">

View File

@ -4,10 +4,11 @@ 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 { 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,
@ -26,6 +27,25 @@ interface CanvasChatPanelProps {
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
function extractImageUrls(text: string): string[] {
const urls: string[] = [];
@ -270,34 +290,75 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
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 className="flex flex-col h-full bg-background/50 backdrop-blur-sm">
{/* Header - Minimal with Icons */}
<div className="flex items-center justify-end gap-1 p-3 border-b bg-background/80 backdrop-blur-md sticky top-0 z-10">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Share2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<LayoutGrid className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{/* 消息列表 */}
<ScrollArea className="flex-1 p-3">
<div className="space-y-3">
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{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 className="flex flex-col h-full pt-4">
{/* Greeting */}
<div className="mb-8">
<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>
)}
@ -305,67 +366,105 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
<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"
"flex gap-3",
message.role === 'user' ? "flex-row-reverse" : "flex-row"
)}
>
{/* 附件预览 */}
{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')}
/>
))}
{/* 头像 */}
<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>
)}
</div>
);
}
return null;
})}
);
}
return null;
})}
</div>
</div>
</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 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>
)}
@ -374,75 +473,120 @@ export function CanvasChatPanel({ chatId, initialMessages = [] }: CanvasChatPane
</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>
{/* 底部输入区域 - 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 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all">
<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">
{/* 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>
<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>

View File

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