支持画布内多聊天窗口与聊天记录切换
This commit is contained in:
parent
db8893b799
commit
6615079106
@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
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 { createPortal } from "react-dom";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import useSWR from "swr";
|
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 { 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";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -194,16 +193,48 @@ interface CanvasImageAttachment {
|
|||||||
contentType: string;
|
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) {
|
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
|
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const hiddenPromptRef = useRef<string | null>(null);
|
const hiddenPromptRef = useRef<string | null>(null);
|
||||||
const autoContextRef = useRef<string | null>(null);
|
const autoContextRef = useRef<string | null>(null);
|
||||||
|
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 🆕 标签悬停预览状态
|
// 🆕 标签悬停预览状态
|
||||||
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
|
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
|
||||||
@ -254,6 +285,67 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
return () => window.removeEventListener("keydown", handleEsc);
|
return () => window.removeEventListener("keydown", handleEsc);
|
||||||
}, [fullscreenPreview]);
|
}, [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
|
// Canvas store
|
||||||
const addNode = useCanvasStore((state) => state.addNode);
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
@ -267,12 +359,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
|
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
|
||||||
|
|
||||||
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
|
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
|
||||||
const rawChatId = chatId.startsWith('canvas-')
|
const rawChatId = toRawCanvasChatId(activeChatId);
|
||||||
? chatId.replace('canvas-', '')
|
|
||||||
: chatId;
|
|
||||||
|
|
||||||
// 从 API 加载历史消息
|
// 从 API 加载历史消息
|
||||||
const { data: historyData, isLoading: isLoadingHistory } = useSWR<{
|
const { data: historyData } = useSWR<{
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
|
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
|
||||||
|
|
||||||
@ -603,9 +693,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
}, [imageParams]);
|
}, [imageParams]);
|
||||||
|
|
||||||
// 合并 initialMessages 和历史消息
|
// 合并 initialMessages 和历史消息
|
||||||
const mergedInitialMessages = historyData?.messages?.length
|
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
|
||||||
? historyData.messages
|
|
||||||
: initialMessages;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@ -614,7 +702,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
status,
|
status,
|
||||||
stop,
|
stop,
|
||||||
} = useChat<ChatMessage>({
|
} = useChat<ChatMessage>({
|
||||||
id: chatId,
|
id: activeChatId,
|
||||||
messages: mergedInitialMessages,
|
messages: mergedInitialMessages,
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
@ -689,10 +777,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
// 当历史消息加载完成后,更新消息列表
|
// 当历史消息加载完成后,更新消息列表
|
||||||
const hasLoadedHistory = useRef(false);
|
const hasLoadedHistory = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historyData?.messages?.length && !hasLoadedHistory.current) {
|
if (historyData && !hasLoadedHistory.current) {
|
||||||
hasLoadedHistory.current = true;
|
hasLoadedHistory.current = true;
|
||||||
setMessages(historyData.messages);
|
setMessages(historyData.messages || []);
|
||||||
console.log('[CanvasChat] 已加载历史消息:', historyData.messages.length);
|
console.log('[CanvasChat] 已加载历史消息:', historyData.messages?.length || 0);
|
||||||
}
|
}
|
||||||
}, [historyData, setMessages]);
|
}, [historyData, setMessages]);
|
||||||
|
|
||||||
@ -700,6 +788,63 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
// 只处理新生成的图片,不处理历史消息中的图片
|
// 只处理新生成的图片,不处理历史消息中的图片
|
||||||
const processedMessageIds = useRef<Set<string>>(new Set());
|
const processedMessageIds = useRef<Set<string>>(new Set());
|
||||||
const isInitialLoad = useRef(true);
|
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(() => {
|
useEffect(() => {
|
||||||
// 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布
|
// 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布
|
||||||
@ -1279,6 +1424,32 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
|
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
|
||||||
const loadingImageCount = imageParams.imageCount || 1;
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background/50 backdrop-blur-sm relative">
|
<div className="flex flex-col h-full bg-background/50 backdrop-blur-sm relative">
|
||||||
{/* Header - Minimal with Icons */}
|
{/* Header - Minimal with Icons */}
|
||||||
@ -1288,21 +1459,63 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-full hover:bg-muted"
|
className="h-8 w-8 rounded-full hover:bg-muted"
|
||||||
onClick={() => {
|
onClick={createNewChatSession}
|
||||||
// 创建新画布并导航
|
title="新建聊天窗口"
|
||||||
const newId = crypto.randomUUID();
|
|
||||||
const targetUrl = `/canvas/${newId}`;
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.assign(targetUrl);
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
title="新建画布"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
<div className="relative" ref={sessionMenuRef}>
|
||||||
<History className="h-4 w-4" />
|
<Button
|
||||||
</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">
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user