"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import MarkdownMessage from "@/components/MarkdownMessage"; import { sendChatMessage } from "@/lib/api"; import { parseJsonSafely, splitSseEvents, type TencentErrorEvent, type TencentReplyEvent, } from "@/lib/tencentSse"; import type { ChatMessage } from "@/types/mindmap"; type TriggerRequest = { id: number; content: string; }; type ChatPanelProps = { sessionId: string; triggerRequest?: TriggerRequest; onClose?: () => void; }; const TYPING_INTERVAL_MS = 24; export default function ChatPanel({ sessionId, triggerRequest, onClose, }: ChatPanelProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const messagesEndRef = useRef(null); const typingTimerRef = useRef(null); const pendingCleanupTimerRef = useRef(null); const abortControllerRef = useRef(null); const assistantTargetRef = useRef(""); const assistantDisplayedRef = useRef(""); const streamCompleteRef = useRef(false); const handledTriggerIdRef = useRef(null); const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: loading ? "auto" : "smooth", block: "end", }); }, [loading]); useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); const cancelPendingCleanup = useCallback(() => { if (pendingCleanupTimerRef.current !== null) { window.clearTimeout(pendingCleanupTimerRef.current); pendingCleanupTimerRef.current = null; } }, []); const abortInFlightRequest = useCallback(() => { abortControllerRef.current?.abort(); abortControllerRef.current = null; }, []); const updateAssistantMessage = useCallback((content: string) => { setMessages((prev) => { const next = [...prev]; const lastMessage = next[next.length - 1]; if (!lastMessage || lastMessage.role !== "assistant") { next.push({ role: "assistant", content }); return next; } next[next.length - 1] = { ...lastMessage, content, }; return next; }); }, []); const stopTypingAnimation = useCallback(() => { if (typingTimerRef.current !== null) { window.clearInterval(typingTimerRef.current); typingTimerRef.current = null; } }, []); const getTypingStep = useCallback((remaining: number) => { if (remaining > 160) return 10; if (remaining > 80) return 7; if (remaining > 36) return 4; if (remaining > 12) return 2; return 1; }, []); const ensureTypingAnimation = useCallback(() => { if (typingTimerRef.current !== null) { return; } typingTimerRef.current = window.setInterval(() => { const current = assistantDisplayedRef.current; const target = assistantTargetRef.current; if (current === target) { if (streamCompleteRef.current) { stopTypingAnimation(); } return; } const remaining = target.length - current.length; const nextLength = current.length + getTypingStep(remaining); const nextContent = target.slice(0, nextLength); assistantDisplayedRef.current = nextContent; updateAssistantMessage(nextContent); if (nextContent === target && streamCompleteRef.current) { stopTypingAnimation(); } }, TYPING_INTERVAL_MS); }, [getTypingStep, stopTypingAnimation, updateAssistantMessage]); useEffect(() => { cancelPendingCleanup(); return () => { pendingCleanupTimerRef.current = window.setTimeout(() => { abortInFlightRequest(); stopTypingAnimation(); pendingCleanupTimerRef.current = null; }, 0); }; }, [abortInFlightRequest, cancelPendingCleanup, stopTypingAnimation]); const resetStreamingState = useCallback(() => { cancelPendingCleanup(); abortInFlightRequest(); stopTypingAnimation(); assistantTargetRef.current = ""; assistantDisplayedRef.current = ""; streamCompleteRef.current = false; }, [abortInFlightRequest, cancelPendingCleanup, stopTypingAnimation]); const mergeIncomingContent = useCallback((currentTarget: string, incomingContent: string) => { if (!currentTarget) { return incomingContent; } if (!incomingContent) { return currentTarget; } if (incomingContent.startsWith(currentTarget)) { return incomingContent; } if (currentTarget.startsWith(incomingContent)) { return currentTarget; } const maxOverlap = Math.min(currentTarget.length, incomingContent.length); for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { if (currentTarget.slice(-overlap) === incomingContent.slice(0, overlap)) { return `${currentTarget}${incomingContent.slice(overlap)}`; } } return incomingContent.length > currentTarget.length ? incomingContent : currentTarget; }, []); const setAssistantTarget = useCallback( (incomingContent: string) => { assistantTargetRef.current = mergeIncomingContent( assistantTargetRef.current, incomingContent, ); ensureTypingAnimation(); }, [ensureTypingAnimation, mergeIncomingContent], ); const processStreamResponse = useCallback( async (response: Response) => { const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder("utf-8"); let buffer = ""; const handleReplyEvent = (event: TencentReplyEvent | null) => { const payload = event?.payload; if (!payload || payload.is_from_self || typeof payload.content !== "string") { return; } setAssistantTarget(payload.content); if (payload.is_final) { streamCompleteRef.current = true; ensureTypingAnimation(); } }; const handleErrorEvent = (event: TencentErrorEvent | null) => { const errorMessage = event?.error?.message ?? event?.message ?? "Unknown streaming error"; assistantTargetRef.current = `错误: ${errorMessage}`; assistantDisplayedRef.current = assistantTargetRef.current; streamCompleteRef.current = true; updateAssistantMessage(assistantTargetRef.current); stopTypingAnimation(); }; const handleSseEvent = (eventName: string, data: string) => { const normalizedEventName = parseJsonSafely<{ type?: string }>(data)?.type ?? eventName; if (normalizedEventName === "reply") { handleReplyEvent(parseJsonSafely(data)); return; } if (normalizedEventName === "error") { handleErrorEvent(parseJsonSafely(data)); } }; while (true) { const { done, value } = await reader.read(); if (done) { buffer += decoder.decode(); } else { buffer += decoder.decode(value, { stream: true }); } const { events, rest } = splitSseEvents(buffer); buffer = rest; for (const event of events) { handleSseEvent(event.event, event.data); } if (done) { if (buffer.trim()) { const { events: tailEvents } = splitSseEvents(`${buffer}\n\n`); for (const event of tailEvents) { handleSseEvent(event.event, event.data); } } streamCompleteRef.current = true; ensureTypingAnimation(); break; } } }, [ensureTypingAnimation, setAssistantTarget, stopTypingAnimation, updateAssistantMessage], ); const sendMessage = useCallback( async (content: string) => { if (!content.trim() || loading) return; resetStreamingState(); const abortController = new AbortController(); abortControllerRef.current = abortController; const userMessage: ChatMessage = { role: "user", content }; const assistantMessage: ChatMessage = { role: "assistant", content: "" }; setMessages((prev) => [...prev, userMessage, assistantMessage]); setInput(""); setLoading(true); try { const response = await sendChatMessage( sessionId, content, "default_visitor", abortController.signal, ); await processStreamResponse(response); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { return; } const errorMessage = err instanceof Error ? err.message : "请求失败"; assistantTargetRef.current = `错误: ${errorMessage}`; assistantDisplayedRef.current = assistantTargetRef.current; streamCompleteRef.current = true; updateAssistantMessage(assistantTargetRef.current); stopTypingAnimation(); } finally { if (abortControllerRef.current === abortController) { abortControllerRef.current = null; } setLoading(false); } }, [loading, processStreamResponse, resetStreamingState, sessionId, stopTypingAnimation, updateAssistantMessage], ); useEffect(() => { if (!triggerRequest) { return; } if (handledTriggerIdRef.current === triggerRequest.id) { return; } handledTriggerIdRef.current = triggerRequest.id; void sendMessage(triggerRequest.content); }, [triggerRequest, sendMessage]); const handleSend = useCallback(async () => { await sendMessage(input); }, [input, sendMessage]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, [handleSend], ); return (

AI 对话

{onClose ? ( ) : null}
{messages.length === 0 && (

双击思维导图节点开始对话

)} {messages.map((msg, idx) => (
{msg.role === "assistant" ? ( msg.content || (loading && idx === messages.length - 1 ? "思考中..." : "") ? ( ) : null ) : (
{msg.content}
)}
))}