feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
@@ -0,0 +1,79 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
const state = getCollapsedAgentState(statusLabel, personaState);
return (
<aside className={cn("agent-rail-enter pointer-events-auto w-[188px] p-2", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<div className="agent-panel-control agent-rail-item rounded-2xl p-2">
<div className="flex items-center gap-2">
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10"
state={personaState}
statusLabel={statusLabel}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-slate-950">HydroAgent</p>
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", state.dotClassName)} />
<span className="truncate" title={statusLabel}>{state.label}</span>
</p>
</div>
<button
type="button"
aria-label="展开 Agent 面板"
title="展开 Agent 面板"
onClick={onExpand}
className="agent-panel-primary-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</div>
</aside>
);
}
function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) {
if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) {
return {
label: "离线",
dotClassName: "bg-red-500"
};
}
if (personaState === "listening") {
return {
label: "待确认",
dotClassName: "bg-orange-500"
};
}
if (personaState === "thinking") {
return {
label: "处理中",
dotClassName: "bg-blue-500"
};
}
return {
label: "在线",
dotClassName: "bg-emerald-500"
};
}
@@ -0,0 +1,629 @@
"use client";
import {
Bolt,
CheckCircle2,
ChevronsUpDown,
ChevronLeft,
Gauge,
History,
Plus,
SendHorizontal,
Sparkles
} 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails } 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;
};
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
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<div className="agent-panel-band border-b border-white/70">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
<AgentPersona
className="h-[72px] w-[72px]"
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
<div className="min-w-0 flex-1 space-y-2">
<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/70 bg-white/75 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>
</div>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className="gap-4 p-3">
<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="w-full"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState statusLabel={statusLabel} />
</motion.div>
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
uiResults={uiResults}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band border-t border-white/70 p-3">
<PromptInput
className="agent-panel-control rounded-2xl shadow-sm"
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>
<span className="min-w-0 flex-1 truncate text-xs text-slate-500" title={statusLabel}>
{statusLabel}
</span>
<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;
signature: string;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId,
streaming,
uiResults
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
uiResults: AgentUiResult[];
}) {
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastUserMessageId: getLastUserMessageId(messages),
scrollRequestId,
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
}),
[activeSessionId, messages, scrollRequestId, streaming, uiResults]
);
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);
const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature;
if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({
animation: "instant",
ignoreEscapes: forceScroll,
wait: !forceScroll
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot, wasPinnedToBottom]);
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 createAgentConversationScrollSignature(
messages: AgentChatMessage[],
streaming: boolean,
uiResults: AgentUiResult[]
) {
return [
streaming ? "streaming" : "idle",
messages.map(createAgentMessageScrollSignature).join("|"),
uiResults.map((result) => result.id).join("|")
].join("#");
}
function createAgentMessageScrollSignature(message: AgentChatMessage) {
return [
message.id,
message.role,
message.content.length,
message.progress?.map((item) => `${item.id}:${item.status}:${item.title.length}:${item.detail?.length ?? 0}`).join(",") ?? "",
message.todos?.todos.map((todo) => `${todo.id}:${todo.status}:${todo.content.length}`).join(",") ?? "",
message.permissions?.map((permission) => `${permission.requestId}:${permission.status}:${permission.error?.length ?? 0}`).join(",") ?? "",
message.questions?.map((question) => `${question.requestId}:${question.status}:${question.error?.length ?? 0}`).join(",") ?? ""
].join(":");
}
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-[126px] shrink-0 justify-between rounded-md border-transparent bg-transparent px-2 text-xs text-slate-600 shadow-none hover:bg-white/80"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
<span className="flex min-w-0 items-center gap-1.5">
<ModelIcon model={selectedModel} size={13} />
<span className="truncate">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={13} className="shrink-0 opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={8}
className="agent-panel-control w-72 rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
Agent
</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/80 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-white text-slate-600 shadow-sm">
<ModelIcon model={model} size={15} />
</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" ? (
<Bolt size={size} className="text-amber-500" aria-hidden="true" />
) : (
<Sparkles size={size} className="text-violet-500" aria-hidden="true" />
);
}
function AgentEmptyState({ statusLabel }: { statusLabel: string }) {
return (
<Message from="assistant" className="max-w-full">
<MessageContent className="agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-600 shadow-sm">
<div className="flex items-center gap-2">
<Gauge size={15} className="text-blue-600" aria-hidden="true" />
<p className="font-semibold text-slate-900"></p>
</div>
<p className="mt-2"></p>
<p className="mt-1 text-xs text-slate-500">{statusLabel}</p>
</MessageContent>
</Message>
);
}
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}
layout
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<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.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>
);
}
@@ -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,612 @@
"use client";
import {
CheckCircle2,
HelpCircle,
ListChecks,
LockKeyhole,
Loader2,
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 {
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 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.progress?.length ||
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
if (!hasDetails) {
return null;
}
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.progress?.length ? (
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<ProgressList progress={message.progress} />
</motion.div>
) : null}
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
</motion.div>
) : null}
{message.permissions?.length ? (
<motion.div key="permissions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div>
) : null}
{message.questions?.length ? (
<motion.div key="questions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
}) {
return (
<div className="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
layout
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
{children}
</motion.div>
);
}
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
return (
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{progress.slice(-6).map((item) => (
<motion.li
key={item.id}
layout
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 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
) : null}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
{progressStatusMeta[item.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
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}
layout
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="space-y-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="rounded-lg bg-white/90 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="补充信息">
<motion.div className="space-y-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="rounded-lg bg-white/90 px-2.5 py-2 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 bg-white/90 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 bg-white/90 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 || "工具权限请求";
}
+72
View File
@@ -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,122 @@
"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 justify-between 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>
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-1 text-xs font-semibold text-violet-700">
Agent
</span>
</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="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 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="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 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,62 @@
"use client";
import { Bot } from "lucide-react";
import { useState } from "react";
import { Persona, type PersonaState, type PersonaVariant } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
type AgentPersonaProps = {
className?: string;
fallbackClassName?: string;
state?: PersonaState;
statusLabel?: string;
streaming?: boolean;
variant?: PersonaVariant;
};
export function AgentPersona({
className,
fallbackClassName,
state,
statusLabel,
streaming = false,
variant = "obsidian"
}: AgentPersonaProps) {
const [failed, setFailed] = useState(false);
const personaState = state ?? getAgentPersonaState(statusLabel, streaming);
if (failed) {
return (
<span
className={cn(
"grid shrink-0 place-items-center rounded-xl bg-violet-100 text-violet-700",
className,
fallbackClassName
)}
>
<Bot size={19} aria-hidden="true" />
</span>
);
}
return (
<Persona
className={cn("shrink-0", className)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
/>
);
}
export function getAgentPersonaState(statusLabel = "", streaming = false): PersonaState {
if (/失败|不可用|错误|降级/.test(statusLabel)) {
return "asleep";
}
if (streaming || /请求|分析|生成|连接中|加载/.test(statusLabel)) {
return "thinking";
}
return "idle";
}
@@ -0,0 +1,407 @@
"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}
layout
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="min-w-0 rounded-lg bg-white/90 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,301 @@
export const DEFAULT_STREAMING_TOKEN_LOCALE = "zh";
export const STREAMING_TOKEN_BLOCK_SIZE = 4;
const FALLBACK_CHUNK_SIZE = 2;
export function splitStreamingText(text: string, locale = DEFAULT_STREAMING_TOKEN_LOCALE) {
if (!text) {
return [];
}
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(locale, { granularity: "word" })
: null;
if (segmenter) {
return Array.from(segmenter.segment(text), (item) => item.segment).filter(Boolean);
}
return splitStreamingTextFallback(text);
}
export function splitStreamingTextFallback(text: string) {
const codePoints = Array.from(text);
const chunks: string[] = [];
for (let index = 0; index < codePoints.length; index += FALLBACK_CHUNK_SIZE) {
chunks.push(codePoints.slice(index, index + FALLBACK_CHUNK_SIZE).join(""));
}
return chunks;
}
function takeStreamingTokenBlock(pending: string[], blockSize = STREAMING_TOKEN_BLOCK_SIZE) {
return pending.splice(0, blockSize).join("");
}
export type StreamingMarkdownBufferState = {
content: string;
displayContent: string;
locale: string;
};
type FenceMarker = {
char: "`" | "~";
length: number;
};
const FENCE_START_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
const HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]+\S/;
const PARTIAL_HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]*$/;
const LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\S/;
const PARTIAL_LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]*$/;
const BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]?\S/;
const PARTIAL_BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]*$/;
const HORIZONTAL_RULE_PATTERN = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})[ \t]*(?:\r?\n|$)/;
const TABLE_SEPARATOR_PATTERN = /^ {0,3}\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
const POSSIBLE_TABLE_HEADER_PATTERN = /^ {0,3}\|.*\||^ {0,3}\S.*\|.*\|/;
const LINE_START_PATTERN = /(?:^|\n)(?= {0,3}(?:#{1,6}|[-+*]|\d{1,9}[.)]|>|`{3,}|~{3,}|\|))/g;
function readFenceStart(line: string): FenceMarker | null {
const match = FENCE_START_PATTERN.exec(line);
if (!match) {
return null;
}
const marker = match[1];
return {
char: marker[0] as FenceMarker["char"],
length: marker.length,
};
}
function readLineEnd(content: string) {
const newlineIndex = content.indexOf("\n");
if (newlineIndex === -1) {
return content.length;
}
return newlineIndex + 1;
}
function isTableLine(line: string) {
return line.includes("|") && line.trim().length > 0;
}
function isTableSeparatorLine(line: string) {
return TABLE_SEPARATOR_PATTERN.test(line);
}
type MarkdownOpenerMatch = {
chunk: string;
};
export function createStreamingMarkdownBufferState(
content = "",
displayContent = "",
locale = DEFAULT_STREAMING_TOKEN_LOCALE
): StreamingMarkdownBufferState {
return { content, displayContent, locale };
}
export function resolveStreamingMarkdownDisplayContent(
content: string,
displayContent: string,
streamDone: boolean
) {
if (streamDone) {
return content;
}
return content.startsWith(displayContent) ? displayContent : "";
}
function isLineStart(content: string, offset: number) {
return offset === 0 || content[offset - 1] === "\n";
}
function takeMarkdownTableOpener(content: string): MarkdownOpenerMatch | null {
const headerEnd = readLineEnd(content);
const headerLine = content.slice(0, headerEnd).replace(/\r?\n$/, "");
if (!POSSIBLE_TABLE_HEADER_PATTERN.test(headerLine)) {
return null;
}
if (headerEnd >= content.length && !content.endsWith("\n")) {
return { chunk: "" };
}
const afterHeader = content.slice(headerEnd);
const separatorEnd = readLineEnd(afterHeader);
const separatorLine = afterHeader.slice(0, separatorEnd).replace(/\r?\n$/, "");
if (!separatorLine) {
return { chunk: "" };
}
if (!isTableSeparatorLine(separatorLine)) {
return null;
}
return {
chunk: content.slice(0, headerEnd + separatorEnd),
};
}
function isStreamingTableOpen(displayContent: string) {
const lines = displayContent.replace(/\r\n/g, "\n").split("\n");
while (lines.at(-1) === "") {
lines.pop();
}
const blockLines: string[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line.trim()) {
break;
}
if (!isTableLine(line)) {
break;
}
blockLines.unshift(line);
}
return (
blockLines.length >= 2 &&
blockLines.every(isTableLine) &&
blockLines.some(isTableSeparatorLine)
);
}
function takeMarkdownOpener(
content: string,
{ allowTable = true }: { allowTable?: boolean } = {}
): MarkdownOpenerMatch | null {
if (!content) {
return null;
}
const lineEnd = readLineEnd(content);
const firstLine = content.slice(0, lineEnd);
const lineWithoutEnding = firstLine.replace(/\r?\n$/, "");
const fence = readFenceStart(lineWithoutEnding);
if (fence) {
return firstLine.endsWith("\n") ? { chunk: firstLine } : { chunk: "" };
}
const tableOpener = allowTable ? takeMarkdownTableOpener(content) : null;
if (tableOpener) {
return tableOpener;
}
const firstNonSyntaxIndex = (pattern: RegExp) => {
const match = pattern.exec(content);
return match ? match[0].length : 0;
};
const headingLength = firstNonSyntaxIndex(HEADING_OPENER_PATTERN);
if (headingLength > 0) {
return { chunk: content.slice(0, headingLength) };
}
if (PARTIAL_HEADING_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const listLength = firstNonSyntaxIndex(LIST_OPENER_PATTERN);
if (listLength > 0) {
return { chunk: content.slice(0, listLength) };
}
if (PARTIAL_LIST_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const blockquoteLength = firstNonSyntaxIndex(BLOCKQUOTE_OPENER_PATTERN);
if (blockquoteLength > 0) {
return { chunk: content.slice(0, blockquoteLength) };
}
if (PARTIAL_BLOCKQUOTE_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const ruleMatch = HORIZONTAL_RULE_PATTERN.exec(content);
if (ruleMatch) {
return { chunk: ruleMatch[0] };
}
return null;
}
export function isMarkdownOpenerBoundary(content: string, offset = 0) {
if (!isLineStart(content, offset)) {
return false;
}
const opener = takeMarkdownOpener(content.slice(offset));
return Boolean(opener?.chunk);
}
function findNextPotentialMarkdownOpenerIndex(content: string) {
LINE_START_PATTERN.lastIndex = 0;
for (const match of content.matchAll(LINE_START_PATTERN)) {
const index = match.index + (match[0] === "\n" ? 1 : 0);
if (index > 0) {
return index;
}
}
return -1;
}
function takeSegmentedMarkdownChunk(
content: string,
locale: string,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const pendingSegments = splitStreamingText(content, locale);
const candidate = takeStreamingTokenBlock(pendingSegments, blockSize);
if (!candidate) {
return "";
}
const nextOpenerIndex = findNextPotentialMarkdownOpenerIndex(candidate);
return nextOpenerIndex > 0 ? candidate.slice(0, nextOpenerIndex) : candidate;
}
export function takeNextStreamingMarkdownChunk(
state: StreamingMarkdownBufferState,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const displayContent = state.content.startsWith(state.displayContent)
? state.displayContent
: "";
const pendingContent = state.content.slice(displayContent.length);
if (!pendingContent) {
return {
chunk: "",
state: { ...state, displayContent },
};
}
const opener = isLineStart(state.content, displayContent.length)
? takeMarkdownOpener(pendingContent, {
allowTable: !isStreamingTableOpen(displayContent),
})
: null;
const chunk = opener
? opener.chunk
: takeSegmentedMarkdownChunk(pendingContent, state.locale, blockSize);
const nextDisplayContent = `${displayContent}${chunk}`;
return {
chunk,
state: {
...state,
displayContent: nextDisplayContent,
},
};
}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/shared/ai-elements/message";
import {
createStreamingMarkdownBufferState,
resolveStreamingMarkdownDisplayContent,
takeNextStreamingMarkdownChunk,
} from "./streaming-token-response-state";
type StreamingTokenResponseProps = {
content: string;
streaming: boolean;
streamDone?: boolean;
className?: string;
messageId?: string;
};
const STREAMING_MARKDOWN_FRAME_MS = 45;
export function StreamingTokenResponse({
content,
streaming,
streamDone = !streaming,
className,
messageId,
}: StreamingTokenResponseProps) {
const [displayContent, setDisplayContent] = useState(() =>
streamDone ? content : ""
);
useEffect(() => {
setDisplayContent((currentDisplayContent) =>
resolveStreamingMarkdownDisplayContent(
content,
currentDisplayContent,
streamDone
)
);
}, [content, messageId, streamDone]);
useEffect(() => {
if (streamDone || !streaming) {
return;
}
const showNextChunk = () => {
setDisplayContent((currentDisplayContent) => {
const { chunk, state } = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content, currentDisplayContent)
);
if (chunk || state.displayContent !== currentDisplayContent) {
return state.displayContent;
}
return currentDisplayContent;
});
};
showNextChunk();
const timer = window.setInterval(showNextChunk, STREAMING_MARKDOWN_FRAME_MS);
return () => window.clearInterval(timer);
}, [content, streamDone, streaming]);
if (!displayContent) {
return null;
}
return (
<MessageResponse
className={cn("agent-streaming-response", className)}
isAnimating={streaming}
mode="streaming"
parseIncompleteMarkdown={true}
>
{displayContent}
</MessageResponse>
);
}