700 lines
27 KiB
TypeScript
700 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Activity,
|
|
CheckCircle2,
|
|
ChevronsUpDown,
|
|
ChevronLeft,
|
|
DatabaseZap,
|
|
GitMerge,
|
|
History,
|
|
MapPinned,
|
|
Plus,
|
|
SendHorizontal
|
|
} from "lucide-react";
|
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useStickToBottomContext } from "use-stick-to-bottom";
|
|
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
|
import {
|
|
Conversation,
|
|
ConversationContent,
|
|
ConversationScrollButton
|
|
} from "@/shared/ai-elements/conversation";
|
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
|
import { Message, MessageContent } from "@/shared/ai-elements/message";
|
|
import {
|
|
PromptInput,
|
|
PromptInputBody,
|
|
PromptInputFooter,
|
|
PromptInputSubmit,
|
|
PromptInputTextarea
|
|
} from "@/shared/ai-elements/prompt-input";
|
|
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import type { AgentChatSessionSummary } from "../api/client";
|
|
import { AgentPersona } from "./agent-persona";
|
|
import {
|
|
agentPanelItemVariants,
|
|
agentPanelListVariants,
|
|
agentPanelSectionVariants
|
|
} from "./agent-motion";
|
|
import { AgentHistoryPanel } from "./agent-history-panel";
|
|
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
|
|
import { AgentOperationalBrief } from "./agent-operational-brief";
|
|
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
|
import { StreamingTokenResponse } from "./streaming-token-response";
|
|
import type {
|
|
AgentChatMessage,
|
|
AgentModelOption,
|
|
AgentPermissionReply,
|
|
AgentPermissionRequest,
|
|
AgentQuestionRequest,
|
|
AgentStreamRenderState,
|
|
AgentUiResult
|
|
} from "../types";
|
|
|
|
type AgentCommandPanelProps = {
|
|
collapsing?: boolean;
|
|
personaState?: PersonaState;
|
|
sessionTitle?: string;
|
|
sessionHistory?: AgentChatSessionSummary[];
|
|
sessionHistoryLoading?: boolean;
|
|
activeSessionId?: string | null;
|
|
statusLabel?: string;
|
|
streaming?: boolean;
|
|
messages?: AgentChatMessage[];
|
|
streamRenderState?: AgentStreamRenderState;
|
|
modelOptions?: AgentModelOption[];
|
|
selectedModel?: string;
|
|
uiResults?: AgentUiResult[];
|
|
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;
|
|
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 = [
|
|
"当前监测数据质量如何,是否存在缺失、失真或可信度不足的问题?",
|
|
"当前监测数据中有哪些异常问题需要重点关注?",
|
|
"当前管网哪些区域可能存在雨污混接,判断依据是什么?"
|
|
] as const;
|
|
|
|
const AGENT_CAPABILITIES = [
|
|
{ icon: DatabaseZap, label: "监测质量诊断" },
|
|
{ icon: Activity, label: "异常状态研判" },
|
|
{ icon: GitMerge, label: "雨污混接识别" },
|
|
{ icon: MapPinned, label: "GIS 地图联动" }
|
|
] as const;
|
|
|
|
export function AgentCommandPanel({
|
|
collapsing = false,
|
|
personaState,
|
|
sessionTitle = "新对话",
|
|
sessionHistory = [],
|
|
sessionHistoryLoading = false,
|
|
activeSessionId,
|
|
statusLabel = "Agent 后端连接中",
|
|
streaming = false,
|
|
messages = [],
|
|
streamRenderState = {},
|
|
modelOptions = [],
|
|
selectedModel = "",
|
|
uiResults = [],
|
|
onCollapse,
|
|
onRefreshHistory,
|
|
onStartNewSession,
|
|
onLoadHistorySession,
|
|
onRenameHistorySession,
|
|
onDeleteHistorySession,
|
|
onSelectModel,
|
|
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 trimmedPrompt = prompt.trim();
|
|
|
|
useEffect(() => {
|
|
if (historyOpen) {
|
|
void Promise.resolve(onRefreshHistory?.());
|
|
}
|
|
}, [historyOpen, onRefreshHistory]);
|
|
|
|
const submitPrompt = (nextPrompt: string) => {
|
|
if (!nextPrompt || streaming) {
|
|
return;
|
|
}
|
|
setPrompt("");
|
|
setScrollRequestId((requestId) => requestId + 1);
|
|
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
|
|
setPrompt(nextPrompt);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<MotionConfig reducedMotion="user">
|
|
<aside
|
|
aria-label="Agent 命令面板"
|
|
className={cn(
|
|
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
|
"agent-panel-shell",
|
|
"acrylic-panel border",
|
|
"rounded-2xl",
|
|
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
|
)}
|
|
>
|
|
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
|
<header className="agent-panel-header acrylic-panel">
|
|
<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"
|
|
fallbackClassName="h-12 w-12 rounded-xl"
|
|
state={personaState}
|
|
statusLabel={statusLabel}
|
|
streaming={streaming}
|
|
/>
|
|
<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>
|
|
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
|
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
|
|
{getAgentConnectionLabel(statusLabel)}
|
|
</span>
|
|
</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-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
|
>
|
|
<Plus size={17} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="打开 Agent 历史记录"
|
|
title="打开 Agent 历史记录"
|
|
onClick={() => setHistoryOpen((open) => !open)}
|
|
className={cn(
|
|
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
|
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
|
)}
|
|
>
|
|
<History size={17} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="折叠 Agent 面板"
|
|
title="折叠 Agent 面板"
|
|
onClick={onCollapse}
|
|
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
|
>
|
|
<ChevronLeft size={17} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<AnimatePresence initial={false}>
|
|
{historyOpen ? (
|
|
<motion.div
|
|
key="history"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelSectionVariants}
|
|
style={{ overflow: "hidden" }}
|
|
>
|
|
<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}
|
|
/>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
<AnimatePresence initial={false}>
|
|
{messages.length > 0 || streaming ? (
|
|
<motion.div
|
|
key="operational-brief"
|
|
className="space-y-2 px-3 pb-3"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelSectionVariants}
|
|
style={{ overflow: "hidden" }}
|
|
>
|
|
<AgentOperationalBrief
|
|
messages={messages}
|
|
statusLabel={statusLabel}
|
|
streaming={streaming}
|
|
onSubmitCommand={submitPrompt}
|
|
/>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</header>
|
|
|
|
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
|
<Conversation className="agent-panel-conversation min-h-0">
|
|
<ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
|
|
<AnimatePresence mode="popLayout" initial={false}>
|
|
{messages.length > 0 ? (
|
|
<BackendMessageList
|
|
key="messages"
|
|
messages={messages}
|
|
streaming={streaming}
|
|
streamRenderState={streamRenderState}
|
|
onReplyPermission={onReplyPermission}
|
|
onReplyQuestion={onReplyQuestion}
|
|
onRejectQuestion={onRejectQuestion}
|
|
/>
|
|
) : (
|
|
<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 />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AgentUiEnvelopeRenderer results={uiResults} />
|
|
</ConversationContent>
|
|
<AgentConversationScrollManager
|
|
activeSessionId={activeSessionId}
|
|
messages={messages}
|
|
scrollRequestId={scrollRequestId}
|
|
/>
|
|
<ConversationScrollButton className="bottom-3" />
|
|
</Conversation>
|
|
</AgentContent>
|
|
|
|
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
|
{messages.length === 0 && !streaming ? (
|
|
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
|
<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-sm transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[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="min-h-14 text-sm"
|
|
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
|
/>
|
|
</PromptInputBody>
|
|
<PromptInputFooter className="justify-end">
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<AgentModelSelect
|
|
models={modelOptions}
|
|
value={selectedModel}
|
|
onValueChange={onSelectModel}
|
|
/>
|
|
<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} aria-hidden="true" />}
|
|
</PromptInputSubmit>
|
|
</div>
|
|
</PromptInputFooter>
|
|
</PromptInput>
|
|
</div>
|
|
</Agent>
|
|
</aside>
|
|
</MotionConfig>
|
|
);
|
|
}
|
|
|
|
type AgentConversationScrollSnapshot = {
|
|
activeSessionId?: string | null;
|
|
lastUserMessageId: string | null;
|
|
scrollRequestId: number;
|
|
};
|
|
|
|
function AgentConversationScrollManager({
|
|
activeSessionId,
|
|
messages,
|
|
scrollRequestId
|
|
}: {
|
|
activeSessionId?: string | null;
|
|
messages: AgentChatMessage[];
|
|
scrollRequestId: number;
|
|
}) {
|
|
const { scrollToBottom } = useStickToBottomContext();
|
|
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
|
|
const lastUserMessageId = getLastUserMessageId(messages);
|
|
const snapshot = useMemo<AgentConversationScrollSnapshot>(
|
|
() => ({
|
|
activeSessionId,
|
|
lastUserMessageId,
|
|
scrollRequestId
|
|
}),
|
|
[activeSessionId, lastUserMessageId, scrollRequestId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const previousSnapshot = previousSnapshotRef.current;
|
|
previousSnapshotRef.current = snapshot;
|
|
|
|
const forceScroll =
|
|
!previousSnapshot ||
|
|
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
|
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
|
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
|
|
|
if (!forceScroll) {
|
|
return;
|
|
}
|
|
|
|
const animationFrameId = window.requestAnimationFrame(() => {
|
|
void scrollToBottom({
|
|
animation: "instant",
|
|
ignoreEscapes: true,
|
|
wait: false
|
|
});
|
|
});
|
|
|
|
return () => window.cancelAnimationFrame(animationFrameId);
|
|
}, [scrollToBottom, snapshot]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function getLastUserMessageId(messages: AgentChatMessage[]) {
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
if (messages[index].role === "user") {
|
|
return messages[index].id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function AgentModelSelect({
|
|
models,
|
|
value,
|
|
onValueChange
|
|
}: {
|
|
models: AgentModelOption[];
|
|
value: string;
|
|
onValueChange?: (model: string) => void;
|
|
}) {
|
|
const selectedModel = models.find((model) => model.id === value) ?? models[0];
|
|
|
|
if (!models.length) {
|
|
return (
|
|
<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"
|
|
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md 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>
|
|
);
|
|
}
|
|
|
|
function getAgentConnectionLabel(statusLabel: string) {
|
|
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
|
|
}
|
|
|
|
function getAgentConnectionClassName(statusLabel: string) {
|
|
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
|
|
}
|
|
|
|
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
|
|
return model?.icon === "bolt" ? <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() {
|
|
return (
|
|
<section
|
|
aria-labelledby="agent-empty-state-title"
|
|
className="surface-reading mx-auto w-full max-w-sm rounded-2xl border border-slate-200/80 px-4 pb-4 pt-5 text-center shadow-sm"
|
|
>
|
|
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full border border-violet-100 bg-white shadow-[0_10px_28px_rgba(126,34,206,0.12)]">
|
|
<AgentReadyMark />
|
|
</div>
|
|
<h2 id="agent-empty-state-title" className="mt-4 text-lg font-semibold leading-7 text-slate-900">
|
|
我已就绪,请描述任务
|
|
</h2>
|
|
<p className="mx-auto mt-2 max-w-[30ch] text-sm leading-6 text-slate-500">
|
|
使用自然语言下达指令,我会整理监测与空间证据,并在地图上呈现分析结果。
|
|
</p>
|
|
<ul className="mt-5 grid grid-cols-2 gap-2" aria-label="Agent 分析能力">
|
|
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
|
|
<li
|
|
key={label}
|
|
className="surface-control flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200/70 px-2 py-2.5 text-center shadow-[0_6px_18px_rgba(15,23,42,0.04)] sm:flex-row sm:gap-2 sm:px-2.5 sm:text-left"
|
|
>
|
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
|
<Icon size={17} strokeWidth={1.9} aria-hidden="true" />
|
|
</span>
|
|
<span className="whitespace-nowrap text-xs font-semibold leading-5 text-slate-800">{label}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function BackendMessageList({
|
|
messages,
|
|
streaming,
|
|
streamRenderState,
|
|
onReplyPermission,
|
|
onReplyQuestion,
|
|
onRejectQuestion
|
|
}: {
|
|
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;
|
|
}) {
|
|
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" && "bg-blue-600 px-3 py-2 text-white",
|
|
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
|
|
)}
|
|
>
|
|
<div className="space-y-3">
|
|
{message.role === "assistant" ? (
|
|
<AgentMessageProgress
|
|
progress={message.progress}
|
|
running={streaming && index === messages.length - 1}
|
|
/>
|
|
) : null}
|
|
{message.content ? (
|
|
message.role === "assistant" ? (
|
|
<StreamingTokenResponse
|
|
className="leading-6"
|
|
content={message.content}
|
|
messageId={message.id}
|
|
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
|
streaming={streaming && index === messages.length - 1}
|
|
/>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|