fix: stabilize agent UI and SCADA card

This commit is contained in:
2026-07-20 17:17:55 +08:00
parent df2fb3ca38
commit 319c604e9d
12 changed files with 408 additions and 700 deletions
@@ -14,7 +14,10 @@ type AgentCollapsedRailProps = {
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
return (
<aside className={cn("agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
<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="展开排水助手面板"
@@ -43,7 +43,6 @@ import { cn } from "@/lib/utils";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
@@ -157,6 +156,7 @@ export function AgentCommandPanel({
return (
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"agent-panel-shell",
@@ -166,7 +166,7 @@ export function AgentCommandPanel({
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<div className="agent-panel-band border-b border-slate-200">
<header className="agent-panel-header acrylic-panel">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona
@@ -274,7 +274,7 @@ export function AgentCommandPanel({
</motion.div>
) : null}
</AnimatePresence>
</div>
</header>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
@@ -310,8 +310,6 @@ export function AgentCommandPanel({
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
uiResults={uiResults}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
@@ -382,33 +380,27 @@ type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastUserMessageId: string | null;
scrollRequestId: number;
signature: string;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId,
streaming,
uiResults
scrollRequestId
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
uiResults: AgentUiResult[];
}) {
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext();
const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom);
const lastUserMessageId = getLastUserMessageId(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastUserMessageId: getLastUserMessageId(messages),
scrollRequestId,
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
lastUserMessageId,
scrollRequestId
}),
[activeSessionId, messages, scrollRequestId, streaming, uiResults]
[activeSessionId, lastUserMessageId, scrollRequestId]
);
useEffect(() => {
@@ -420,22 +412,21 @@ function AgentConversationScrollManager({
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)) {
if (!forceScroll) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({
animation: "instant",
ignoreEscapes: forceScroll,
wait: !forceScroll
ignoreEscapes: true,
wait: false
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot, wasPinnedToBottom]);
}, [scrollToBottom, snapshot]);
return null;
}
@@ -450,30 +441,6 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
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,
@@ -675,13 +642,11 @@ function BackendMessageList({
{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}
@@ -16,6 +16,7 @@ import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import {
agentEase,
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
@@ -117,20 +118,20 @@ export function AgentMessageDetails({
}
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<motion.div className="space-y-2" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<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" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<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" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<motion.div key="questions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
@@ -184,7 +185,6 @@ function AgentNestedBlock({
function AnimatedDetailItem({ children }: { children: ReactNode }) {
return (
<motion.div
layout
initial="initial"
animate="animate"
exit="exit"
@@ -198,8 +198,8 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const recentProgress = progress.slice(-3);
const visibleProgress = expanded ? progress : running ? recentProgress : [];
const visibleProgress = expanded ? progress : progress.slice(-1);
const listMode = expanded ? "expanded" : "summary";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
@@ -216,7 +216,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{expanded
? `全部 ${progress.length}`
: running
? `最近 ${recentProgress.length}`
? "当前步骤"
: `${progress.length}`}
</span>
<ChevronDown
@@ -225,43 +225,68 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
aria-hidden="true"
/>
</button>
<AnimatePresence initial={false}>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
aria-label={expanded ? "全部执行进度" : "当前执行进度"}
className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition}
>
{visibleProgress.map((item) => (
<li key={item.id} className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs">
<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
)}
<AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}>
{visibleProgress.map((item) => (
<motion.li
key={item.id}
className="grid min-h-7 grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
{progressStatusMeta[item.status].label}
</span>
</li>
))}
<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] } : { opacity: 1 }}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: { duration: 0.14 }
}
/>
<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>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={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={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14 }}
>
{progressStatusMeta[item.status].label}
</motion.span>
</AnimatePresence>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
) : null}
</AnimatePresence>
@@ -277,7 +302,6 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
{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"
@@ -29,7 +29,6 @@ export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProp
{results.map((result) => (
<motion.div
key={result.id}
layout
initial="initial"
animate="animate"
exit="exit"
@@ -1,301 +0,0 @@
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,
},
};
}
@@ -1,14 +1,7 @@
"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;
@@ -18,66 +11,26 @@ type StreamingTokenResponseProps = {
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) {
if (!content) {
return null;
}
const isAnimating = streaming && !streamDone;
return (
<MessageResponse
className={cn("agent-streaming-response", className)}
isAnimating={streaming}
isAnimating={isAnimating}
mode="streaming"
parseIncompleteMarkdown={true}
>
{displayContent}
{content}
</MessageResponse>
);
}