"use client"; import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; // MUI import { Avatar, Box, Drawer, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Paper, Stack, TextField, Typography, useTheme, alpha, } from "@mui/material"; // Icons import CloseRounded from "@mui/icons-material/CloseRounded"; import SendRounded from "@mui/icons-material/SendRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; import MicRounded from "@mui/icons-material/MicRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; import { useChatToolStore, type ChatToolAction, } from "@/store/chatToolStore"; import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types"; import { CHAT_STORAGE_KEY, PRESET_PROMPTS, createId, getInitialChatState, normalizeThoughtTagToken, } from "./GlobalChatbox.utils"; import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts"; import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"; export const GlobalChatbox: React.FC = ({ open, onClose }) => { const initialChatStateRef = useRef(null); if (initialChatStateRef.current === null) { initialChatStateRef.current = getInitialChatState(); } const [messages, setMessages] = useState(initialChatStateRef.current.messages); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [width, setWidth] = useState(480); const [isResizing, setIsResizing] = useState(false); const [conversationId, setConversationId] = useState( initialChatStateRef.current.conversationId ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState(null); const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); // SSE tool_call → inline chart data (keyed by assistantMessageId) const [sseCharts, setSseCharts] = useState< Record }>> >({}); const dispatchToolAction = useChatToolStore((s) => s.dispatch); const abortRef = useRef(null); const bottomRef = useRef(null); const inputRef = useRef(null); const theme = useTheme(); // --- Voice Features --- const { speechState, speakingMessageId, speak: handleSpeak, pause: handlePauseSpeech, resume: handleResumeSpeech, stop: handleStopSpeech, isSupported: isTtsSupported, } = useSpeechSynthesis(); const handleSpeechResult = useCallback((text: string) => { setInput((prev) => prev + text); }, []); const { isListening, start: startListening, stop: stopListening, isSupported: isSttSupported, } = useSpeechRecognition(handleSpeechResult); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); const isHeaderMenuOpen = Boolean(headerMenuAnchorEl); // Auto-scroll useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isStreaming]); useEffect(() => { if (!open) return; const timer = window.setTimeout(() => { inputRef.current?.focus(); bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, 0); return () => window.clearTimeout(timer); }, [open]); useEffect(() => { const state: PersistedChatState = { messages, conversationId }; try { window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); } catch (error) { console.error("[GlobalChatbox] Failed to persist chat state:", error); } }, [messages, conversationId]); const sendPrompt = useCallback( async (rawPrompt: string) => { const prompt = rawPrompt.trim(); if (!prompt || isStreaming) return; stopListening(); const userId = createId(); const assistantId = createId(); setInput(""); setIsStreaming(true); setMessages((prev) => [ ...prev, { id: userId, role: "user", content: prompt }, { id: assistantId, role: "assistant", content: "" }, ]); const controller = new AbortController(); abortRef.current = controller; // Track SSE tool_call hashes to deduplicate against text-parsed tool_calls const sseToolHashes = new Set(); const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => { const { tool, params } = event; const hash = `${tool}:${JSON.stringify(params)}`; sseToolHashes.add(hash); const startTime = (params.start_time as string | undefined) ?? (params.startTime as string | undefined) ?? (params.from as string | undefined) ?? (params.start as string | undefined); const endTime = (params.end_time as string | undefined) ?? (params.endTime as string | undefined) ?? (params.to as string | undefined) ?? (params.end as string | undefined); const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { const normalizedFeatureInfos = rawFeatureInfos .map((item) => (Array.isArray(item) ? item : null)) .filter((item): item is [unknown, unknown] => Boolean(item)) .map( (item) => [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ string, string, ], ) .filter(([id]) => id.trim().length > 0); if (normalizedFeatureInfos.length > 0) { return normalizedFeatureInfos; } } const rawDeviceIds = params.device_ids ?? params.deviceId ?? params.device_id ?? params.id ?? params.ids; const deviceIds = Array.isArray(rawDeviceIds) ? rawDeviceIds.map((id) => String(id)) : typeof rawDeviceIds === "string" ? rawDeviceIds .split(",") .map((id) => id.trim()) .filter(Boolean) : []; return deviceIds.map((id) => [id, "scada"]); }; // show_chart → store as inline chart for rendering if (tool === "show_chart") { setSseCharts((prev) => ({ ...prev, [assistantId]: [ ...(prev[assistantId] ?? []), { tool, params }, ], })); return; } // Other frontend tools → dispatch to chatToolStore immediately const normalizeIds = (): string[] => { const rawIds = params.ids; if (Array.isArray(rawIds)) { return rawIds .map((id) => String(id).trim()) .filter(Boolean); } if (typeof rawIds === "string") { return rawIds .split(",") .map((id) => id.trim()) .filter(Boolean); } return []; }; const buildLocateFeaturesAction = ( layer: string, geometryKind: "point" | "line", ): ChatToolAction => ({ type: "locate_features" as const, ids: normalizeIds(), layer, geometryKind, }); const buildLocateByFeatureType = (): ChatToolAction | null => { const rawType = params.feature_type; const featureType = typeof rawType === "string" ? rawType.trim().toLowerCase() : ""; const featureTypeMap: Record< string, { layer: string; geometryKind: "point" | "line" } > = { junction: { layer: "geo_junctions_mat", geometryKind: "point" }, junctions: { layer: "geo_junctions_mat", geometryKind: "point" }, pipe: { layer: "geo_pipes_mat", geometryKind: "line" }, pipes: { layer: "geo_pipes_mat", geometryKind: "line" }, valve: { layer: "geo_valves", geometryKind: "point" }, valves: { layer: "geo_valves", geometryKind: "point" }, reservoir: { layer: "geo_reservoirs", geometryKind: "point" }, reservoirs: { layer: "geo_reservoirs", geometryKind: "point" }, pump: { layer: "geo_pumps", geometryKind: "point" }, pumps: { layer: "geo_pumps", geometryKind: "point" }, tank: { layer: "geo_tanks", geometryKind: "point" }, tanks: { layer: "geo_tanks", geometryKind: "point" }, }; const config = featureTypeMap[featureType]; if (!config) return null; return buildLocateFeaturesAction(config.layer, config.geometryKind); }; const actionMap: Record ChatToolAction | null> = { locate_features: buildLocateByFeatureType, locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"), locate_junctions: () => buildLocateFeaturesAction("geo_junctions_mat", "point"), locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"), locate_reservoirs: () => buildLocateFeaturesAction("geo_reservoirs", "point"), locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"), locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"), view_history: () => ({ type: "view_history" as const, featureInfos: (params.feature_infos as [string, string][]) ?? [], dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime", startTime, endTime, }), view_scada: () => ({ type: "view_scada" as const, featureInfos: resolveScadaFeatureInfos(), startTime, endTime, }), }; const buildAction = actionMap[tool]; if (buildAction) { const action = buildAction(); if (action) dispatchToolAction(action); } }; try { await streamCopilotChat({ message: prompt, conversationId, signal: controller.signal, onEvent: (event) => { if (event.type === "token") { if (!conversationId && event.conversationId) setConversationId(event.conversationId); const normalizedToken = normalizeThoughtTagToken(event.content); setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content + normalizedToken, isError: false } : m ) ); } else if (event.type === "done") { if (!conversationId && event.conversationId) setConversationId(event.conversationId); setMessages((prev) => prev.map((m) => m.id === assistantId && m.content.trim().length === 0 ? { ...m, content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。", isError: true, } : m ) ); setIsStreaming(false); } else if (event.type === "error") { setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content || `⚠️ **错误:** ${event.message}`, isError: true, } : m ) ); setIsStreaming(false); } else if (event.type === "tool_call") { handleSseToolCall(event); } }, }); } catch (error) { if (abortRef.current?.signal.aborted) { setMessages((prev) => prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0)) ); return; } setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true } : m ) ); setIsStreaming(false); } finally { abortRef.current = null; setIsStreaming(false); } }, [conversationId, isStreaming, stopListening, dispatchToolAction], ); const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; await sendPrompt(prompt); }; const handleAbort = () => { abortRef.current?.abort(); setIsStreaming(false); }; const handlePresetPromptSelect = useCallback((prompt: string) => { setInput(prompt); setIsPresetPanelOpen(false); window.setTimeout(() => { inputRef.current?.focus(); }, 0); }, []); const handleHeaderMenuOpen = useCallback( (event: React.MouseEvent) => { setHeaderMenuAnchorEl(event.currentTarget); }, [], ); const handleHeaderMenuClose = useCallback(() => { setHeaderMenuAnchorEl(null); }, []); const handleNewConversation = useCallback(() => { abortRef.current?.abort(); handleStopSpeech(); stopListening(); setMessages([]); setConversationId(undefined); setInput(""); setIsStreaming(false); handleHeaderMenuClose(); window.setTimeout(() => { inputRef.current?.focus(); }, 0); }, [handleHeaderMenuClose, handleStopSpeech, stopListening]); const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); }, []); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; const newWidth = window.innerWidth - e.clientX; if (newWidth > 320 && newWidth < 1200) { setWidth(newWidth); } }; const handleMouseUp = () => { setIsResizing(false); }; if (isResizing) { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); } return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing]); const renderedMessages = useMemo( () => messages.map((message) => ( )), [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts], ); return ( muiTheme.zIndex.modal + 100 }} PaperProps={{ sx: { width: { xs: "100%", sm: width }, background: "transparent", boxShadow: "none", overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside. zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize }, }} > {/* Resize Handle */} {/* Ambient Blobs */} {/* Header - Transparent & Floating */} Copilot 你的 AI 助手 {/* Messages - Bouncy List */} {messages.length === 0 && ( 你好呀!👋 我已准备好为你提供帮助,尽管问我吧! )} {renderedMessages} {isStreaming && ( )}
{/* Input Area - Floating Capsule */} {isPresetPanelOpen && ( {PRESET_PROMPTS.map((prompt, index) => ( handlePresetPromptSelect(prompt)} sx={{ textAlign: "left", width: "100%", px: 1.1, py: 0.9, borderRadius: 2, border: `1px solid ${alpha(theme.palette.divider, 0.24)}`, bgcolor: alpha("#fff", 0.72), color: "text.secondary", fontSize: "0.84rem", lineHeight: 1.45, cursor: "pointer", transition: "all 0.18s ease", "&:hover": { borderColor: alpha(theme.palette.primary.main, 0.45), color: "text.primary", transform: "translateY(-1px)", boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`, }, }} > {prompt} ))} )} 常用功能 setIsPresetPanelOpen((prev) => !prev)} aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"} sx={{ color: "text.secondary" }} > {isPresetPanelOpen ? : } setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }} placeholder="输入消息给 Copilot..." fullWidth multiline maxRows={3} variant="standard" InputProps={{ disableUnderline: true, sx: { px: 2.5, py: 1.5, fontSize: "1rem" }, }} /> {isSttSupported && ( {isListening ? ( ) : ( )} )} {isStreaming ? ( ) : ( void handleSend()} sx={{ bgcolor: canSend ? "primary.main" : "action.disabledBackground", color: "#fff", width: 44, height: 44, transition: "background-color 0.2s", "&:hover": { bgcolor: "primary.dark", boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}` } }} > )} ); };