Files
EPEEAIKit/art-agent/frontend/src/app/page.tsx
Nostars Developer b12e55a776 交互原型大版本
2026-04-17 19:30:23 +08:00

537 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
import { SessionQuickPicks } from "@/components/chat/session-quick-picks";
import { AdvancedControls } from "@/components/workbench/advanced-controls";
import { CandidatePanel } from "@/components/workbench/candidate-panel";
import { Sidebar } from "@/components/sidebar/sidebar";
import { TopNav } from "@/components/layout/top-nav";
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
import { generateId } from "@/lib/store";
import { scrollBtnEnter, scrollBtnClick, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
export default function Home() {
const {
activeSession,
activeSessionId,
appendMessage,
addAsset,
updateSessionThumbnail,
detailImage,
sidebarCollapsed,
setSidebarCollapsed,
} = useApp();
const messages = activeSession?.messages ?? [];
const [isLoading, setIsLoading] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
const [statusText, setStatusText] = useState("");
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
const [mainDragging, setMainDragging] = useState(false);
const dragCounter = useRef(0);
const chatInputRef = useRef<ChatInputHandle>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollPositions = useRef<Map<string, number>>(new Map());
const prevSessionId = useRef<string | null>(null);
const isSwitching = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const [showCandidates, setShowCandidates] = useState(false);
const scrollToBottom = useCallback((instant?: boolean) => {
if (isSwitching.current && !instant) return;
const el = scrollRef.current;
if (!el) return;
if (instant) {
el.scrollTop = el.scrollHeight;
} else {
setTimeout(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, 50);
}
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el || !activeSessionId) return;
const handler = () => {
if (!isSwitching.current) {
scrollPositions.current.set(activeSessionId, el.scrollTop);
}
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBtn(distFromBottom > 200);
};
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}, [activeSessionId]);
useEffect(() => {
if (!activeSessionId) return;
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
isSwitching.current = true;
}
prevSessionId.current = activeSessionId;
setLastRefImageUrls(lastRefPerSession.current.get(activeSessionId) ?? []);
}, [activeSessionId]);
useEffect(() => {
if (!activeSessionId || !isSwitching.current) return;
requestAnimationFrame(() => {
const el = scrollRef.current;
if (!el) return;
const saved = scrollPositions.current.get(activeSessionId);
if (saved !== undefined) {
el.scrollTop = saved;
} else {
el.scrollTop = el.scrollHeight;
}
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBtn(distFromBottom > 200);
isSwitching.current = false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSessionId, messages.length]);
const handleSend = useCallback(
async (text: string, refImageServerUrls: string[], imageModel: string | null = null) => {
if (!activeSessionId || !activeSession) return;
let finalText = text;
let finalRefServerUrls = [...refImageServerUrls];
if (pendingAnnotation) {
const annotationDescs = pendingAnnotation.annotations
.filter((a) => a.text)
.map((a) => {
if (a.type === "rect") {
return `[区域 (${Math.round(a.x * 100)}%, ${Math.round(a.y * 100)}%) 大小 ${Math.round((a.w ?? 0) * 100)}%×${Math.round((a.h ?? 0) * 100)}%]: ${a.text}`;
}
return `[标注]: ${a.text}`;
})
.join("\n");
if (annotationDescs) {
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
}
if (pendingAnnotation.snapshot && finalRefServerUrls.length === 0) {
try {
const res = await fetch(pendingAnnotation.snapshot);
const blob = await res.blob();
const file = new File([blob], "annotation.png", { type: "image/png" });
const uploadResult = await uploadRefImage(file);
finalRefServerUrls = [uploadResult.url];
} catch {
// 忽略转换/上传失败
}
}
setPendingAnnotation(null);
}
if (finalRefServerUrls.length > 0) {
lastRefPerSession.current.set(activeSessionId, finalRefServerUrls);
setLastRefImageUrls(finalRefServerUrls);
}
const previewUrls = finalRefServerUrls.map((u) => getImageUrl(u));
const userMessage: ChatMessage = {
id: generateId("msg-"),
role: "user",
content: finalText,
refImageUrls: previewUrls.length > 0 ? previewUrls : undefined,
};
appendMessage(activeSessionId, userMessage);
setIsLoading(true);
setIsGeneratingImage(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
const apiMessages: ApiMessage[] = [
...activeSession.messages.map((m) => ({ role: m.role, content: m.content })),
{ role: userMessage.role, content: userMessage.content },
];
let assistantText = "";
let collectedImages: ImageAsset[] = [];
let usedModelName = "";
const llmModel = activeSession?.llmModel ?? null;
try {
for await (const event of sendChat(apiMessages, finalRefServerUrls.length > 0 ? finalRefServerUrls : null, imageModel, activeSessionId, llmModel)) {
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);
setIsGeneratingImage(true);
scrollToBottom();
break;
case "image_result": {
const rawUrls = (event.data.images as string[]) || [];
const newUrls = rawUrls.filter((u) => u && !u.startsWith("["));
const prompt = (event.data.prompt_used as string) || "";
const modelName = (event.data.model_name as string) || "";
if (newUrls.length === 0) break;
const newAssets: ImageAsset[] = newUrls.map((url) => ({
id: generateId("img-"),
url,
prompt,
sessionId: activeSessionId,
tags: activeSession.tags ? [...activeSession.tags] : [],
favorited: false,
createdAt: Date.now(),
}));
newAssets.forEach((a) => addAsset(a));
if (newAssets.length > 0) {
updateSessionThumbnail(activeSessionId, newAssets[0].url);
}
collectedImages = [...collectedImages, ...newAssets];
setStreamingImages([...collectedImages]);
setIsGeneratingImage(false);
if (modelName) {
usedModelName = modelName;
setStatusText(`${modelName} 生成`);
} else {
setStatusText("");
}
scrollToBottom();
break;
}
case "tool_error": {
const errors = (event.data.errors as string[]) || [];
const modelName = (event.data.model_name as string) || "";
const errorDetail = errors.join("\n");
const modelHint = modelName ? ` (${modelName})` : "";
assistantText += `\n\n⚠ 图片生成失败${modelHint}:\n${errorDetail}`;
setStreamingText(assistantText);
setStatusText("");
setIsGeneratingImage(false);
scrollToBottom();
break;
}
case "memory_warning":
console.warn("[记忆系统]", event.data.message);
setStatusText(`${event.data.message}`);
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: ChatMessage = {
id: generateId("msg-"),
role: "assistant",
content: assistantText || "(生成完成)",
images: collectedImages.length > 0 ? collectedImages : undefined,
modelName: usedModelName || undefined,
};
appendMessage(activeSessionId, assistantMessage);
setIsLoading(false);
setIsGeneratingImage(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
scrollToBottom();
},
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom]
);
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
setPendingAnnotation(data);
}, []);
const isEmptyState = messages.length === 0 && !isLoading;
return (
<div className="h-screen flex flex-col relative z-[1]">
{/* 环境粒子(萤火 + 云雾) */}
<AmbientParticles count={isEmptyState ? 28 : 18} />
<TopNav />
<div className="flex-1 flex overflow-hidden">
<Sidebar />
<main
className="flex-1 flex flex-col min-w-0 relative"
onDragEnter={(e: DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer.types.includes("Files")) setMainDragging(true);
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDragLeave={(e: DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setMainDragging(false); }
}}
onDrop={(e: DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setMainDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
files.forEach((f) => chatInputRef.current?.uploadFile(f));
}}
>
{/* 拖拽覆盖层 */}
{mainDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center
bg-[var(--bg-primary)]/80 backdrop-blur-sm
border-2 border-dashed border-[var(--accent)]/50 rounded-xl m-2
pointer-events-none">
<div className="text-center space-y-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="var(--accent)" strokeWidth="1.5" className="mx-auto opacity-70">
<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>
<p className="text-sm text-[var(--accent)] font-medium"></p>
</div>
</div>
)}
{/* 桌面端侧边栏展开按钮 */}
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-lg
bg-[var(--bg-secondary)]/80 backdrop-blur-sm
border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40
transition-all cursor-pointer btn-hover-lift
hidden md:flex"
title="展开侧边栏"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" />
</svg>
</button>
)}
{/* 标注提示条 */}
{pendingAnnotation && (
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
flex items-center gap-3 backdrop-blur-sm">
<img
src={pendingAnnotation.snapshot}
alt="标注预览"
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
shadow-[0_0_8px_rgba(46,139,122,0.12)]"
/>
<span className="text-xs text-[var(--accent)]">
{pendingAnnotation.annotations.length}
</span>
<button
onClick={() => setPendingAnnotation(null)}
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer transition-colors"
>
</button>
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
<div className="min-h-full">
{isEmptyState ? (
<div className="h-full flex items-center justify-center" style={{ minHeight: "calc(100vh - 200px)" }}>
<motion.div
className="text-center space-y-5"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{/* Logo 发光效果 */}
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_30px_rgba(46,139,122,0.12)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</motion.div>
<motion.div variants={staggerItem}>
<h2 className="typo-display mb-2">
使 EPEEKit
</h2>
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed mx-auto">
<br />
</p>
</motion.div>
<motion.div variants={staggerItem} className="flex flex-wrap justify-center gap-2 pt-2">
{[
"画一个赛博朋克风格的退出按钮",
"设计一个卡通风格的金币图标",
"画一个奇幻风格的游戏角色立绘",
].map((hint) => (
<button
key={hint}
onClick={() => handleSend(hint, [], null)}
className="text-xs px-4 py-2 rounded-xl
border border-[var(--border)] text-[var(--text-secondary)]
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5
transition-all cursor-pointer btn-hover-lift"
>
{hint}
</button>
))}
</motion.div>
</motion.div>
</div>
) : (
<ChatMessages
messages={messages}
isLoading={isLoading}
streamingText={streamingText}
streamingImages={streamingImages}
statusText={statusText}
/>
)}
</div>
</div>
{/* 回到底部 — 落叶归根(水滴形) */}
<AnimatePresence>
{showScrollBtn && (
<motion.button
key="scroll-btn"
variants={scrollBtnEnter}
initial="hidden"
animate="visible"
exit="exit"
onClick={() => {
scrollToBottom();
}}
className="absolute bottom-16 right-4 z-10
w-9 h-11 flex items-center justify-center
surface-2 text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
shadow-lg shadow-black/30
cursor-pointer"
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
title="回到底部"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-1">
<polyline points="6 9 12 15 18 9" />
</svg>
</motion.button>
)}
</AnimatePresence>
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md
flex items-center">
<div className="flex-1 min-w-0">
<SessionQuickPicks />
</div>
<div className="px-3 md:px-5 py-2 flex items-center gap-1.5">
<AdvancedControls />
<button
onClick={() => setShowCandidates(true)}
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
title="候选评估(占位)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
<span className="phase-chip">P1</span>
</button>
</div>
</div>
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={isLoading}
isGeneratingImage={isGeneratingImage}
lastRefServerUrls={lastRefImageUrls}
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
onClearLastRefImage={() => {
if (activeSessionId) lastRefPerSession.current.delete(activeSessionId);
setLastRefImageUrls([]);
}}
onFileDrop={() => {
dragCounter.current = 0;
setMainDragging(false);
}}
/>
</main>
{detailImage && (
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
)}
</div>
{showCandidates && (
<CandidatePanel
candidates={
messages
.slice()
.reverse()
.find((m) => m.role === "assistant" && m.images && m.images.length > 0)?.images ??
streamingImages ??
[]
}
onPick={(id) => {
alert(`(占位)已选 ${id} 作为定稿`);
setShowCandidates(false);
}}
onClose={() => setShowCandidates(false)}
/>
)}
</div>
);
}