This commit is contained in:
2026-04-12 01:02:14 +08:00
parent 509487f155
commit 9b053e302b
14085 changed files with 2680009 additions and 12 deletions

View File

@@ -0,0 +1,131 @@
"use client";
import { useRef, useState, type KeyboardEvent } from "react";
interface ChatInputProps {
onSend: (text: string, refImage: File | null) => void;
disabled: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [text, setText] = useState("");
const [refImage, setRefImage] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
onSend(trimmed, refImage);
setText("");
setRefImage(null);
setPreviewUrl(null);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setRefImage(file);
setPreviewUrl(URL.createObjectURL(file));
};
const removeRefImage = () => {
setRefImage(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<div className="border-t border-[var(--border)] bg-[var(--bg-secondary)] px-4 py-3">
{/* 参考图预览 */}
{previewUrl && (
<div className="mb-3 flex items-start gap-2">
<div className="relative">
<img
src={previewUrl}
alt="参考图"
className="w-16 h-16 rounded-lg object-cover border border-[var(--border)]"
/>
<button
onClick={removeRefImage}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full
bg-red-500 text-white text-xs flex items-center justify-center
hover:bg-red-600 cursor-pointer"
>
×
</button>
</div>
<span className="text-xs text-[var(--text-secondary)] mt-1"></span>
</div>
)}
<div className="flex items-end gap-2">
{/* 上传参考图按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
className="flex-shrink-0 w-10 h-10 rounded-lg border border-[var(--border)]
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:border-[var(--accent)]
flex items-center justify-center transition-colors
disabled:opacity-50 cursor-pointer"
title="上传参考图"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* 文字输入框 */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="描述你想要的美术资源..."
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-lg border border-[var(--border)]
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
placeholder:text-[var(--text-secondary)]
px-4 py-2.5 text-sm leading-relaxed
focus:outline-none focus:border-[var(--accent)]
disabled:opacity-50 transition-colors
min-h-[42px] max-h-[120px]"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
{/* 发送按钮 */}
<button
onClick={handleSubmit}
disabled={disabled || !text.trim()}
className="flex-shrink-0 w-10 h-10 rounded-lg
bg-[var(--accent)] text-white
hover:bg-[var(--accent-hover)]
flex items-center justify-center transition-colors
disabled:opacity-50 cursor-pointer"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import type { Message } from "@/lib/api";
import { ImageGrid } from "./image-grid";
interface ChatMessagesProps {
messages: Message[];
isLoading: boolean;
streamingText: string;
streamingImages: string[];
statusText: string;
}
export function ChatMessages({
messages,
isLoading,
streamingText,
streamingImages,
statusText,
}: ChatMessagesProps) {
return (
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
{/* 历史消息 */}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
msg.role === "user"
? "bg-[var(--accent)] text-white"
: "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
}`}
>
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
{msg.images && msg.images.length > 0 && <ImageGrid images={msg.images} />}
</div>
</div>
))}
{/* 正在生成的消息 */}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl px-4 py-3 bg-[var(--bg-tertiary)]">
{statusText && (
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
{statusText}
</div>
)}
{streamingText && (
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
)}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && (
<div className="flex gap-1">
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce" />
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.1s]" />
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.2s]" />
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { getImageUrl } from "@/lib/api";
interface ImageGridProps {
images: string[];
}
export function ImageGrid({ images }: ImageGridProps) {
const handleDownload = async (imageUrl: string, index: number) => {
try {
const fullUrl = getImageUrl(imageUrl);
const response = await fetch(fullUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `art-agent-${Date.now()}-${index + 1}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
alert("下载失败,请重试");
}
};
const gridCols =
images.length === 1
? "grid-cols-1 max-w-md"
: images.length === 2
? "grid-cols-2 max-w-2xl"
: "grid-cols-2 max-w-2xl";
return (
<div className={`grid ${gridCols} gap-3 my-3`}>
{images.map((img, i) => (
<div key={i} className="group relative rounded-lg overflow-hidden border border-[var(--border)]">
<img
src={getImageUrl(img)}
alt={`生成图片 ${i + 1}`}
className="w-full aspect-square object-cover"
loading="lazy"
/>
<button
onClick={() => handleDownload(img, i)}
className="absolute bottom-2 right-2 px-3 py-1.5 rounded-md
bg-black/70 text-white text-sm
opacity-0 group-hover:opacity-100 transition-opacity
hover:bg-black/90 cursor-pointer"
>
</button>
</div>
))}
</div>
);
}