feat: initialize TJWater WebGIS frontend
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
personaState?: PersonaState;
|
||||
onExpand: () => void;
|
||||
};
|
||||
|
||||
export function AgentCollapsedRail({ personaState, onExpand }: AgentCollapsedRailProps) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开排水助手面板"
|
||||
title="展开排水助手面板"
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
>
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
|
||||
type AgentHistoryPanelProps = {
|
||||
activeSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingSessionId: string | null;
|
||||
sessions: AgentChatSessionSummary[];
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AgentHistoryPanel({
|
||||
activeSessionId,
|
||||
loading,
|
||||
loadingSessionId,
|
||||
sessions,
|
||||
onRefresh,
|
||||
onSelectSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
}: AgentHistoryPanelProps) {
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
||||
|
||||
const beginRename = (session: AgentChatSessionSummary) => {
|
||||
setEditingSessionId(session.id);
|
||||
setDraftTitle(session.title);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
const saveRename = (session: AgentChatSessionSummary) => {
|
||||
const normalizedTitle = draftTitle.trim();
|
||||
if (!normalizedTitle || normalizedTitle === session.title) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSessionId(session.id);
|
||||
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
||||
.then(cancelRename)
|
||||
.finally(() => setSavingSessionId(null));
|
||||
};
|
||||
|
||||
const confirmDelete = (sessionId: string) => {
|
||||
setDeletingSessionId(sessionId);
|
||||
void Promise.resolve(onDeleteSession?.(sessionId))
|
||||
.then(() => setConfirmingDeleteSessionId(null))
|
||||
.finally(() => setDeletingSessionId(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
正在加载
|
||||
</motion.div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
const active = session.id === activeSessionId;
|
||||
const itemLoading = loadingSessionId === session.id;
|
||||
const editing = editingSessionId === session.id;
|
||||
const saving = savingSessionId === session.id;
|
||||
const deleting = deletingSessionId === session.id;
|
||||
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
|
||||
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
删除后无法从历史记录中恢复
|
||||
</span>
|
||||
</motion.div>
|
||||
) : editing ? (
|
||||
<motion.form
|
||||
key="rename"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveRename(session);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 rounded-lg bg-white text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
{formatSessionUpdatedAt(session.updatedAt)}
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-1">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
setConfirmingDeleteSessionId(session.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSessionUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
HelpCircle,
|
||||
ListChecks,
|
||||
ListTree,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
agentEase,
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants,
|
||||
agentPanelSectionVariants
|
||||
} from "./agent-motion";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentPermissionReply,
|
||||
AgentPermissionRequest,
|
||||
AgentQuestionInfo,
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate
|
||||
} from "../types";
|
||||
|
||||
type ProgressStatus = AgentChatProgress["status"];
|
||||
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
|
||||
type PermissionStatus = AgentPermissionRequest["status"];
|
||||
type QuestionStatus = AgentQuestionRequest["status"];
|
||||
|
||||
const STREAMING_PROGRESS_LIMIT = 3;
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
running: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
},
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-100"
|
||||
},
|
||||
error: {
|
||||
label: "失败",
|
||||
className: "bg-red-100 text-red-700",
|
||||
dotClassName: "border-red-500 bg-red-100"
|
||||
}
|
||||
};
|
||||
|
||||
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
|
||||
completed: {
|
||||
label: "完成",
|
||||
className: "bg-emerald-100 text-emerald-700",
|
||||
dotClassName: "border-emerald-500 bg-emerald-500"
|
||||
},
|
||||
in_progress: {
|
||||
label: "进行中",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
dotClassName: "border-blue-500 bg-blue-100"
|
||||
},
|
||||
pending: {
|
||||
label: "待办",
|
||||
className: "bg-slate-100 text-slate-600",
|
||||
dotClassName: "border-slate-300 bg-white"
|
||||
},
|
||||
cancelled: {
|
||||
label: "取消",
|
||||
className: "bg-slate-200 text-slate-500",
|
||||
dotClassName: "border-slate-300 bg-slate-200"
|
||||
}
|
||||
};
|
||||
|
||||
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
|
||||
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
|
||||
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
|
||||
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
|
||||
};
|
||||
|
||||
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
|
||||
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
|
||||
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
|
||||
error: { label: "失败", className: "bg-red-100 text-red-700" },
|
||||
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
|
||||
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
|
||||
};
|
||||
|
||||
export function AgentMessageDetails({
|
||||
message,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
message: AgentChatMessage;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
const hasDetails = Boolean(
|
||||
message.todos?.todos.length ||
|
||||
message.permissions?.length ||
|
||||
message.questions?.length
|
||||
);
|
||||
|
||||
if (!hasDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="space-y-2" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{message.todos ? (
|
||||
<motion.div key="todos" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<TodoCard todoUpdate={message.todos} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
{message.permissions?.length ? (
|
||||
<motion.div key="permissions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
{message.questions?.length ? (
|
||||
<motion.div key="questions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
|
||||
<QuestionList
|
||||
questions={message.questions}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentMessageProgress({
|
||||
progress,
|
||||
running
|
||||
}: {
|
||||
progress?: AgentChatProgress[];
|
||||
running: boolean;
|
||||
}) {
|
||||
if (!progress?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ProgressList progress={progress} running={running} />;
|
||||
}
|
||||
|
||||
function AgentNestedBlock({
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
title,
|
||||
children,
|
||||
floating = false
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
floating?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(floating ? "px-0 py-1" : "agent-panel-nested rounded-xl p-2.5")}>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<Icon size={14} className={iconClassName} aria-hidden="true" />
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const compact = running && !expanded;
|
||||
const visibleProgress = expanded
|
||||
? progress
|
||||
: running
|
||||
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||
: [];
|
||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<span>执行进度</span>
|
||||
<span className="flex-1" />
|
||||
<span className="shrink-0 font-normal text-slate-400">
|
||||
{expanded
|
||||
? `全部 ${progress.length} 条`
|
||||
: running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: `共 ${progress.length} 条`}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{visibleProgress.map((item) => (
|
||||
<motion.li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
|
||||
compact && "h-12 overflow-hidden"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: agentEase }}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
||||
progressStatusMeta[item.status].dotClassName
|
||||
)}
|
||||
animate={
|
||||
item.status === "running"
|
||||
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
||||
: item.status === "error"
|
||||
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
||||
: { opacity: 1, scale: 1 }
|
||||
}
|
||||
transition={
|
||||
item.status === "running"
|
||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
||||
: item.status === "error"
|
||||
? { duration: 0.28, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"block font-semibold text-slate-800",
|
||||
compact ? "truncate" : "break-words"
|
||||
)}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail ? (
|
||||
<span
|
||||
className={cn(
|
||||
"block leading-5 text-slate-500",
|
||||
compact ? "truncate" : "break-words"
|
||||
)}
|
||||
title={item.detail}
|
||||
>
|
||||
{item.detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={item.status}
|
||||
data-progress-status={item.status}
|
||||
className={cn(
|
||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
||||
progressStatusMeta[item.status].className
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
item.status === "error"
|
||||
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||
: { opacity: 1, x: 0 }
|
||||
}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={
|
||||
item.status === "error"
|
||||
? { duration: 0.24, ease: agentEase }
|
||||
: { duration: 0.14 }
|
||||
}
|
||||
>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ol>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
|
||||
return (
|
||||
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
|
||||
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{todoUpdate.todos.slice(0, 6).map((todo) => (
|
||||
<motion.li
|
||||
key={todo.id}
|
||||
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
|
||||
todoStatusMeta[todo.status].dotClassName
|
||||
)}
|
||||
>
|
||||
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
|
||||
{todoStatusMeta[todo.status].label}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ol>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionList({
|
||||
permissions,
|
||||
onReplyPermission
|
||||
}: {
|
||||
permissions: AgentPermissionRequest[];
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
|
||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
<AnimatedDetailItem key={permission.requestId}>
|
||||
<PermissionRequestCard
|
||||
permission={permission}
|
||||
onReplyPermission={onReplyPermission}
|
||||
/>
|
||||
</AnimatedDetailItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionRequestCard({
|
||||
permission,
|
||||
onReplyPermission
|
||||
}: {
|
||||
permission: AgentPermissionRequest;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
}) {
|
||||
const actionable = permission.status === "pending" || permission.status === "error";
|
||||
const submitting = permission.status === "submitting";
|
||||
const canApproveAlways = permission.always.length > 0;
|
||||
const disabled = !onReplyPermission || submitting;
|
||||
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
|
||||
|
||||
const reply = (nextReply: AgentPermissionReply) => {
|
||||
if (!onReplyPermission || submitting) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onReplyPermission(permission, nextReply));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
||||
{getPermissionTitle(permission)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
|
||||
{target}
|
||||
</p>
|
||||
{permission.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
|
||||
{permissionStatusMeta[permission.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="permission-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("once")}
|
||||
>
|
||||
<CheckCircle2 size={13} aria-hidden="true" />
|
||||
允许一次
|
||||
</Button>
|
||||
{canApproveAlways ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("always")}
|
||||
>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
始终允许
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
|
||||
disabled={disabled}
|
||||
onClick={() => reply("reject")}
|
||||
>
|
||||
<XCircle size={13} aria-hidden="true" />
|
||||
拒绝
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
questions,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
questions: AgentQuestionRequest[];
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
return (
|
||||
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
|
||||
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{questions.slice(-3).map((request) => (
|
||||
<AnimatedDetailItem key={request.requestId}>
|
||||
<QuestionRequestCard
|
||||
request={request}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
</AnimatedDetailItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AgentNestedBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionRequestCard({
|
||||
request,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: {
|
||||
request: AgentQuestionRequest;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
|
||||
const actionable = request.status === "pending" || request.status === "error";
|
||||
const submitting = request.status === "submitting";
|
||||
const disabled = submitting || !onReplyQuestion;
|
||||
const answers = normalizeDraftAnswers(draftAnswers);
|
||||
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
|
||||
|
||||
const submit = () => {
|
||||
if (!onReplyQuestion || !canSubmit) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onReplyQuestion(request, answers));
|
||||
};
|
||||
|
||||
const reject = () => {
|
||||
if (!onRejectQuestion || submitting) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onRejectQuestion(request));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-0 py-1 text-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
|
||||
{questionStatusMeta[request.status].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{request.questions.map((question, questionIndex) => (
|
||||
<QuestionInput
|
||||
key={`${request.requestId}-${questionIndex}`}
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||
disabled={!actionable || submitting}
|
||||
onChange={(nextValue) =>
|
||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{request.error ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
|
||||
) : null}
|
||||
{request.answers?.length ? (
|
||||
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
|
||||
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{actionable ? (
|
||||
<motion.div
|
||||
key="question-actions"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
<CheckCircle2 size={13} aria-hidden="true" />
|
||||
提交回答
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
|
||||
disabled={submitting || !onRejectQuestion}
|
||||
onClick={reject}
|
||||
>
|
||||
<XCircle size={13} aria-hidden="true" />
|
||||
跳过
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QuestionDraftAnswer = {
|
||||
selected: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
function QuestionInput({
|
||||
question,
|
||||
questionIndex,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
question: AgentQuestionInfo;
|
||||
questionIndex: number;
|
||||
value: QuestionDraftAnswer;
|
||||
disabled: boolean;
|
||||
onChange: (value: QuestionDraftAnswer) => void;
|
||||
}) {
|
||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||
const showCustomInput = question.custom || question.options.length === 0;
|
||||
|
||||
const toggleOption = (label: string, checked: boolean) => {
|
||||
if (question.multiple) {
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [label] : []
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||
{question.options.length ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option) => {
|
||||
const checked = value.selected.includes(option.label);
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold text-slate-700">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="block leading-4 text-slate-500">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||
return request.questions.map((question, index) => ({
|
||||
selected: request.answers?.[index] ?? [],
|
||||
custom: ""
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||
return draftAnswers.map((answer) => {
|
||||
const custom = answer.custom.trim();
|
||||
return custom ? [...answer.selected, custom] : answer.selected;
|
||||
});
|
||||
}
|
||||
|
||||
function getPermissionTitle(permission: AgentPermissionRequest) {
|
||||
if (permission.permission === "bash") return "执行终端命令";
|
||||
if (permission.permission === "edit") return "修改文件内容";
|
||||
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||
return permission.permission || "工具权限请求";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import type { Variants } from "motion/react";
|
||||
|
||||
export const AGENT_MOTION_DURATION_MS = 180;
|
||||
|
||||
export const agentEase = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
export const agentPanelItemVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 6,
|
||||
scale: 0.985
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -4,
|
||||
scale: 0.99,
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentPanelSectionVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
y: -4
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
height: "auto",
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
y: -4,
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentPanelListVariants: Variants = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.025
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const agentLayoutTransition = {
|
||||
duration: 0.18,
|
||||
ease: agentEase
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Crosshair,
|
||||
MapPinned,
|
||||
Radio,
|
||||
Route
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentChatMessage
|
||||
} from "../types";
|
||||
|
||||
export function AgentOperationalBrief({
|
||||
messages,
|
||||
statusLabel,
|
||||
streaming,
|
||||
onSubmitCommand
|
||||
}: {
|
||||
messages: AgentChatMessage[];
|
||||
statusLabel: string;
|
||||
streaming: boolean;
|
||||
onSubmitCommand: (prompt: string) => void;
|
||||
}) {
|
||||
const brief = createOperationalBrief(messages, statusLabel, streaming);
|
||||
|
||||
return (
|
||||
<div className="agent-panel-control rounded-2xl p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
|
||||
{brief.task}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
|
||||
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
|
||||
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.primaryCommand)}
|
||||
>
|
||||
<MapPinned size={14} aria-hidden="true" />
|
||||
预览影响
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
<Crosshair size={14} aria-hidden="true" />
|
||||
检查证据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
|
||||
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
|
||||
const progressItems = messages.flatMap((message) => message.progress ?? []);
|
||||
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
|
||||
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
|
||||
const questionItems = messages.flatMap((message) => message.questions ?? []);
|
||||
const latestProgress = progressItems.at(-1);
|
||||
const activeTodo =
|
||||
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
|
||||
[...todoItems].reverse().find((todo) => todo.status === "pending");
|
||||
const pendingConfirmations =
|
||||
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
|
||||
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
|
||||
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
|
||||
const task = lastUserMessage?.content.trim() || "等待调度指令";
|
||||
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
|
||||
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
|
||||
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
|
||||
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
|
||||
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
|
||||
|
||||
return {
|
||||
task,
|
||||
evidence,
|
||||
plan,
|
||||
confirmation,
|
||||
stateLabel,
|
||||
statusDotClassName,
|
||||
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
|
||||
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LazyPersona = lazy(async () => {
|
||||
const module = await import("@/shared/ai-elements/persona");
|
||||
return { default: module.Persona };
|
||||
});
|
||||
|
||||
type AgentPersonaProps = {
|
||||
className?: string;
|
||||
state?: PersonaState;
|
||||
};
|
||||
|
||||
function useElementVisibility() {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let intersectsViewport = false;
|
||||
const updateVisibility = () => {
|
||||
setVisible(intersectsViewport && document.visibilityState === "visible");
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||
updateVisibility();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, visible };
|
||||
}
|
||||
|
||||
function PersonaFallback() {
|
||||
return (
|
||||
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
|
||||
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||
const { containerRef, visible } = useElementVisibility();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||
|
||||
const shouldRenderAnimation = !failed && visible;
|
||||
|
||||
return (
|
||||
<span ref={containerRef} className={cn("relative block shrink-0", className)}>
|
||||
{shouldRenderAnimation ? (
|
||||
<Suspense fallback={<PersonaFallback />}>
|
||||
<LazyPersona
|
||||
className="h-full w-full scale-110"
|
||||
onLoadError={handleLoadError}
|
||||
state={state}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PersonaFallback />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
|
||||
type AgentUiEnvelopeRendererProps = {
|
||||
results: AgentUiResult[];
|
||||
};
|
||||
|
||||
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{results.map((result) => (
|
||||
<motion.div
|
||||
key={result.id}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AgentUiResultCard result={result} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
const { envelope } = result;
|
||||
|
||||
return (
|
||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
||||
{getEnvelopeIcon(envelope)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
|
||||
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
|
||||
agent-ui@1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
|
||||
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
|
||||
{envelope.kind === "map_action" ? null : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data);
|
||||
|
||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||
{spec.title ?? "Agent 图表"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{data.series.slice(0, 3).map((series, index) => (
|
||||
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
|
||||
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
|
||||
{series.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SafeChartSvg spec={spec} data={data} />
|
||||
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
||||
const width = 360;
|
||||
const height = 190;
|
||||
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const values = data.series.flatMap((series) => series.data);
|
||||
const min = Math.min(0, ...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const xFor = (index: number) =>
|
||||
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
||||
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
||||
const ticks = createTicks(min, max);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
||||
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
||||
{ticks.map((tick) => {
|
||||
const y = yFor(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
||||
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
||||
{formatChartNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
{data.xData.map((label, index) => (
|
||||
<text
|
||||
key={`${label}-${index}`}
|
||||
x={xFor(index)}
|
||||
y={height - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{shortLabel(label)}
|
||||
</text>
|
||||
))}
|
||||
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
||||
(series.type ?? spec.chartType) === "bar" ? (
|
||||
<BarSeries
|
||||
key={series.name}
|
||||
series={series}
|
||||
seriesIndex={seriesIndex}
|
||||
seriesCount={Math.min(data.series.length, 4)}
|
||||
xFor={xFor}
|
||||
yFor={yFor}
|
||||
baselineY={yFor(0)}
|
||||
/>
|
||||
) : (
|
||||
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
xFor,
|
||||
yFor
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
}) {
|
||||
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
||||
const stroke = chartStroke(seriesIndex);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="agent-chart-line"
|
||||
/>
|
||||
{series.data.map((value, index) => (
|
||||
<circle
|
||||
key={`${series.name}-${index}`}
|
||||
cx={xFor(index)}
|
||||
cy={yFor(value)}
|
||||
r="2.8"
|
||||
fill={stroke}
|
||||
className="agent-chart-point"
|
||||
style={{ animationDelay: `${80 + index * 18}ms` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function BarSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
seriesCount,
|
||||
xFor,
|
||||
yFor,
|
||||
baselineY
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
seriesCount: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
baselineY: number;
|
||||
}) {
|
||||
const fill = chartStroke(seriesIndex);
|
||||
const barWidth = Math.max(5, 22 / seriesCount);
|
||||
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{series.data.map((value, index) => {
|
||||
const y = yFor(value);
|
||||
return (
|
||||
<rect
|
||||
key={`${series.name}-${index}`}
|
||||
x={xFor(index) - barWidth / 2 + offset}
|
||||
y={Math.min(y, baselineY)}
|
||||
width={barWidth}
|
||||
height={Math.max(1, Math.abs(baselineY - y))}
|
||||
rx="2"
|
||||
fill={fill}
|
||||
className="agent-chart-bar"
|
||||
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisteredComponentEnvelope({
|
||||
envelope
|
||||
}: {
|
||||
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
|
||||
}) {
|
||||
if (envelope.component === "HistoryPanel") {
|
||||
return (
|
||||
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
|
||||
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
|
||||
</TrustedPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.component === "ScadaPanel") {
|
||||
return (
|
||||
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
|
||||
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
|
||||
</TrustedPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
|
||||
}
|
||||
|
||||
function TrustedPanelShell({
|
||||
icon,
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
|
||||
const rows = safePropRows(props, labels);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-slate-600">已打开可信面板,暂无可展示参数。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
||||
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safePropRows(props: unknown, labels: Record<string, string>) {
|
||||
if (!isRecord(props)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(labels).flatMap(([key, label]) => {
|
||||
const value = props[key];
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ label, value: String(value) }];
|
||||
});
|
||||
}
|
||||
|
||||
function getEnvelopeIcon(envelope: UIEnvelope) {
|
||||
if (envelope.kind === "chart") {
|
||||
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
|
||||
<BarChart3 size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<LineChart size={16} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
return <Database size={16} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <Activity size={16} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||
if (envelope.kind === "chart") {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
return spec.title ?? "Agent 图表";
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
return envelope.component;
|
||||
}
|
||||
|
||||
return envelope.action;
|
||||
}
|
||||
|
||||
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
||||
if (surface === "chat_inline") return "聊天内联展示";
|
||||
if (surface === "side_panel") return "侧栏展示";
|
||||
if (surface === "canvas") return "画布展示";
|
||||
return "地图叠加";
|
||||
}
|
||||
|
||||
function createTicks(min: number, max: number) {
|
||||
const range = max - min || 1;
|
||||
return [max, min + range * 0.5, min];
|
||||
}
|
||||
|
||||
function chartStroke(index: number) {
|
||||
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
||||
}
|
||||
|
||||
function chartColorClass(index: number) {
|
||||
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
|
||||
}
|
||||
|
||||
function formatChartNumber(value: number) {
|
||||
if (Math.abs(value) >= 100) {
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 10) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function shortLabel(value: string) {
|
||||
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
|
||||
type StreamingTokenResponseProps = {
|
||||
content: string;
|
||||
streaming: boolean;
|
||||
streamDone?: boolean;
|
||||
className?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export function StreamingTokenResponse({
|
||||
content,
|
||||
streaming,
|
||||
streamDone = !streaming,
|
||||
className,
|
||||
}: StreamingTokenResponseProps) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAnimating = streaming && !streamDone;
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={cn("agent-streaming-response", className)}
|
||||
isAnimating={isAnimating}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown={true}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user