From 66150791069ef18e934639f4bf7a3b145f4c9f9e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 9 Feb 2026 01:06:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=BB=E5=B8=83=E5=86=85?= =?UTF-8?q?=E5=A4=9A=E8=81=8A=E5=A4=A9=E7=AA=97=E5=8F=A3=E4=B8=8E=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=AE=B0=E5=BD=95=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/canvas/CanvasChatPanel.tsx | 265 +++++++++++++++++++++++--- 1 file changed, 239 insertions(+), 26 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index d589823..1c062bc 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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([]); const [selectedModelId, setSelectedModelId] = useState(""); + const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]); + const [activeChatId, setActiveChatId] = useState(chatId); + const [chatSessions, setChatSessions] = useState([]); + const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); + const sessionMenuRef = useRef(null); // 🆕 标签悬停预览状态 const [tagPreview, setTagPreview] = useState(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({ - 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>(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 (
{/* 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="新建聊天窗口" > - +
+ + {isSessionMenuOpen && ( +
+
聊天窗口记录
+
+ {chatSessions.length === 0 ? ( +
暂无记录
+ ) : ( + chatSessions.map((session, index) => { + const isActive = session.id === activeChatId; + const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + return ( + + ); + }) + )} +
+
+ )} +