1531 lines
55 KiB
TypeScript
1531 lines
55 KiB
TypeScript
import {
|
||
Activity,
|
||
CheckCircle2,
|
||
Clock3,
|
||
ChevronsUpDown,
|
||
ChevronLeft,
|
||
DatabaseZap,
|
||
History,
|
||
LoaderCircle,
|
||
MapPinned,
|
||
Mic,
|
||
Pause,
|
||
Play,
|
||
Plus,
|
||
SendHorizontal,
|
||
Shield,
|
||
ShieldCheck,
|
||
Square,
|
||
Volume2,
|
||
X
|
||
} from "lucide-react";
|
||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||
import { useCallback, useEffect, useId, 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";
|
||
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 "@/shared/ui/cn";
|
||
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
|
||
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,
|
||
AgentConnectionStatus,
|
||
AgentModelOption,
|
||
AgentPermissionReply,
|
||
AgentPermissionRequest,
|
||
AgentQuestionRequest,
|
||
AgentStreamRenderState,
|
||
AgentUiResult
|
||
} from "../types";
|
||
|
||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||
|
||
export type AgentWorkspaceContextItem = {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
tone?: "neutral" | "info" | "warning";
|
||
};
|
||
|
||
type AgentCommandPanelProps = {
|
||
presentation?: AgentCommandPanelPresentation;
|
||
collapsing?: boolean;
|
||
personaState?: PersonaState;
|
||
sessionTitle?: string;
|
||
sessionHistory?: AgentChatSessionSummary[];
|
||
sessionHistoryLoading?: boolean;
|
||
activeSessionId?: string | null;
|
||
statusLabel?: string;
|
||
connectionStatus?: AgentConnectionStatus;
|
||
streaming?: boolean;
|
||
messages?: AgentChatMessage[];
|
||
streamRenderState?: AgentStreamRenderState;
|
||
modelOptions?: AgentModelOption[];
|
||
selectedModel?: string;
|
||
approvalMode?: AgentApprovalMode;
|
||
uiResults?: AgentUiResult[];
|
||
workspaceContext?: AgentWorkspaceContextItem[];
|
||
onCollapse: () => void;
|
||
onRefreshHistory?: () => Promise<void> | void;
|
||
onStartNewSession?: () => Promise<void> | void;
|
||
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
|
||
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
|
||
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
|
||
onSelectModel?: (model: string) => void;
|
||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||
onStopPrompt?: () => void;
|
||
onReplyPermission?: (
|
||
permission: AgentPermissionRequest,
|
||
reply: AgentPermissionReply
|
||
) => Promise<void> | void;
|
||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||
};
|
||
|
||
const AGENT_PROMPT_SUGGESTIONS = [
|
||
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
||
"供水服务分区分析。",
|
||
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
||
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
||
"排查当前管网爆管风险,并说明优先处置建议。"
|
||
] as const;
|
||
|
||
const AGENT_CAPABILITIES = [
|
||
{ icon: DatabaseZap, label: "水力瓶颈诊断" },
|
||
{ icon: Activity, label: "压力异常研判" },
|
||
{ icon: ShieldCheck, label: "爆管风险排查" },
|
||
{ icon: MapPinned, label: "GIS 地图联动" }
|
||
] as const;
|
||
|
||
export function AgentCommandPanel({
|
||
presentation = "desktop-dock",
|
||
collapsing = false,
|
||
personaState,
|
||
sessionTitle = "新对话",
|
||
sessionHistory = [],
|
||
sessionHistoryLoading = false,
|
||
activeSessionId,
|
||
statusLabel = "Agent 后端连接中",
|
||
connectionStatus = "connecting",
|
||
streaming = false,
|
||
messages = [],
|
||
streamRenderState = {},
|
||
modelOptions = [],
|
||
selectedModel = "",
|
||
approvalMode = "request",
|
||
uiResults = [],
|
||
workspaceContext = [],
|
||
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<string | null>(null);
|
||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||
const historyPanelId = useId();
|
||
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
const historyPanelRef = useRef<HTMLDivElement | null>(null);
|
||
const desktopFloating = presentation === "desktop-floating";
|
||
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
|
||
const hasConversation = messages.length > 0 || streaming;
|
||
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(() => {
|
||
if (!historyOpen) {
|
||
return;
|
||
}
|
||
|
||
const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
|
||
const target = event.target;
|
||
if (!(target instanceof Node)) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
historyPanelRef.current?.contains(target) ||
|
||
historyTriggerRef.current?.contains(target)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setHistoryOpen(false);
|
||
};
|
||
const closeHistoryOnEscape = (event: KeyboardEvent) => {
|
||
if (event.key !== "Escape") {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setHistoryOpen(false);
|
||
window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
|
||
};
|
||
|
||
document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||
document.addEventListener("keydown", closeHistoryOnEscape);
|
||
|
||
return () => {
|
||
document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
|
||
document.removeEventListener("keydown", closeHistoryOnEscape);
|
||
};
|
||
}, [historyOpen]);
|
||
|
||
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);
|
||
});
|
||
};
|
||
const historyPanel = (
|
||
<AgentHistoryPanel
|
||
activeSessionId={activeSessionId}
|
||
loadingSessionId={loadingSessionId}
|
||
loading={sessionHistoryLoading}
|
||
sessions={sessionHistory}
|
||
onRefresh={onRefreshHistory}
|
||
onSelectSession={(nextSessionId) => {
|
||
setLoadingSessionId(nextSessionId);
|
||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||
.then(() => setHistoryOpen(false))
|
||
.finally(() => setLoadingSessionId(null));
|
||
}}
|
||
onRenameSession={onRenameHistorySession}
|
||
onDeleteSession={onDeleteHistorySession}
|
||
/>
|
||
);
|
||
const operationalBrief = (
|
||
<AgentOperationalBrief
|
||
messages={messages}
|
||
statusLabel={statusLabel}
|
||
streaming={streaming}
|
||
onSubmitCommand={submitPrompt}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<MotionConfig reducedMotion="user">
|
||
<aside
|
||
aria-label="Agent 命令面板"
|
||
className={cn(
|
||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||
"agent-panel-shell",
|
||
presentation === "desktop-dock"
|
||
? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
|
||
: desktopFloating
|
||
? "acrylic-panel overflow-visible rounded-2xl border"
|
||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||
)}
|
||
>
|
||
<Agent
|
||
className={cn(
|
||
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
|
||
floatingPresentation && "gap-1 overflow-visible p-1"
|
||
)}
|
||
>
|
||
<div className="relative z-30 h-16 shrink-0">
|
||
<header
|
||
className={cn(
|
||
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
|
||
floatingPresentation && "agent-panel-integrated-header",
|
||
presentation === "mobile-sheet" && "agent-panel-header-mobile",
|
||
historyOpen && "agent-panel-header-expanded"
|
||
)}
|
||
>
|
||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="min-w-0">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||
对话主题
|
||
</p>
|
||
<StatusBadge
|
||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||
icon={
|
||
<StatusDot
|
||
tone={agentConnectionMetadata[connectionStatus].tone}
|
||
activity={connectionStatus === "connecting" ? "live" : "static"}
|
||
/>
|
||
}
|
||
>
|
||
{agentConnectionMetadata[connectionStatus].label}
|
||
</StatusBadge>
|
||
</div>
|
||
<p
|
||
className="truncate text-sm font-semibold text-slate-950"
|
||
title={sessionTitle}
|
||
>
|
||
{sessionTitle}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="新建 Agent 对话"
|
||
title="新建 Agent 对话"
|
||
onClick={() => {
|
||
setHistoryOpen(false);
|
||
setPrompt("");
|
||
void Promise.resolve(onStartNewSession?.());
|
||
}}
|
||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||
>
|
||
<Plus size={17} aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
ref={historyTriggerRef}
|
||
type="button"
|
||
aria-label="打开 Agent 历史记录"
|
||
title="打开 Agent 历史记录"
|
||
aria-expanded={historyOpen}
|
||
aria-controls={historyPanelId}
|
||
onClick={() => setHistoryOpen((open) => !open)}
|
||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||
>
|
||
<History size={17} aria-hidden="true" />
|
||
</button>
|
||
{presentation === "mobile-sheet" ? (
|
||
<button
|
||
type="button"
|
||
aria-label="关闭 Agent 面板"
|
||
title="关闭 Agent 面板"
|
||
onClick={onCollapse}
|
||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||
>
|
||
<X size={17} aria-hidden="true" />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
aria-label="折叠 Agent 面板"
|
||
title="折叠 Agent 面板"
|
||
onClick={onCollapse}
|
||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||
>
|
||
<ChevronLeft size={17} aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<AnimatePresence initial={false}>
|
||
{historyOpen ? (
|
||
<motion.div
|
||
ref={historyPanelRef}
|
||
key="history"
|
||
id={historyPanelId}
|
||
role="region"
|
||
aria-label="Agent 历史记录"
|
||
className="agent-panel-floating-acrylic agent-panel-history-extension"
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelSectionVariants}
|
||
style={{ overflow: "hidden" }}
|
||
>
|
||
{historyPanel}
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</header>
|
||
</div>
|
||
|
||
{workspaceContext.length > 0 ? (
|
||
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
|
||
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
|
||
{workspaceContext.map((item) => (
|
||
<span
|
||
key={item.id}
|
||
className={cn(
|
||
"shrink-0 rounded-md px-2 py-1",
|
||
item.tone === "info" && "bg-blue-50 text-blue-700",
|
||
item.tone === "warning" && "bg-amber-50 text-amber-700",
|
||
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
|
||
)}
|
||
title={`${item.label}:${item.value}`}
|
||
>
|
||
{item.label} · <strong className="font-semibold">{item.value}</strong>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{floatingPresentation ? (
|
||
<AnimatePresence initial={false}>
|
||
{hasConversation && !historyOpen ? (
|
||
<motion.div
|
||
key="operational-brief"
|
||
className="agent-panel-operational-float absolute z-20 overflow-hidden rounded-2xl"
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelSectionVariants}
|
||
>
|
||
{operationalBrief}
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
) : (
|
||
<AnimatePresence initial={false}>
|
||
{hasConversation ? (
|
||
<motion.div
|
||
key="operational-brief"
|
||
aria-hidden={historyOpen}
|
||
className={cn(
|
||
"space-y-2 px-3 pb-3",
|
||
historyOpen && "invisible pointer-events-none"
|
||
)}
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelSectionVariants}
|
||
style={{ overflow: "hidden" }}
|
||
>
|
||
{operationalBrief}
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
)}
|
||
|
||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||
<Conversation
|
||
className={cn(
|
||
"agent-panel-conversation min-h-0",
|
||
floatingPresentation && "agent-panel-conversation-canvas"
|
||
)}
|
||
>
|
||
<ConversationContent
|
||
className={cn(
|
||
"gap-4 p-3",
|
||
messages.length === 0 && "min-h-full",
|
||
floatingPresentation && "agent-panel-conversation-content",
|
||
floatingPresentation &&
|
||
hasConversation &&
|
||
"agent-panel-conversation-content-has-brief",
|
||
floatingPresentation &&
|
||
!hasConversation &&
|
||
"agent-panel-conversation-content-empty"
|
||
)}
|
||
>
|
||
<AnimatePresence mode="popLayout" initial={false}>
|
||
{messages.length > 0 ? (
|
||
<BackendMessageList
|
||
key="messages"
|
||
messages={messages}
|
||
streaming={streaming}
|
||
streamRenderState={streamRenderState}
|
||
onReplyPermission={onReplyPermission}
|
||
onReplyQuestion={onReplyQuestion}
|
||
onRejectQuestion={onRejectQuestion}
|
||
isSpeechSupported={isSpeechSupported}
|
||
speakingMessageId={speakingMessageId}
|
||
speechState={speechState}
|
||
onSpeak={speak}
|
||
onPauseSpeech={pauseSpeech}
|
||
onResumeSpeech={resumeSpeech}
|
||
onStopSpeech={stopSpeech}
|
||
/>
|
||
) : (
|
||
<motion.div
|
||
key="empty"
|
||
className="flex flex-1 items-center justify-center px-4 py-8"
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelItemVariants}
|
||
>
|
||
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AgentUiEnvelopeRenderer results={uiResults} />
|
||
</ConversationContent>
|
||
<AgentConversationScrollManager
|
||
activeSessionId={activeSessionId}
|
||
messages={messages}
|
||
scrollRequestId={scrollRequestId}
|
||
streaming={streaming}
|
||
/>
|
||
<ConversationScrollButton
|
||
className={cn(
|
||
"agent-panel-floating-acrylic agent-conversation-scroll-button",
|
||
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
|
||
)}
|
||
/>
|
||
</Conversation>
|
||
</AgentContent>
|
||
|
||
<div
|
||
className={cn(
|
||
"agent-panel-band relative",
|
||
floatingPresentation
|
||
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||
: "border-t border-slate-200 p-3"
|
||
)}
|
||
>
|
||
{!hasConversation ? (
|
||
<div
|
||
className={cn(
|
||
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
|
||
floatingPresentation && "bg-transparent"
|
||
)}
|
||
>
|
||
<Suggestions
|
||
aria-label="推荐问题"
|
||
role="group"
|
||
className="pointer-events-auto px-0.5"
|
||
>
|
||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||
<Suggestion
|
||
key={suggestion}
|
||
suggestion={suggestion}
|
||
onClick={submitPrompt}
|
||
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||
>
|
||
<span className="truncate">{suggestion}</span>
|
||
</Suggestion>
|
||
))}
|
||
</Suggestions>
|
||
</div>
|
||
) : null}
|
||
<PromptInput
|
||
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!"
|
||
onSubmit={(message) => {
|
||
const nextPrompt = message.text.trim();
|
||
if (nextPrompt) {
|
||
submitPrompt(nextPrompt);
|
||
}
|
||
}}
|
||
>
|
||
<PromptInputBody>
|
||
<PromptInputTextarea
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||
/>
|
||
</PromptInputBody>
|
||
<PromptInputFooter className="justify-between gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<ApprovalModeSelect
|
||
value={approvalMode}
|
||
disabled={streaming}
|
||
onValueChange={onApprovalModeChange}
|
||
/>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<AgentModelSelect
|
||
models={modelOptions}
|
||
value={selectedModel}
|
||
onValueChange={onSelectModel}
|
||
/>
|
||
{isRecognitionSupported ? (
|
||
<VoiceInputButton
|
||
isListening={isListening}
|
||
disabled={streaming}
|
||
onClick={
|
||
isListening ? stopListening : () => startListening(showRecognitionError)
|
||
}
|
||
/>
|
||
) : null}
|
||
<PromptInputSubmit
|
||
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||
className="agent-panel-primary-icon"
|
||
disabled={!streaming && !trimmedPrompt}
|
||
status={streaming ? "streaming" : "ready"}
|
||
onStop={onStopPrompt}
|
||
>
|
||
{streaming ? undefined : (
|
||
<SendHorizontal
|
||
size={15}
|
||
style={{ transform: "rotate(-45deg)" }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</PromptInputSubmit>
|
||
</div>
|
||
</PromptInputFooter>
|
||
</PromptInput>
|
||
</div>
|
||
</Agent>
|
||
</aside>
|
||
</MotionConfig>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<motion.button
|
||
type="button"
|
||
aria-label={label}
|
||
aria-pressed={isListening}
|
||
title={label}
|
||
className={cn(
|
||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
|
||
"border-transparent bg-transparent shadow-none",
|
||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||
"disabled:cursor-not-allowed disabled:opacity-45",
|
||
isListening
|
||
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
|
||
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
|
||
)}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
>
|
||
{isListening ? (
|
||
<motion.span
|
||
data-slot="voice-input-pulse"
|
||
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
|
||
aria-hidden="true"
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={
|
||
reduceMotion
|
||
? { opacity: 0.38, scale: 1 }
|
||
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
|
||
}
|
||
transition={
|
||
reduceMotion
|
||
? { duration: 0.16 }
|
||
: {
|
||
duration: 1.7,
|
||
ease: "easeOut",
|
||
repeat: Infinity,
|
||
times: [0, 0.12, 1]
|
||
}
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
|
||
<AnimatePresence initial={false} mode="wait">
|
||
{isListening ? (
|
||
<motion.span
|
||
key="voice-wave"
|
||
className="flex h-4 items-center gap-0.5"
|
||
aria-hidden="true"
|
||
initial={{ opacity: 0, scale: 0.72 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.72 }}
|
||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||
>
|
||
{VOICE_WAVE_BARS.map((bar) => (
|
||
<motion.span
|
||
key={bar.height}
|
||
className="w-px rounded-full bg-[var(--status-mark)]"
|
||
style={{ height: bar.height }}
|
||
animate={
|
||
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||
}
|
||
transition={
|
||
reduceMotion
|
||
? { duration: 0.16 }
|
||
: {
|
||
duration: bar.duration,
|
||
delay: bar.delay,
|
||
ease: "easeInOut",
|
||
repeat: Infinity
|
||
}
|
||
}
|
||
aria-hidden="true"
|
||
/>
|
||
))}
|
||
</motion.span>
|
||
) : (
|
||
<motion.span
|
||
key="voice-microphone"
|
||
className="grid place-items-center"
|
||
aria-hidden="true"
|
||
initial={{ opacity: 0, scale: 0.72, y: 2 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.72, y: -2 }}
|
||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||
>
|
||
<Mic
|
||
size={15}
|
||
strokeWidth={2.15}
|
||
className="transition-transform duration-200 group-hover:scale-105"
|
||
/>
|
||
</motion.span>
|
||
)}
|
||
</AnimatePresence>
|
||
</span>
|
||
</motion.button>
|
||
);
|
||
}
|
||
|
||
function ApprovalModeSelect({
|
||
value,
|
||
disabled,
|
||
onValueChange
|
||
}: {
|
||
value: AgentApprovalMode;
|
||
disabled: boolean;
|
||
onValueChange?: (mode: AgentApprovalMode) => void;
|
||
}) {
|
||
const requestApproval = value === "request";
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||
disabled={disabled || !onValueChange}
|
||
aria-label="权限批准模式"
|
||
>
|
||
{requestApproval ? (
|
||
<Shield size={14} aria-hidden="true" />
|
||
) : (
|
||
<ShieldCheck size={14} aria-hidden="true" />
|
||
)}
|
||
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
|
||
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
side="top"
|
||
align="start"
|
||
sideOffset={8}
|
||
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
|
||
>
|
||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
|
||
权限批准模式
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuItem
|
||
className={cn(
|
||
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||
requestApproval && "border-blue-200 bg-blue-50 focus:bg-blue-50"
|
||
)}
|
||
onSelect={() => onValueChange?.("request")}
|
||
>
|
||
<Shield className="mt-0.5 text-blue-600" aria-hidden="true" />
|
||
<span>
|
||
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
|
||
<span className="block text-xs leading-5 text-slate-500">工具权限逐次确认</span>
|
||
</span>
|
||
{requestApproval ? (
|
||
<CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" />
|
||
) : null}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className={cn(
|
||
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||
!requestApproval &&
|
||
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
|
||
)}
|
||
onSelect={() => onValueChange?.("always")}
|
||
>
|
||
<ShieldCheck
|
||
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
|
||
aria-hidden="true"
|
||
/>
|
||
<span>
|
||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||
</span>
|
||
{!requestApproval ? (
|
||
<CheckCircle2
|
||
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
|
||
aria-hidden="true"
|
||
/>
|
||
) : null}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
type AgentConversationScrollSnapshot = {
|
||
activeSessionId?: string | null;
|
||
lastMessageRevision: string;
|
||
lastUserMessageId: string | null;
|
||
scrollRequestId: number;
|
||
streaming: boolean;
|
||
};
|
||
|
||
function AgentConversationScrollManager({
|
||
activeSessionId,
|
||
messages,
|
||
scrollRequestId,
|
||
streaming
|
||
}: {
|
||
activeSessionId?: string | null;
|
||
messages: AgentChatMessage[];
|
||
scrollRequestId: number;
|
||
streaming: boolean;
|
||
}) {
|
||
const { scrollToBottom } = useStickToBottomContext();
|
||
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
||
const lastUserMessageId = getLastUserMessageId(messages);
|
||
const lastMessageRevision = getLastMessageRevision(messages);
|
||
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
||
() => ({
|
||
activeSessionId,
|
||
lastMessageRevision,
|
||
lastUserMessageId,
|
||
scrollRequestId,
|
||
streaming
|
||
}),
|
||
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
|
||
);
|
||
|
||
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) ||
|
||
(snapshot.streaming &&
|
||
previousSnapshot.lastMessageRevision !== snapshot.lastMessageRevision) ||
|
||
previousSnapshot.streaming !== snapshot.streaming;
|
||
|
||
if (!forceScroll) {
|
||
return;
|
||
}
|
||
|
||
const animationFrameId = window.requestAnimationFrame(() => {
|
||
void scrollToBottom({
|
||
animation: "instant",
|
||
ignoreEscapes: true,
|
||
wait: false
|
||
});
|
||
});
|
||
|
||
return () => window.cancelAnimationFrame(animationFrameId);
|
||
}, [scrollToBottom, snapshot]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function getLastMessageRevision(messages: AgentChatMessage[]) {
|
||
const lastMessage = messages.at(-1);
|
||
|
||
if (!lastMessage) {
|
||
return "";
|
||
}
|
||
|
||
const progressRevision =
|
||
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
|
||
|
||
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
|
||
}
|
||
|
||
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 (
|
||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">模型</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||
disabled={!onValueChange}
|
||
aria-label="选择 Agent 模型"
|
||
>
|
||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||
<ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
|
||
<span className="shrink-0">{selectedModel?.label ?? "模型"}</span>
|
||
</span>
|
||
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
side="top"
|
||
align="end"
|
||
sideOffset={8}
|
||
className="agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
|
||
>
|
||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
|
||
模型选择
|
||
</DropdownMenuLabel>
|
||
<div className="space-y-1">
|
||
{models.map((model) => {
|
||
const selected = model.id === selectedModel?.id;
|
||
return (
|
||
<DropdownMenuItem
|
||
key={model.id}
|
||
className={cn(
|
||
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||
selected && "border-violet-200 bg-violet-50 focus:bg-violet-50"
|
||
)}
|
||
onSelect={() => onValueChange?.(model.id)}
|
||
>
|
||
<span className="ml-0.5 shrink-0 pt-0.5">
|
||
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-semibold text-slate-800">
|
||
{model.label}
|
||
</span>
|
||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||
{model.description}
|
||
</span>
|
||
</span>
|
||
{selected ? (
|
||
<CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" />
|
||
) : null}
|
||
</DropdownMenuItem>
|
||
);
|
||
})}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
const agentConnectionMetadata = {
|
||
connecting: { label: "连接中", tone: "info" },
|
||
online: { label: "在线", tone: "success" },
|
||
offline: { label: "离线", tone: "danger" }
|
||
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
|
||
|
||
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
||
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
|
||
}
|
||
|
||
function FastModelIcon({ size }: { size: number }) {
|
||
return (
|
||
<svg
|
||
width={size}
|
||
height={size}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
className="text-sky-600"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="M10.67 21c-.35 0-.62-.31-.57-.66L11 14H7.5c-.88 0-.33-.75-.31-.78 1.26-2.23 3.15-5.53 5.65-9.93.1-.18.3-.29.5-.29.35 0 .62.31.57.66l-.9 6.34h3.51c.4 0 .62.19.4.66-3.29 5.74-5.2 9.09-5.75 10.05-.1.18-.29.29-.5.29"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ExpertModelIcon({ size }: { size: number }) {
|
||
return (
|
||
<svg
|
||
width={size}
|
||
height={size}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
className="text-violet-600"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="m19.46 8 .79-1.75L22 5.46c.39-.18.39-.73 0-.91l-1.75-.79L19.46 2c-.18-.39-.73-.39-.91 0l-.79 1.75-1.76.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.74.39.92 0M11.5 9.5 9.91 6c-.35-.78-1.47-.78-1.82 0L6.5 9.5 3 11.09c-.78.36-.78 1.47 0 1.82l3.5 1.59L8.09 18c.36.78 1.47.78 1.82 0l1.59-3.5 3.5-1.59c.78-.36.78-1.47 0-1.82zm7.04 6.5-.79 1.75-1.75.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.73.39.91 0l.79-1.75 1.76-.79c.39-.18.39-.73 0-.91l-1.75-.79-.79-1.76c-.18-.39-.74-.39-.92 0"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function AgentReadyMark() {
|
||
return (
|
||
<svg viewBox="0 0 1024 1024" className="h-12 w-12 overflow-visible" aria-hidden="true">
|
||
<g className="agent-ready-orbit">
|
||
<path
|
||
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
|
||
fill="#CE75FF"
|
||
/>
|
||
<path
|
||
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
|
||
fill="#F62E76"
|
||
/>
|
||
<path
|
||
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
|
||
fill="#CD88FF"
|
||
/>
|
||
</g>
|
||
<path
|
||
className="agent-ready-core"
|
||
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
|
||
fill="#F3E2FF"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||
return (
|
||
<section
|
||
aria-labelledby="agent-empty-state-title"
|
||
className="mx-auto w-full max-w-[360px]"
|
||
lang="zh-CN"
|
||
>
|
||
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||
<div className="flex items-center gap-4">
|
||
<div
|
||
className={cn(
|
||
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
|
||
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||
)}
|
||
>
|
||
<AgentReadyMark />
|
||
</div>
|
||
<div className="min-w-0 flex-1 text-left">
|
||
<h2
|
||
id="agent-empty-state-title"
|
||
className={cn(
|
||
"text-balance font-semibold text-slate-950",
|
||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||
)}
|
||
>
|
||
我已就绪,请描述任务
|
||
</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<p
|
||
className={cn(
|
||
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||
compact ? "mt-3" : "mt-5"
|
||
)}
|
||
>
|
||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||
</p>
|
||
</div>
|
||
|
||
<ul
|
||
className={cn(
|
||
"surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
|
||
compact && "hidden"
|
||
)}
|
||
aria-label="Agent 分析能力"
|
||
>
|
||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||
<li
|
||
key={label}
|
||
className={cn(
|
||
"flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
|
||
index % 2 === 0 && "border-r border-slate-300/80",
|
||
index < 2 && "border-b border-slate-300/80"
|
||
)}
|
||
>
|
||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
|
||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||
</span>
|
||
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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> | void;
|
||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||
isSpeechSupported: boolean;
|
||
speakingMessageId: string | null;
|
||
speechState: AgentSpeechState;
|
||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||
onPauseSpeech: () => void;
|
||
onResumeSpeech: () => void;
|
||
onStopSpeech: () => void;
|
||
}) {
|
||
return (
|
||
<motion.div
|
||
className="flex w-full flex-col gap-4"
|
||
variants={agentPanelListVariants}
|
||
animate="animate"
|
||
>
|
||
<AnimatePresence initial={false} mode="popLayout">
|
||
{messages.map((message, index) => (
|
||
<motion.div
|
||
key={message.id}
|
||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={agentPanelItemVariants}
|
||
>
|
||
<Message
|
||
from={message.role}
|
||
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
|
||
>
|
||
<MessageContent
|
||
className={cn(
|
||
message.role === "user" &&
|
||
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
|
||
message.role === "assistant" &&
|
||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
|
||
)}
|
||
>
|
||
<div className="space-y-3">
|
||
{message.role === "assistant" ? (
|
||
<AgentMessageProgress
|
||
progress={message.progress}
|
||
running={streaming && index === messages.length - 1}
|
||
/>
|
||
) : null}
|
||
{message.content ? (
|
||
message.role === "assistant" ? (
|
||
<AgentSpeechMessage
|
||
messageId={message.id}
|
||
content={message.content}
|
||
disabled={streaming && index === messages.length - 1}
|
||
isSupported={isSpeechSupported}
|
||
speechState={speakingMessageId === message.id ? speechState : "idle"}
|
||
onSpeak={onSpeak}
|
||
onPause={onPauseSpeech}
|
||
onResume={onResumeSpeech}
|
||
onStop={onStopSpeech}
|
||
>
|
||
<StreamingTokenResponse
|
||
className="leading-6"
|
||
content={message.content}
|
||
messageId={message.id}
|
||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||
streaming={streaming && index === messages.length - 1}
|
||
/>
|
||
</AgentSpeechMessage>
|
||
) : (
|
||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||
)
|
||
) : streaming && index === messages.length - 1 ? (
|
||
<span className="text-slate-500">正在生成...</span>
|
||
) : null}
|
||
{message.role === "assistant" ? (
|
||
<AgentMessageDetails
|
||
message={message}
|
||
onReplyPermission={onReplyPermission}
|
||
onReplyQuestion={onReplyQuestion}
|
||
onRejectQuestion={onRejectQuestion}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</MessageContent>
|
||
</Message>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
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> | void;
|
||
onPause: () => void;
|
||
onResume: () => void;
|
||
onStop: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||
const lastSelectionRef = useRef<SpeechSelection | null>(null);
|
||
const [selection, setSelection] = useState<SpeechSelection | null>(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 (
|
||
<div>
|
||
<div
|
||
ref={contentRef}
|
||
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
|
||
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
|
||
>
|
||
{children}
|
||
</div>
|
||
|
||
{selectionAnchor && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div
|
||
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
||
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
||
>
|
||
<AnimatePresence>
|
||
{selection ? (
|
||
<motion.button
|
||
key="speech-selection-action"
|
||
type="button"
|
||
className="agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||
initial={{ opacity: 0 }}
|
||
animate={{
|
||
opacity: 1,
|
||
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
|
||
}}
|
||
exit={{
|
||
opacity: 0,
|
||
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
|
||
}}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={speakFromSelection}
|
||
>
|
||
<span
|
||
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600"
|
||
aria-hidden="true"
|
||
>
|
||
<Volume2 size={15} />
|
||
</span>
|
||
从这里开始朗读
|
||
</motion.button>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</div>,
|
||
document.body
|
||
)
|
||
: null}
|
||
|
||
{isSupported && !disabled ? (
|
||
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
|
||
{speechState === "idle" ? (
|
||
<SpeechIconButton
|
||
label="语音播放"
|
||
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}
|
||
>
|
||
<Volume2 size={15} aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
) : null}
|
||
{speechState === "loading" ? (
|
||
<>
|
||
<SpeechIconButton label="正在生成语音" disabled>
|
||
<LoaderCircle size={15} className="animate-spin text-blue-600" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
</>
|
||
) : null}
|
||
{speechState === "playing" ? (
|
||
<>
|
||
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
|
||
<Pause size={15} fill="currentColor" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
</>
|
||
) : null}
|
||
{speechState === "paused" ? (
|
||
<>
|
||
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
|
||
<Play size={15} fill="currentColor" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||
</SpeechIconButton>
|
||
</>
|
||
) : null}
|
||
<span className="text-[11px] text-slate-400">
|
||
{speechState === "idle"
|
||
? "语音播放"
|
||
: speechState === "loading"
|
||
? "正在生成"
|
||
: speechState === "playing"
|
||
? "播放中"
|
||
: "已暂停"}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SpeechIconButton({
|
||
label,
|
||
tone = "default",
|
||
disabled = false,
|
||
onClick,
|
||
children
|
||
}: {
|
||
label: string;
|
||
tone?: "default" | "primary" | "danger";
|
||
disabled?: boolean;
|
||
onClick?: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<Button
|
||
type="button"
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
className={cn(
|
||
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait",
|
||
tone === "primary" && "text-blue-600",
|
||
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
||
)}
|
||
>
|
||
{children}
|
||
</Button>
|
||
);
|
||
}
|