支持画布内多聊天窗口与聊天记录切换
This commit is contained in:
parent
db8893b799
commit
6615079106
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user