"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([]); const [statusText, setStatusText] = useState(""); const [isGeneratingImage, setIsGeneratingImage] = useState(false); const [pendingAnnotation, setPendingAnnotation] = useState(null); const [lastRefImageUrls, setLastRefImageUrls] = useState([]); const lastRefPerSession = useRef>(new Map()); const [mainDragging, setMainDragging] = useState(false); const dragCounter = useRef(0); const chatInputRef = useRef(null); const scrollRef = useRef(null); const scrollPositions = useRef>(new Map()); const prevSessionId = useRef(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 (
{/* 环境粒子(萤火 + 云雾) */}
{ 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 && (

松开以添加参考图(可多张)

)} {/* 桌面端侧边栏展开按钮 */} {sidebarCollapsed && ( )} {/* 标注提示条 */} {pendingAnnotation && (
标注预览 标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
)}
{isEmptyState ? (
{/* Logo 发光效果 */}

欢迎使用 EPEEKit

描述你想要的美术资源,我来帮你生成。
你可以上传参考图来引导风格方向。

{[ "画一个赛博朋克风格的退出按钮", "设计一个卡通风格的金币图标", "画一个奇幻风格的游戏角色立绘", ].map((hint) => ( ))}
) : ( )}
{/* 回到底部 — 落叶归根(水滴形) */} {showScrollBtn && ( { 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="回到底部" > )}
getImageUrl(u))} onClearLastRefImage={() => { if (activeSessionId) lastRefPerSession.current.delete(activeSessionId); setLastRefImageUrls([]); }} onFileDrop={() => { dragCounter.current = 0; setMainDragging(false); }} />
{detailImage && ( )}
{showCandidates && ( m.role === "assistant" && m.images && m.images.length > 0)?.images ?? streamingImages ?? [] } onPick={(id) => { alert(`(占位)已选 ${id} 作为定稿`); setShowCandidates(false); }} onClose={() => setShowCandidates(false)} /> )}
); }