大活
This commit is contained in:
30
art-agent/frontend/src/app/globals.css
Normal file
30
art-agent/frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
19
art-agent/frontend/src/app/layout.tsx
Normal file
19
art-agent/frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
art-agent/frontend/src/app/page.tsx
Normal file
152
art-agent/frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
art-agent/frontend/src/components/chat/chat-input.tsx
Normal file
131
art-agent/frontend/src/components/chat/chat-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal file
65
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
art-agent/frontend/src/components/chat/image-grid.tsx
Normal file
58
art-agent/frontend/src/components/chat/image-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
art-agent/frontend/src/lib/api.ts
Normal file
95
art-agent/frontend/src/lib/api.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user