"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { ChatMessages } from "@/components/chat/chat-messages"; import { ChatInput } from "@/components/chat/chat-input"; import { Sidebar } from "@/components/sidebar/sidebar"; import { TopNav } from "@/components/layout/top-nav"; import { ImageDetailPanel } from "@/components/detail/image-detail-panel"; import { useApp } from "@/lib/app-context"; import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api"; import { generateId } from "@/lib/store"; 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 [pendingAnnotation, setPendingAnnotation] = useState(null); const scrollRef = useRef(null); const scrollPositions = useRef>(new Map()); const prevSessionId = useRef(null); const isSwitching = useRef(false); const [showScrollBtn, setShowScrollBtn] = 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]); // 会话切换:标记 switching,等 DOM 更新后恢复位置 useEffect(() => { if (!activeSessionId) return; if (prevSessionId.current && prevSessionId.current !== activeSessionId) { isSwitching.current = true; } prevSessionId.current = 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, refImageServerUrl: string | null, imageModel: string | null = null) => { if (!activeSessionId || !activeSession) return; let finalText = text; let finalRefServerUrl = refImageServerUrl; 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}`; } // 标注截图作为参考图:需要先上传再获取 URL if (pendingAnnotation.snapshot && !refImageServerUrl) { 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); finalRefServerUrl = uploadResult.url; } catch { // 忽略转换/上传失败 } } setPendingAnnotation(null); } // 用户消息中的参考图预览:优先使用服务端路径(通过 getImageUrl 转为完整 URL) let previewUrl: string | undefined; if (finalRefServerUrl) { previewUrl = getImageUrl(finalRefServerUrl); } const userMessage: ChatMessage = { id: generateId("msg-"), role: "user", content: finalText, refImageUrl: previewUrl, }; appendMessage(activeSessionId, userMessage); setIsLoading(true); 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 = ""; try { for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel, activeSessionId)) { 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": { 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]); 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(""); 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); setStreamingText(""); setStreamingImages([]); setStatusText(""); scrollToBottom(); }, [activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation, scrollToBottom] ); const handleAnnotationComplete = useCallback((data: AnnotationData) => { setPendingAnnotation(data); }, []); return (
{/* 桌面端侧边栏展开按钮(移动端用顶栏汉堡菜单替代) */} {sidebarCollapsed && ( )} {/* 标注提示条 */} {pendingAnnotation && (
标注预览 标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
)}
{messages.length === 0 && !isLoading ? (
🎨

欢迎使用 EPEEKit

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

{[ "画一个赛博朋克风格的退出按钮", "设计一个卡通风格的金币图标", "画一个奇幻风格的游戏角色立绘", ].map((hint) => ( ))}
) : ( )}
{showScrollBtn && ( )}
{detailImage && ( )}
); }