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,30 @@
@import "tailwindcss";
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #141414;
--bg-tertiary: #1e1e1e;
--text-primary: #e5e5e5;
--text-secondary: #a3a3a3;
--accent: #6366f1;
--accent-hover: #818cf8;
--border: #2e2e2e;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, -apple-system, sans-serif;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Art Agent MVP",
description: "AI 美术资源生成助手",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChatInput } from "@/components/chat/chat-input";
import { sendChat, type Message } from "@/lib/api";
export default function Home() {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingImages, setStreamingImages] = useState<string[]>([]);
const [statusText, setStatusText] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
setTimeout(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, 50);
};
const handleSend = useCallback(
async (text: string, refImage: File | null) => {
// 添加用户消息
const userMessage: Message = { role: "user", content: text };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setIsLoading(true);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
let assistantText = "";
let collectedImages: string[] = [];
try {
for await (const event of sendChat(updatedMessages, refImage)) {
switch (event.type) {
case "text_delta":
assistantText += event.data.text as string;
setStreamingText(assistantText);
scrollToBottom();
break;
case "tool_start":
setStatusText(event.data.message as string);
scrollToBottom();
break;
case "image_result":
collectedImages = [
...collectedImages,
...(event.data.images as string[]),
];
setStreamingImages(collectedImages);
setStatusText("");
scrollToBottom();
break;
case "error":
assistantText += `\n\n[错误: ${event.data.message}]`;
setStreamingText(assistantText);
break;
case "done":
break;
}
}
} catch (e) {
assistantText += `\n\n[请求失败: ${e instanceof Error ? e.message : "未知错误"}]`;
}
// 完成:将流式内容合并为正式消息
const assistantMessage: Message = {
role: "assistant",
content: assistantText || "(生成完成)",
images: collectedImages.length > 0 ? collectedImages : undefined,
};
setMessages([...updatedMessages, assistantMessage]);
setIsLoading(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
},
[messages]
);
return (
<div className="h-screen flex flex-col">
{/* 标题栏 */}
<header className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-6 py-3">
<h1 className="text-lg font-semibold">Art Agent</h1>
<p className="text-xs text-[var(--text-secondary)]">
AI · MVP
</p>
</header>
{/* 消息区域 */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-3">
<div className="text-4xl">🎨</div>
<h2 className="text-xl font-medium text-[var(--text-primary)]">
使 Art Agent
</h2>
<p className="text-sm text-[var(--text-secondary)] max-w-md">
<br />
</p>
<div className="flex flex-wrap justify-center gap-2 mt-4">
{[
"画一个赛博朋克风格的退出按钮",
"设计一个卡通风格的金币图标",
"画一个奇幻风格的游戏角色立绘",
].map((hint) => (
<button
key={hint}
onClick={() => handleSend(hint, null)}
className="text-xs px-3 py-1.5 rounded-full
border border-[var(--border)] text-[var(--text-secondary)]
hover:border-[var(--accent)] hover:text-[var(--accent)]
transition-colors cursor-pointer"
>
{hint}
</button>
))}
</div>
</div>
</div>
) : (
<ChatMessages
messages={messages}
isLoading={isLoading}
streamingText={streamingText}
streamingImages={streamingImages}
statusText={statusText}
/>
)}
</div>
{/* 输入区域 */}
<ChatInput onSend={handleSend} disabled={isLoading} />
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,95 @@
/**
* 后端 API 调用封装 + SSE 流式读取。
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export interface Message {
role: "user" | "assistant";
content: string;
images?: string[];
}
export interface SSEEvent {
type: "text_delta" | "tool_start" | "image_result" | "done" | "error";
data: Record<string, unknown>;
}
/**
* 发送对话消息到后端,返回 SSE 事件的异步迭代器。
*/
export async function* sendChat(
messages: Message[],
refImageFile?: File | null
): AsyncGenerator<SSEEvent> {
const formData = new FormData();
// messages 序列化:只发 role + content
const apiMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
formData.append("messages", JSON.stringify(apiMessages));
if (refImageFile) {
formData.append("ref_image", refImageFile);
}
const response = await fetch(`${API_URL}/api/chat`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`API 请求失败: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("无法读取响应流");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按 SSE 协议解析:每个事件以 \n\n 分隔
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
if (!part.trim()) continue;
let eventType = "message";
let eventData = "";
for (const line of part.split("\n")) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
eventData = line.slice(5).trim();
}
}
if (eventData) {
try {
yield {
type: eventType as SSEEvent["type"],
data: JSON.parse(eventData),
};
} catch {
// 解析失败则跳过
}
}
}
}
}
/** 获取图片完整 URL处理相对路径。 */
export function getImageUrl(path: string): string {
if (path.startsWith("http")) return path;
return `${API_URL}${path}`;
}