Restore full acrylic materials to stacked task cards and floating controls. Extend the map behind the navigation header while preserving document and control insets. Align timeline and scale readouts, refresh dense-marker hover content, and highlight model and approval menu options. Keep narrow timeline headers clear of their legend and actions. Update coupled geometry checks and retain city zoom-12 and menu regression coverage. Validation: scoped git diff --check and read-only UI geometry inspection. Test suites were not rerun at the user's request.
1587 lines
58 KiB
TypeScript
1587 lines
58 KiB
TypeScript
import {
|
|
Activity,
|
|
CheckCircle2,
|
|
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 Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction
|
|
} 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;
|
|
qaOnly?: boolean;
|
|
statusLabel?: string;
|
|
connectionStatus?: AgentConnectionStatus;
|
|
streaming?: boolean;
|
|
messages?: AgentChatMessage[];
|
|
streamRenderState?: AgentStreamRenderState;
|
|
modelOptions?: AgentModelOption[];
|
|
selectedModel?: string;
|
|
approvalMode?: AgentApprovalMode;
|
|
uiResults?: AgentUiResult[];
|
|
reviewPanel?: ReactNode;
|
|
workspaceContext?: AgentWorkspaceContextItem[];
|
|
prompt: string;
|
|
onCollapse: () => void;
|
|
onPromptChange: Dispatch<SetStateAction<string>>;
|
|
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,
|
|
qaOnly = false,
|
|
statusLabel = "Agent 后端连接中",
|
|
connectionStatus = "connecting",
|
|
streaming = false,
|
|
messages = [],
|
|
streamRenderState = {},
|
|
modelOptions = [],
|
|
selectedModel = "",
|
|
approvalMode = "request",
|
|
uiResults = [],
|
|
reviewPanel,
|
|
prompt,
|
|
onCollapse,
|
|
onPromptChange,
|
|
onRefreshHistory,
|
|
onStartNewSession,
|
|
onLoadHistorySession,
|
|
onRenameHistorySession,
|
|
onDeleteHistorySession,
|
|
onSelectModel,
|
|
onApprovalModeChange,
|
|
onSubmitPrompt,
|
|
onStopPrompt,
|
|
onReplyPermission,
|
|
onReplyQuestion,
|
|
onRejectQuestion
|
|
}: AgentCommandPanelProps) {
|
|
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 promptRestoreGenerationRef = useRef(0);
|
|
const mountedRef = useRef(true);
|
|
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) => {
|
|
promptRestoreGenerationRef.current += 1;
|
|
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
|
|
}, [onPromptChange]);
|
|
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(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
promptRestoreGenerationRef.current += 1;
|
|
};
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
const restoreGeneration = promptRestoreGenerationRef.current + 1;
|
|
promptRestoreGenerationRef.current = restoreGeneration;
|
|
onPromptChange("");
|
|
stopListening();
|
|
setScrollRequestId((requestId) => requestId + 1);
|
|
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
|
|
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
|
|
return;
|
|
}
|
|
onPromptChange((current) => current || nextPrompt);
|
|
});
|
|
};
|
|
const historyPanel = (
|
|
<AgentHistoryPanel
|
|
activeSessionId={activeSessionId}
|
|
loadingSessionId={loadingSessionId}
|
|
loading={sessionHistoryLoading}
|
|
sessions={sessionHistory}
|
|
onRefresh={onRefreshHistory}
|
|
onSelectSession={(nextSessionId) => {
|
|
promptRestoreGenerationRef.current += 1;
|
|
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 命令面板"
|
|
data-agent-presentation={presentation}
|
|
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-none border-0 border-r"
|
|
: 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={cn("relative z-30 shrink-0", presentation === "desktop-dock" ? "h-14" : "h-16")}>
|
|
<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={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "min-h-14 py-1.5" : "min-h-16 py-2")}>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
|
<AgentPersona className={presentation === "desktop-dock" ? "h-10 w-10" : "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-[var(--ink-muted)]">
|
|
对话主题
|
|
</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={() => {
|
|
promptRestoreGenerationRef.current += 1;
|
|
setHistoryOpen(false);
|
|
onPromptChange("");
|
|
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 历史记录"
|
|
data-agent-presentation={presentation}
|
|
className="agent-panel-overlay 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>
|
|
|
|
{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"
|
|
presentation={presentation}
|
|
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}
|
|
qaOnly={qaOnly}
|
|
/>
|
|
) : (
|
|
<motion.div
|
|
key="empty"
|
|
className={cn(
|
|
"flex flex-1 px-4",
|
|
presentation === "mobile-sheet"
|
|
? "items-start justify-center pb-0 pt-5"
|
|
: "items-center justify-center py-8"
|
|
)}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
>
|
|
<AgentEmptyState compact={presentation === "mobile-sheet"} />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
|
|
{reviewPanel}
|
|
</ConversationContent>
|
|
<AgentConversationScrollManager
|
|
activeSessionId={activeSessionId}
|
|
messages={messages}
|
|
scrollRequestId={scrollRequestId}
|
|
streaming={streaming}
|
|
/>
|
|
<ConversationScrollButton
|
|
data-agent-presentation={presentation}
|
|
className={cn(
|
|
"agent-panel-overlay 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
|
|
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
|
: "border-t border-[var(--surface-divider)] 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-[var(--surface-divider)] text-slate-600 hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]"
|
|
>
|
|
<span className="truncate">{suggestion}</span>
|
|
</Suggestion>
|
|
))}
|
|
</Suggestions>
|
|
</div>
|
|
) : null}
|
|
<PromptInput
|
|
className="form-field 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"
|
|
onSubmit={(message) => {
|
|
const nextPrompt = message.text.trim();
|
|
if (nextPrompt) {
|
|
submitPrompt(nextPrompt);
|
|
}
|
|
}}
|
|
>
|
|
<PromptInputBody>
|
|
<PromptInputTextarea
|
|
value={prompt}
|
|
onChange={(event) => {
|
|
promptRestoreGenerationRef.current += 1;
|
|
onPromptChange(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">
|
|
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<AgentModelSelect
|
|
presentation={presentation}
|
|
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-[var(--action-focus-ring)] 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>
|
|
);
|
|
}
|
|
|
|
const AGENT_SELECT_ITEM_CLASS_NAME =
|
|
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
|
|
|
|
function ApprovalModeSelect({
|
|
presentation,
|
|
value,
|
|
disabled,
|
|
onValueChange
|
|
}: {
|
|
presentation: AgentCommandPanelPresentation;
|
|
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-[var(--surface-reading)]"
|
|
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
|
|
data-agent-presentation={presentation}
|
|
side="top"
|
|
align="start"
|
|
sideOffset={8}
|
|
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
|
|
>
|
|
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
|
|
权限批准模式
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
className={cn(
|
|
AGENT_SELECT_ITEM_CLASS_NAME,
|
|
"items-start",
|
|
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
|
|
)}
|
|
onSelect={() => onValueChange?.("request")}
|
|
>
|
|
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
|
|
<span>
|
|
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
|
|
<span className="block text-xs leading-5 text-[var(--ink-muted)]">工具权限逐次确认</span>
|
|
</span>
|
|
{requestApproval ? (
|
|
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
|
|
) : null}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={cn(
|
|
AGENT_SELECT_ITEM_CLASS_NAME,
|
|
"mt-1 items-start",
|
|
!requestApproval &&
|
|
"status-tone-success border-[var(--status-border)] 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-[var(--ink-muted)]">自动允许本轮权限请求</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({
|
|
presentation,
|
|
models,
|
|
value,
|
|
onValueChange
|
|
}: {
|
|
presentation: AgentCommandPanelPresentation;
|
|
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-[var(--ink-muted)]">模型</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)] sm:w-24 sm:gap-1.5 sm:px-1.5"
|
|
disabled={!onValueChange}
|
|
aria-label="选择 Agent 模型"
|
|
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
|
|
title={selectedModel?.label ?? "模型"}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-1">
|
|
<ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
|
|
<span className="hidden shrink-0 sm:inline">{selectedModel?.label ?? "模型"}</span>
|
|
</span>
|
|
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
data-agent-presentation={presentation}
|
|
side="top"
|
|
align="end"
|
|
sideOffset={8}
|
|
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
|
|
>
|
|
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
|
|
模型选择
|
|
</DropdownMenuLabel>
|
|
<div className="space-y-1">
|
|
{models.map((model) => {
|
|
const selected = model.id === selectedModel?.id;
|
|
return (
|
|
<DropdownMenuItem
|
|
key={model.id}
|
|
className={cn(
|
|
AGENT_SELECT_ITEM_CLASS_NAME,
|
|
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
|
|
)}
|
|
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-[var(--ink-muted)]">
|
|
{model.description}
|
|
</span>
|
|
</span>
|
|
{selected ? (
|
|
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} 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-[var(--action-blue-hover)]"
|
|
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="var(--palette-brand)"
|
|
/>
|
|
<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="var(--palette-champagne)"
|
|
/>
|
|
<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="var(--palette-sand)"
|
|
/>
|
|
</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="var(--action-selection-soft)"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
|
if (compact) {
|
|
return (
|
|
<section
|
|
aria-labelledby="agent-empty-state-title"
|
|
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
|
|
lang="zh-CN"
|
|
>
|
|
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
|
|
<AgentReadyMark />
|
|
</div>
|
|
<div className="min-w-0 flex-1 text-left">
|
|
<h2
|
|
id="agent-empty-state-title"
|
|
className="text-sm font-semibold leading-5 text-slate-950"
|
|
>
|
|
我已就绪,请描述任务
|
|
</h2>
|
|
<p className="mt-0.5 text-xs leading-5 text-slate-600">
|
|
描述调查目标,我会整理证据并带回地图。
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section
|
|
aria-labelledby="agent-empty-state-title"
|
|
className="agent-empty-state mx-auto w-full max-w-[360px]"
|
|
lang="zh-CN"
|
|
>
|
|
<div className="px-2 py-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className="agent-empty-mark surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
|
|
<AgentReadyMark />
|
|
</div>
|
|
<div className="min-w-0 flex-1 text-left">
|
|
<h2
|
|
id="agent-empty-state-title"
|
|
className="text-balance text-lg font-semibold leading-7 text-slate-950"
|
|
>
|
|
我已就绪,请描述任务
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
|
|
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
|
</p>
|
|
</div>
|
|
|
|
<ul
|
|
className={cn(
|
|
"agent-capabilities 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(
|
|
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
|
|
index % 2 === 0 && "border-r border-[var(--surface-border)]/80",
|
|
index < 2 && "border-b border-[var(--surface-border)]/80"
|
|
)}
|
|
>
|
|
<span className="agent-capability-icon 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({
|
|
presentation,
|
|
messages,
|
|
streaming,
|
|
streamRenderState,
|
|
onReplyPermission,
|
|
onReplyQuestion,
|
|
onRejectQuestion,
|
|
isSpeechSupported,
|
|
speakingMessageId,
|
|
speechState,
|
|
onSpeak,
|
|
onPauseSpeech,
|
|
onResumeSpeech,
|
|
onStopSpeech,
|
|
qaOnly
|
|
}: {
|
|
presentation: AgentCommandPanelPresentation;
|
|
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;
|
|
qaOnly: boolean;
|
|
}) {
|
|
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" && !qaOnly ? (
|
|
<AgentMessageProgress
|
|
progress={message.progress}
|
|
running={streaming && index === messages.length - 1}
|
|
/>
|
|
) : null}
|
|
{message.content ? (
|
|
message.role === "assistant" ? (
|
|
<AgentSpeechMessage
|
|
presentation={presentation}
|
|
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-[var(--ink-muted)]">正在生成...</span>
|
|
) : null}
|
|
{message.role === "assistant" && !qaOnly ? (
|
|
<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({
|
|
presentation,
|
|
messageId,
|
|
content,
|
|
disabled,
|
|
isSupported,
|
|
speechState,
|
|
onSpeak,
|
|
onPause,
|
|
onResume,
|
|
onStop,
|
|
children
|
|
}: {
|
|
presentation: AgentCommandPanelPresentation;
|
|
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"
|
|
data-agent-presentation={presentation}
|
|
className="agent-panel-overlay 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-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
|
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-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
|
|
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-[var(--surface-divider)] 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-[var(--action-blue-hover)]" 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-[var(--ink-muted)]">
|
|
{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-[var(--ink-muted)] hover:bg-[var(--surface-control)] disabled:cursor-wait",
|
|
tone === "primary" && "text-[var(--action-blue-hover)]",
|
|
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
|
)}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
}
|