支持画布内多聊天窗口与聊天记录切换

This commit is contained in:
Your Name 2026-02-09 01:06:27 +08:00
parent db8893b799
commit 6615079106

View File

@ -2,9 +2,8 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, Share2, LayoutGrid, Maximize2, RefreshCw, Zap, Globe, Box, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
@ -194,16 +193,48 @@ interface CanvasImageAttachment {
contentType: string;
}
interface CanvasChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
const SESSION_TITLE_MAX_LEN = 18;
function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
function getSessionStorageKey(canvasId: string): string {
return `canvas-chat-sessions:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
if (!prompt) return "";
return prompt.length > SESSION_TITLE_MAX_LEN
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
: prompt;
}
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
const router = useRouter();
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]);
const [activeChatId, setActiveChatId] = useState<string>(chatId);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const sessionMenuRef = useRef<HTMLDivElement>(null);
// 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
@ -254,6 +285,67 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return () => window.removeEventListener("keydown", handleEsc);
}, [fullscreenPreview]);
useEffect(() => {
const now = Date.now();
const fallbackSession: CanvasChatSession = {
id: chatId,
title: "对话 1",
createdAt: now,
updatedAt: now,
};
try {
const key = getSessionStorageKey(canvasId);
const saved = localStorage.getItem(key);
const parsed = saved ? (JSON.parse(saved) as CanvasChatSession[]) : [];
const sanitized = parsed
.filter((session) => session?.id)
.map((session) => ({
id: session.id,
title: (session.title || "").trim() || "新对话",
createdAt: Number(session.createdAt) || now,
updatedAt: Number(session.updatedAt) || now,
}));
const hasCurrent = sanitized.some((session) => session.id === chatId);
const nextSessions = hasCurrent
? sanitized
: [fallbackSession, ...sanitized];
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
setChatSessions(sorted);
setActiveChatId(chatId);
localStorage.setItem(key, JSON.stringify(sorted));
} catch (error) {
console.error("[CanvasChat] Failed to load chat sessions:", error);
setChatSessions([fallbackSession]);
setActiveChatId(chatId);
}
}, [canvasId, chatId]);
useEffect(() => {
if (!isSessionMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!sessionMenuRef.current?.contains(event.target as Node)) {
setIsSessionMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isSessionMenuOpen]);
useEffect(() => {
try {
localStorage.setItem(
getSessionStorageKey(canvasId),
JSON.stringify(chatSessions)
);
} catch (error) {
console.error("[CanvasChat] Failed to save chat sessions:", error);
}
}, [canvasId, chatSessions]);
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const updateNode = useCanvasStore((state) => state.updateNode);
@ -267,12 +359,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
const rawChatId = chatId.startsWith('canvas-')
? chatId.replace('canvas-', '')
: chatId;
const rawChatId = toRawCanvasChatId(activeChatId);
// 从 API 加载历史消息
const { data: historyData, isLoading: isLoadingHistory } = useSWR<{
const { data: historyData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
@ -603,9 +693,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [imageParams]);
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages?.length
? historyData.messages
: initialMessages;
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const {
messages,
@ -614,7 +702,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
status,
stop,
} = useChat<ChatMessage>({
id: chatId,
id: activeChatId,
messages: mergedInitialMessages,
experimental_throttle: 100,
generateId: generateUUID,
@ -689,10 +777,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 当历史消息加载完成后,更新消息列表
const hasLoadedHistory = useRef(false);
useEffect(() => {
if (historyData?.messages?.length && !hasLoadedHistory.current) {
if (historyData && !hasLoadedHistory.current) {
hasLoadedHistory.current = true;
setMessages(historyData.messages);
console.log('[CanvasChat] 已加载历史消息:', historyData.messages.length);
setMessages(historyData.messages || []);
console.log('[CanvasChat] 已加载历史消息:', historyData.messages?.length || 0);
}
}, [historyData, setMessages]);
@ -700,6 +788,63 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 只处理新生成的图片,不处理历史消息中的图片
const processedMessageIds = useRef<Set<string>>(new Set());
const isInitialLoad = useRef(true);
useEffect(() => {
hasLoadedHistory.current = false;
processedMessageIds.current.clear();
isInitialLoad.current = true;
hiddenPromptRef.current = null;
autoContextRef.current = null;
setMessages([]);
setInput("");
setAttachments([]);
setIsSessionMenuOpen(false);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [activeChatId, setMessages]);
useEffect(() => {
if (!activeChatId) return;
const derivedTitle = deriveSessionTitleFromMessages(messages);
const now = Date.now();
setChatSessions((prevSessions) => {
const currentIndex = prevSessions.findIndex(
(session) => session.id === activeChatId
);
const fallbackTitle = `对话 ${prevSessions.length + 1}`;
if (currentIndex < 0) {
const created: CanvasChatSession = {
id: activeChatId,
title: derivedTitle || fallbackTitle,
createdAt: now,
updatedAt: now,
};
return [created, ...prevSessions];
}
const current = prevSessions[currentIndex];
const nextTitle = derivedTitle || current.title || fallbackTitle;
const shouldUpdate =
current.title !== nextTitle || current.updatedAt !== now;
if (!shouldUpdate) return prevSessions;
const nextSessions = [...prevSessions];
nextSessions[currentIndex] = {
...current,
title: nextTitle,
updatedAt: now,
};
nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
return nextSessions;
});
}, [activeChatId, messages]);
useEffect(() => {
// 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布
@ -1279,6 +1424,32 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
const loadingImageCount = imageParams.imageCount || 1;
const createNewChatSession = useCallback(() => {
const now = Date.now();
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
const fallbackTitle = `对话 ${chatSessions.length + 1}`;
const newSession: CanvasChatSession = {
id: newChatSessionId,
title: fallbackTitle,
createdAt: now,
updatedAt: now,
};
setChatSessions((prevSessions) => [newSession, ...prevSessions]);
setActiveChatId(newChatSessionId);
toast.success("已新建聊天窗口");
}, [chatSessions.length]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
setIsSessionMenuOpen(false);
return;
}
setActiveChatId(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId]);
return (
<div className="flex flex-col h-full bg-background/50 backdrop-blur-sm relative">
{/* Header - Minimal with Icons */}
@ -1288,21 +1459,63 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted"
onClick={() => {
// 创建新画布并导航
const newId = crypto.randomUUID();
const targetUrl = `/canvas/${newId}`;
setTimeout(() => {
window.location.assign(targetUrl);
}, 100);
}}
title="新建画布"
onClick={createNewChatSession}
title="新建聊天窗口"
>
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<History className="h-4 w-4" />
</Button>
<div className="relative" ref={sessionMenuRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full hover:bg-muted",
isSessionMenuOpen && "bg-muted"
)}
onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录"
>
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-background/95 backdrop-blur-md shadow-xl p-2">
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{chatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
chatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={session.id}
type="button"
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
onClick={() => switchToSession(session.id)}
>
<div className="text-sm font-medium truncate">
{session.title || `对话 ${index + 1}`}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
最近更新 {timeLabel}
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<Share2 className="h-4 w-4" />
</Button>