382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
"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<ImageAsset[]>([]);
|
||
const [statusText, setStatusText] = useState("");
|
||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(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 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 (
|
||
<div className="h-screen flex flex-col">
|
||
<TopNav />
|
||
|
||
<div className="flex-1 flex overflow-hidden">
|
||
<Sidebar />
|
||
|
||
<main className="flex-1 flex flex-col min-w-0 relative">
|
||
{/* 桌面端侧边栏展开按钮(移动端用顶栏汉堡菜单替代) */}
|
||
{sidebarCollapsed && (
|
||
<button
|
||
onClick={() => setSidebarCollapsed(false)}
|
||
className="absolute left-1 top-2 z-10 p-1.5 rounded-md
|
||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||
transition-colors cursor-pointer
|
||
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-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30
|
||
flex items-center gap-3">
|
||
<img
|
||
src={pendingAnnotation.snapshot}
|
||
alt="标注预览"
|
||
className="w-10 h-10 rounded object-cover border border-[var(--accent)]"
|
||
/>
|
||
<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"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<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)]">
|
||
欢迎使用 EPEEKit
|
||
</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, 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>
|
||
|
||
{showScrollBtn && (
|
||
<button
|
||
onClick={() => scrollToBottom()}
|
||
className="absolute bottom-16 right-4 z-10
|
||
w-8 h-8 rounded-full flex items-center justify-center
|
||
bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||
hover:border-[var(--accent)] shadow-lg
|
||
transition-all duration-200 cursor-pointer
|
||
animate-[fadeIn_150ms_ease-out]"
|
||
title="回到底部"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 12 15 18 9" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
<ChatInput onSend={handleSend} disabled={isLoading} />
|
||
</main>
|
||
|
||
{detailImage && (
|
||
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|