"use client"; import { Activity, CheckCircle2, ChevronsUpDown, ChevronLeft, DatabaseZap, GitMerge, History, LoaderCircle, MapPinned, Mic, Pause, Play, Plus, SendHorizontal, Shield, ShieldCheck, Square, Volume2 } from "lucide-react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { showMapNotice } from "@/features/map/core/components/notice"; import { Agent, AgentContent } from "@/shared/ai-elements/agent"; import { Conversation, ConversationContent, ConversationScrollButton } from "@/shared/ai-elements/conversation"; import type { PersonaState } from "@/shared/ai-elements/persona"; import { Message, MessageContent } from "@/shared/ai-elements/message"; import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input"; import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { Button } from "@/shared/ui/button"; import { cn } from "@/lib/utils"; import type { AgentChatSessionSummary } from "../api/client"; import { AgentPersona } from "./agent-persona"; import { agentPanelItemVariants, agentPanelListVariants, agentPanelSectionVariants } from "./agent-motion"; import { AgentHistoryPanel } from "./agent-history-panel"; import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details"; import { AgentOperationalBrief } from "./agent-operational-brief"; import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer"; import { StreamingTokenResponse } from "./streaming-token-response"; import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice"; import { findSpeechSelectionStartOffset, stripSpeechMarkdown, type AgentSpeakOptions, type AgentSpeechState } from "../speech"; import type { AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest, AgentStreamRenderState, AgentUiResult } from "../types"; type AgentCommandPanelProps = { collapsing?: boolean; personaState?: PersonaState; sessionTitle?: string; sessionHistory?: AgentChatSessionSummary[]; sessionHistoryLoading?: boolean; activeSessionId?: string | null; statusLabel?: string; streaming?: boolean; messages?: AgentChatMessage[]; streamRenderState?: AgentStreamRenderState; modelOptions?: AgentModelOption[]; selectedModel?: string; approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; onCollapse: () => void; onRefreshHistory?: () => Promise | void; onStartNewSession?: () => Promise | void; onLoadHistorySession?: (sessionId: string) => Promise | void; onRenameHistorySession?: (sessionId: string, title: string) => Promise | void; onDeleteHistorySession?: (sessionId: string) => Promise | void; onSelectModel?: (model: string) => void; onApprovalModeChange?: (mode: AgentApprovalMode) => void; onSubmitPrompt: (prompt: string) => Promise | void; onStopPrompt?: () => void; onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void; onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; }; const AGENT_PROMPT_SUGGESTIONS = [ "当前监测数据质量如何,是否存在缺失、失真或可信度不足的问题?", "当前监测数据中有哪些异常问题需要重点关注?", "当前管网哪些区域可能存在雨污混接,判断依据是什么?" ] as const; const AGENT_CAPABILITIES = [ { icon: DatabaseZap, label: "监测质量诊断" }, { icon: Activity, label: "异常状态研判" }, { icon: GitMerge, label: "雨污混接识别" }, { icon: MapPinned, label: "GIS 地图联动" } ] as const; export function AgentCommandPanel({ collapsing = false, personaState, sessionTitle = "新对话", sessionHistory = [], sessionHistoryLoading = false, activeSessionId, statusLabel = "Agent 后端连接中", streaming = false, messages = [], streamRenderState = {}, modelOptions = [], selectedModel = "", approvalMode = "request", uiResults = [], onCollapse, onRefreshHistory, onStartNewSession, onLoadHistorySession, onRenameHistorySession, onDeleteHistorySession, onSelectModel, onApprovalModeChange, onSubmitPrompt, onStopPrompt, onReplyPermission, onReplyQuestion, onRejectQuestion }: AgentCommandPanelProps) { const [prompt, setPrompt] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [loadingSessionId, setLoadingSessionId] = useState(null); const [scrollRequestId, setScrollRequestId] = useState(0); const trimmedPrompt = prompt.trim(); const { speechState, speakingMessageId, speak, pause: pauseSpeech, resume: resumeSpeech, stop: stopSpeech, isSupported: isSpeechSupported } = useAgentSpeechSynthesis(); const appendRecognitionResult = useCallback((text: string) => { setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`); }, []); const showRecognitionError = useCallback((message: string) => { showMapNotice({ id: "agent-speech-recognition-error", tone: "error", title: "语音输入不可用", message, duration: 5000 }); }, []); const { isListening, start: startListening, stop: stopListening, isSupported: isRecognitionSupported } = useAgentSpeechRecognition(appendRecognitionResult); useEffect(() => { if (historyOpen) { void Promise.resolve(onRefreshHistory?.()); } }, [historyOpen, onRefreshHistory]); useEffect(() => { stopSpeech(); }, [activeSessionId, stopSpeech]); useEffect(() => { if (streaming) stopListening(); }, [stopListening, streaming]); const submitPrompt = (nextPrompt: string) => { if (!nextPrompt || streaming) { return; } setPrompt(""); stopListening(); setScrollRequestId((requestId) => requestId + 1); void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => { setPrompt(nextPrompt); }); }; return ( ); } const VOICE_WAVE_BARS = [ { height: 5, duration: 0.72, delay: 0.08 }, { height: 10, duration: 0.58, delay: 0 }, { height: 15, duration: 0.66, delay: 0.12 }, { height: 9, duration: 0.62, delay: 0.04 }, { height: 4, duration: 0.7, delay: 0.16 } ] as const; function VoiceInputButton({ isListening, disabled, onClick }: { isListening: boolean; disabled: boolean; onClick: () => void; }) { const reduceMotion = useReducedMotion(); const label = isListening ? "停止语音输入" : "语音输入"; return ( {isListening ? ( ); } function ApprovalModeSelect({ value, disabled, onValueChange }: { value: AgentApprovalMode; disabled: boolean; onValueChange?: (mode: AgentApprovalMode) => void; }) { const requestApproval = value === "request"; return ( 权限批准模式 onValueChange?.("request")} > onValueChange?.("always")} > ); } type AgentConversationScrollSnapshot = { activeSessionId?: string | null; lastUserMessageId: string | null; scrollRequestId: number; }; function AgentConversationScrollManager({ activeSessionId, messages, scrollRequestId }: { activeSessionId?: string | null; messages: AgentChatMessage[]; scrollRequestId: number; }) { const { scrollToBottom } = useStickToBottomContext(); const previousSnapshotRef = useRef(null); const lastUserMessageId = getLastUserMessageId(messages); const snapshot = useMemo( () => ({ activeSessionId, lastUserMessageId, scrollRequestId }), [activeSessionId, lastUserMessageId, scrollRequestId] ); useEffect(() => { const previousSnapshot = previousSnapshotRef.current; previousSnapshotRef.current = snapshot; const forceScroll = !previousSnapshot || previousSnapshot.activeSessionId !== snapshot.activeSessionId || previousSnapshot.scrollRequestId !== snapshot.scrollRequestId || (Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId); if (!forceScroll) { return; } const animationFrameId = window.requestAnimationFrame(() => { void scrollToBottom({ animation: "instant", ignoreEscapes: true, wait: false }); }); return () => window.cancelAnimationFrame(animationFrameId); }, [scrollToBottom, snapshot]); return null; } function getLastUserMessageId(messages: AgentChatMessage[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { if (messages[index].role === "user") { return messages[index].id; } } return null; } function AgentModelSelect({ models, value, onValueChange }: { models: AgentModelOption[]; value: string; onValueChange?: (model: string) => void; }) { const selectedModel = models.find((model) => model.id === value) ?? models[0]; if (!models.length) { return ( 模型 ); } return ( 模型选择
{models.map((model) => { const selected = model.id === selectedModel?.id; return ( onValueChange?.(model.id)} > {model.label} {model.description} {selected ? ); })}
); } function getAgentConnectionLabel(statusLabel: string) { return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线"; } function getAgentConnectionClassName(statusLabel: string) { return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500"; } function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) { return model?.icon === "bolt" ? : ; } function FastModelIcon({ size }: { size: number }) { return ( ); } function ExpertModelIcon({ size }: { size: number }) { return ( ); } function AgentReadyMark() { return ( ); } function AgentEmptyState() { return (

我已就绪,请描述任务

直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。

    {AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
  • {label}
  • ))}
); } function BackendMessageList({ messages, streaming, streamRenderState, onReplyPermission, onReplyQuestion, onRejectQuestion, isSpeechSupported, speakingMessageId, speechState, onSpeak, onPauseSpeech, onResumeSpeech, onStopSpeech }: { messages: AgentChatMessage[]; streaming: boolean; streamRenderState: AgentStreamRenderState; onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void; onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; isSpeechSupported: boolean; speakingMessageId: string | null; speechState: AgentSpeechState; onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void; onPauseSpeech: () => void; onResumeSpeech: () => void; onStopSpeech: () => void; }) { return ( {messages.map((message, index) => (
{message.role === "assistant" ? ( ) : null} {message.content ? ( message.role === "assistant" ? ( ) : (

{message.content}

) ) : streaming && index === messages.length - 1 ? ( 正在生成... ) : null} {message.role === "assistant" ? ( ) : null}
))}
); } type SpeechSelection = { startOffset: number; left: number; top: number; }; function AgentSpeechMessage({ messageId, content, disabled, isSupported, speechState, onSpeak, onPause, onResume, onStop, children }: { messageId: string; content: string; disabled: boolean; isSupported: boolean; speechState: AgentSpeechState; onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void; onPause: () => void; onResume: () => void; onStop: () => void; children: ReactNode; }) { const contentRef = useRef(null); const lastSelectionRef = useRef(null); const [selection, setSelection] = useState(null); const speechText = useMemo(() => stripSpeechMarkdown(content), [content]); const selectionAnchor = selection ?? lastSelectionRef.current; if (selection) { lastSelectionRef.current = selection; } const captureSelection = useCallback(() => { if (!isSupported || disabled) { setSelection(null); return; } const browserSelection = window.getSelection(); const container = contentRef.current; if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) { setSelection(null); return; } const range = browserSelection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { setSelection(null); return; } const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString()); const rect = range.getBoundingClientRect(); if (startOffset === null || (!rect.width && !rect.height)) { setSelection(null); return; } setSelection({ startOffset, left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88), top: Math.max(rect.top - 10, 48) }); }, [disabled, isSupported, speechText]); useEffect(() => { setSelection(null); }, [messageId, speechText]); useEffect(() => { if (!selection) return; const close = () => setSelection(null); const closeCollapsedSelection = () => { if (window.getSelection()?.isCollapsed) close(); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; document.addEventListener("selectionchange", closeCollapsedSelection); document.addEventListener("keydown", closeOnEscape); window.addEventListener("resize", close); window.addEventListener("scroll", close, true); return () => { document.removeEventListener("selectionchange", closeCollapsedSelection); document.removeEventListener("keydown", closeOnEscape); window.removeEventListener("resize", close); window.removeEventListener("scroll", close, true); }; }, [selection]); const speakFromSelection = () => { if (!selection) return; void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset })); window.getSelection()?.removeAllRanges(); setSelection(null); }; return (
window.requestAnimationFrame(captureSelection)} onKeyUp={() => window.requestAnimationFrame(captureSelection)} > {children}
{selectionAnchor && typeof document !== "undefined" ? createPortal(
{selection ? ( event.preventDefault()} onClick={speakFromSelection} > 从这里开始朗读 ) : null}
, document.body ) : null} {isSupported && !disabled ? (
{speechState === "idle" ? ( void Promise.resolve(onSpeak(messageId, speechText))}> ) : null} {speechState === "loading" ? ( <> ) : null} {speechState === "playing" ? ( <> ) : null} {speechState === "paused" ? ( <> ) : null} {speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
) : null}
); } function SpeechIconButton({ label, tone = "default", disabled = false, onClick, children }: { label: string; tone?: "default" | "primary" | "danger"; disabled?: boolean; onClick?: () => void; children: ReactNode; }) { return ( ); }