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
+10 -3
View File
@@ -324,6 +324,16 @@ input:focus-visible {
border-color: var(--acrylic-border); border-color: var(--acrylic-border);
} }
.agent-panel-header {
position: relative;
z-index: 1;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 10px 24px rgba(15, 23, 42, 0.07);
}
.agent-panel-band { .agent-panel-band {
background: var(--surface-control); background: var(--surface-control);
} }
@@ -507,7 +517,6 @@ input:focus-visible {
agent-panel-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1), agent-panel-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1),
agent-panel-fade 150ms ease-out; agent-panel-fade 150ms ease-out;
transform-origin: left top; transform-origin: left top;
will-change: opacity, transform;
} }
.agent-panel-collapse { .agent-panel-collapse {
@@ -516,7 +525,6 @@ input:focus-visible {
agent-panel-dim 150ms ease-in forwards; agent-panel-dim 150ms ease-in forwards;
pointer-events: none; pointer-events: none;
transform-origin: left top; transform-origin: left top;
will-change: opacity, transform;
} }
.agent-rail-enter { .agent-rail-enter {
@@ -524,7 +532,6 @@ input:focus-visible {
agent-rail-shell 170ms cubic-bezier(0.22, 1, 0.36, 1), agent-rail-shell 170ms cubic-bezier(0.22, 1, 0.36, 1),
agent-panel-fade 130ms ease-out; agent-panel-fade 130ms ease-out;
transform-origin: left top; transform-origin: left top;
will-change: opacity, transform;
} }
.agent-rail-item { .agent-rail-item {
@@ -14,7 +14,10 @@ type AgentCollapsedRailProps = {
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) { export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
return ( 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 <button
type="button" type="button"
aria-label="展开排水助手面板" aria-label="展开排水助手面板"
@@ -43,7 +43,6 @@ import { cn } from "@/lib/utils";
import type { AgentChatSessionSummary } from "../api/client"; import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona"; import { AgentPersona } from "./agent-persona";
import { import {
agentLayoutTransition,
agentPanelItemVariants, agentPanelItemVariants,
agentPanelListVariants, agentPanelListVariants,
agentPanelSectionVariants agentPanelSectionVariants
@@ -157,6 +156,7 @@ export function AgentCommandPanel({
return ( return (
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
<aside <aside
aria-label="Agent 命令面板"
className={cn( className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden", "pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"agent-panel-shell", "agent-panel-shell",
@@ -166,7 +166,7 @@ export function AgentCommandPanel({
)} )}
> >
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent"> <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-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"> <div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona <AgentPersona
@@ -274,7 +274,7 @@ export function AgentCommandPanel({
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</div> </header>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0"> <AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0"> <Conversation className="agent-panel-conversation min-h-0">
@@ -310,8 +310,6 @@ export function AgentCommandPanel({
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
messages={messages} messages={messages}
scrollRequestId={scrollRequestId} scrollRequestId={scrollRequestId}
streaming={streaming}
uiResults={uiResults}
/> />
<ConversationScrollButton className="bottom-3" /> <ConversationScrollButton className="bottom-3" />
</Conversation> </Conversation>
@@ -382,33 +380,27 @@ type AgentConversationScrollSnapshot = {
activeSessionId?: string | null; activeSessionId?: string | null;
lastUserMessageId: string | null; lastUserMessageId: string | null;
scrollRequestId: number; scrollRequestId: number;
signature: string;
}; };
function AgentConversationScrollManager({ function AgentConversationScrollManager({
activeSessionId, activeSessionId,
messages, messages,
scrollRequestId, scrollRequestId
streaming,
uiResults
}: { }: {
activeSessionId?: string | null; activeSessionId?: string | null;
messages: AgentChatMessage[]; messages: AgentChatMessage[];
scrollRequestId: number; scrollRequestId: number;
streaming: boolean;
uiResults: AgentUiResult[];
}) { }) {
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext(); const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null); const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom); const lastUserMessageId = getLastUserMessageId(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>( const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({ () => ({
activeSessionId, activeSessionId,
lastUserMessageId: getLastUserMessageId(messages), lastUserMessageId,
scrollRequestId, scrollRequestId
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
}), }),
[activeSessionId, messages, scrollRequestId, streaming, uiResults] [activeSessionId, lastUserMessageId, scrollRequestId]
); );
useEffect(() => { useEffect(() => {
@@ -420,22 +412,21 @@ function AgentConversationScrollManager({
previousSnapshot.activeSessionId !== snapshot.activeSessionId || previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId || previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId); (Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature;
if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) { if (!forceScroll) {
return; return;
} }
const animationFrameId = window.requestAnimationFrame(() => { const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({ void scrollToBottom({
animation: "instant", animation: "instant",
ignoreEscapes: forceScroll, ignoreEscapes: true,
wait: !forceScroll wait: false
}); });
}); });
return () => window.cancelAnimationFrame(animationFrameId); return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot, wasPinnedToBottom]); }, [scrollToBottom, snapshot]);
return null; return null;
} }
@@ -450,30 +441,6 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
return null; 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({ function AgentModelSelect({
models, models,
value, value,
@@ -675,13 +642,11 @@ function BackendMessageList({
{messages.map((message, index) => ( {messages.map((message, index) => (
<motion.div <motion.div
key={message.id} key={message.id}
layout
className={cn("w-full", message.role === "user" && "flex justify-end")} className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" exit="exit"
variants={agentPanelItemVariants} variants={agentPanelItemVariants}
transition={agentLayoutTransition}
> >
<Message <Message
from={message.role} from={message.role}
@@ -16,6 +16,7 @@ import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
agentEase,
agentLayoutTransition, agentLayoutTransition,
agentPanelItemVariants, agentPanelItemVariants,
agentPanelListVariants, agentPanelListVariants,
@@ -117,20 +118,20 @@ export function AgentMessageDetails({
} }
return ( 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"> <AnimatePresence initial={false} mode="popLayout">
{message.todos ? ( {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} /> <TodoCard todoUpdate={message.todos} />
</motion.div> </motion.div>
) : null} ) : null}
{message.permissions?.length ? ( {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} /> <PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div> </motion.div>
) : null} ) : null}
{message.questions?.length ? ( {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 <QuestionList
questions={message.questions} questions={message.questions}
onReplyQuestion={onReplyQuestion} onReplyQuestion={onReplyQuestion}
@@ -184,7 +185,6 @@ function AgentNestedBlock({
function AnimatedDetailItem({ children }: { children: ReactNode }) { function AnimatedDetailItem({ children }: { children: ReactNode }) {
return ( return (
<motion.div <motion.div
layout
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" exit="exit"
@@ -198,8 +198,8 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const recentProgress = progress.slice(-3); const visibleProgress = expanded ? progress : progress.slice(-1);
const visibleProgress = expanded ? progress : running ? recentProgress : []; const listMode = expanded ? "expanded" : "summary";
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5"> <div className="agent-panel-nested rounded-xl p-2.5">
@@ -216,7 +216,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{expanded {expanded
? `全部 ${progress.length}` ? `全部 ${progress.length}`
: running : running
? `最近 ${recentProgress.length}` ? "当前步骤"
: `${progress.length}`} : `${progress.length}`}
</span> </span>
<ChevronDown <ChevronDown
@@ -225,43 +225,68 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
<AnimatePresence initial={false}> <AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? ( {visibleProgress.length ? (
<motion.ol <motion.ol
key={listMode}
aria-label={expanded ? "全部执行进度" : "当前执行进度"}
className="mt-2 space-y-1.5 overflow-hidden" className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition} transition={agentLayoutTransition}
> >
{visibleProgress.map((item) => ( <AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}>
<li key={item.id} className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"> {visibleProgress.map((item) => (
<span <motion.li
className={cn( key={item.id}
"mt-0.5 h-3.5 w-3.5 rounded-full border", className="grid min-h-7 grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
progressStatusMeta[item.status].dotClassName initial={{ opacity: 0, y: 4 }}
)} animate={{ opacity: 1, y: 0 }}
/> exit={{ opacity: 0, y: -2 }}
<span className="min-w-0"> transition={{ duration: 0.16, ease: agentEase }}
<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} <motion.span
</span> aria-hidden="true"
</li> 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> </motion.ol>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
@@ -277,7 +302,6 @@ function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
{todoUpdate.todos.slice(0, 6).map((todo) => ( {todoUpdate.todos.slice(0, 6).map((todo) => (
<motion.li <motion.li
key={todo.id} key={todo.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs" className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial" initial="initial"
animate="animate" animate="animate"
@@ -29,7 +29,6 @@ export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProp
{results.map((result) => ( {results.map((result) => (
<motion.div <motion.div
key={result.id} key={result.id}
layout
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" 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"; "use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageResponse } from "@/shared/ai-elements/message"; import { MessageResponse } from "@/shared/ai-elements/message";
import {
createStreamingMarkdownBufferState,
resolveStreamingMarkdownDisplayContent,
takeNextStreamingMarkdownChunk,
} from "./streaming-token-response-state";
type StreamingTokenResponseProps = { type StreamingTokenResponseProps = {
content: string; content: string;
@@ -18,66 +11,26 @@ type StreamingTokenResponseProps = {
messageId?: string; messageId?: string;
}; };
const STREAMING_MARKDOWN_FRAME_MS = 45;
export function StreamingTokenResponse({ export function StreamingTokenResponse({
content, content,
streaming, streaming,
streamDone = !streaming, streamDone = !streaming,
className, className,
messageId,
}: StreamingTokenResponseProps) { }: StreamingTokenResponseProps) {
const [displayContent, setDisplayContent] = useState(() => if (!content) {
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 null;
} }
const isAnimating = streaming && !streamDone;
return ( return (
<MessageResponse <MessageResponse
className={cn("agent-streaming-response", className)} className={cn("agent-streaming-response", className)}
isAnimating={streaming} isAnimating={isAnimating}
mode="streaming" mode="streaming"
parseIncompleteMarkdown={true} parseIncompleteMarkdown={true}
> >
{displayContent} {content}
</MessageResponse> </MessageResponse>
); );
} }
@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
createStreamingMarkdownBufferState,
isMarkdownOpenerBoundary,
resolveStreamingMarkdownDisplayContent,
splitStreamingText,
splitStreamingTextFallback,
takeNextStreamingMarkdownChunk,
} from "./components/streaming-token-response-state";
describe("streaming token response state", () => {
it("splits streaming text without dropping content", () => {
const content = "管网压力异常需要继续分析";
const chunks = splitStreamingText(content, "zh");
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe(content);
});
it("uses bounded code point chunks for fallback segmentation", () => {
expect(splitStreamingTextFallback("管网压力")).toEqual(["管网", "压力"]);
});
it("emits continuous Chinese text without waiting for markdown block closure", () => {
let state = createStreamingMarkdownBufferState("正在分析管网压力异常需要继续分析");
const chunks: string[] = [];
for (let index = 0; index < 20; index += 1) {
const next = takeNextStreamingMarkdownChunk(state);
state = next.state;
if (!next.chunk) {
break;
}
chunks.push(next.chunk);
}
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe("正在分析管网压力异常需要继续分析");
});
it("flushes a heading opener atomically", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### 压力分析")
);
expect(next.chunk).toBe("### 压");
expect(isMarkdownOpenerBoundary("### 压力分析")).toBe(true);
});
it("waits for a heading opener to include text", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### ")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes list and quote openers atomically", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("- 节点 A")
).chunk
).toBe("- 节");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("> 注意压力")
).chunk
).toBe("> 注");
});
it("flushes code and mermaid fence openers as complete lines", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts\nconst pressure = 1;")
).chunk
).toBe("```ts\n");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```mermaid\ngraph TD")
).chunk
).toBe("```mermaid\n");
});
it("waits for fenced code openers to reach the line ending", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes table header and separator rows atomically", () => {
const content = "| 节点 | 压力 |\n| --- | --- |\n| J1 | 0.32 |\n\n后续";
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content)
).chunk
).toBe("| 节点 | 压力 |\n| --- | --- |\n");
});
it("grows table rows after the table opener is visible", () => {
const tableOpener = "| 节点 | 压力 |\n| --- | --- |\n";
const state = createStreamingMarkdownBufferState(
`${tableOpener}| J1 | 0.32 |`,
tableOpener
);
expect(takeNextStreamingMarkdownChunk(state).chunk).not.toBe("");
});
it("flushes all display content after streaming is done", () => {
const content = "前文\n\n```ts\nconst pressure = 1;";
expect(resolveStreamingMarkdownDisplayContent(content, "前文", true)).toBe(content);
});
it("StreamingTokenResponse does not use closed-block markdown gating", () => {
const source = readFileSync(
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
"utf8"
);
expect(source).not.toContain("splitStreamingMarkdownContent");
});
});
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("StreamingTokenResponse", () => {
it("renders the backend-smoothed stream without a second timer buffer", () => {
const source = readFileSync(
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
"utf8"
);
expect(source).not.toContain("setInterval");
expect(source).not.toContain("useState");
expect(source).not.toContain("streaming-token-response-state");
expect(source).toContain("{content}");
});
});
@@ -47,6 +47,17 @@ export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
); );
} }
export function ScadaFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="12" cy="7.5" r="1.75" />
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
</DrainageIconFrame>
);
}
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) { export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
return ( return (
<DrainageIconFrame {...props}> <DrainageIconFrame {...props}>
@@ -1,14 +1,6 @@
"use client"; "use client";
import { import { Check, Copy, Database, X } from "lucide-react";
Check,
Copy,
Database,
MapPin,
Radio,
X
} from "lucide-react";
import Image from "next/image";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
@@ -17,11 +9,9 @@ import {
} from "@/features/map/core/components/map-control-styles"; } from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice"; import { showMapNotice } from "@/features/map/core/components/notice";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SCADA_ICON_PATHS } from "../map/scada";
import type { WaterNetworkSourceId } from "../map/sources"; import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { import {
formatFeaturePropertyValue,
getFeaturePanelModel, getFeaturePanelModel,
type FeaturePanelBadgeTone type FeaturePanelBadgeTone
} from "../utils/feature-properties"; } from "../utils/feature-properties";
@@ -31,6 +21,7 @@ import {
OrificeFeatureIcon, OrificeFeatureIcon,
OutfallFeatureIcon, OutfallFeatureIcon,
PumpFeatureIcon, PumpFeatureIcon,
ScadaFeatureIcon,
type DrainageFeatureIconProps type DrainageFeatureIconProps
} from "./drainage-feature-icons"; } from "./drainage-feature-icons";
@@ -54,7 +45,7 @@ const FEATURE_LAYER_META: Record<
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" }, orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" }, outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" }, pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-blue-700 text-white ring-blue-900/10" } scada: { icon: ScadaFeatureIcon, label: "SCADA 测点", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" }
}; };
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = { const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
@@ -116,18 +107,6 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
} }
}; };
if (feature.layer === "scada") {
return (
<ScadaFeaturePopover
feature={feature}
copied={copied}
canCopy={canCopy}
onCopy={() => void handleCopyId()}
onClose={onClose}
/>
);
}
return ( return (
<aside <aside
className={cn( className={cn(
@@ -199,104 +178,3 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
</aside> </aside>
); );
} }
type ScadaFeaturePopoverProps = {
feature: DetailFeature;
copied: boolean;
canCopy: boolean;
onCopy: () => void;
onClose: () => void;
};
function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: ScadaFeaturePopoverProps) {
const properties = feature.properties;
const presentation = getScadaPresentation(properties.kind);
const active = properties.active === true || String(properties.active).toLowerCase() === "true";
const scadaId = formatFeaturePropertyValue("scada_id", properties.scada_id ?? feature.id);
const attributes = [
["站点编号", formatFeaturePropertyValue("site_external_id", properties.site_external_id)],
["设备编号", formatFeaturePropertyValue("external_id", properties.external_id)],
["关联检查井", formatFeaturePropertyValue("junction_id", properties.junction_id)],
["同步时间", formatFeaturePropertyValue("synced_at", properties.synced_at)]
];
return (
<aside
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))]",
MAP_FOCUS_SURFACE_CLASS_NAME,
"-translate-x-1/2 overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
<header className={cn("px-4 pb-3 pt-4", presentation.headerClassName)}>
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
<Image src={presentation.iconPath} alt="" width={48} height={48} className="drop-shadow-sm" />
<div className="min-w-0">
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
<span className={cn("min-w-0 truncate text-xs font-semibold", presentation.labelClassName)} title={formatFeaturePropertyValue("kind", properties.kind)}>
{formatFeaturePropertyValue("kind", properties.kind)}
</span>
<span className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
active ? "bg-emerald-500/10" : "bg-slate-500/10",
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20"
)}>
<span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
{active ? "在线" : "离线"}
</span>
</div>
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">
{formatFeaturePropertyValue("name", properties.name)}
</h3>
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums"> UUID {scadaId}</p>
</div>
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "测点编号已复制" : "复制测点编号"} title={copied ? "测点编号已复制" : "复制测点编号"} onClick={onCopy} className="relative grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭 SCADA 测点信息" title="关闭 SCADA 测点信息" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<X size={16} aria-hidden="true" />
</button>
</div>
</div>
</header>
<div className="px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
<Radio size={14} aria-hidden="true" />
</div>
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
{attributes.map(([label, value]) => (
<div key={label} className="grid min-h-10 grid-cols-[92px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
<dt className="text-xs font-medium text-slate-500">{label}</dt>
<dd className={cn("break-words text-right text-sm font-semibold text-slate-800 tabular-nums", value === "暂无" && "font-medium text-slate-400")}>{value}</dd>
</div>
))}
</dl>
<div className="surface-reading mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl px-3 py-2.5 text-xs leading-5 text-slate-600">
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
<p></p>
</div>
<div className="mt-2 flex items-center gap-2 px-1 text-xs text-slate-400">
<Database size={13} aria-hidden="true" />
线
</div>
</div>
<span className="sr-only" aria-live="polite">{copied ? "测点编号已复制" : ""}</span>
</aside>
);
}
function getScadaPresentation(rawKind: unknown) {
const kind = String(rawKind ?? "");
if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800" };
if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800" };
if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50/70", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700" };
}
@@ -0,0 +1,284 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "playwright/test";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
let streamServer: Server;
let streamServerUrl: string;
test.beforeAll(async () => {
streamServer = createServer((_request, response) => {
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
});
const content = createLongMarkdownResponse();
const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE);
const send = (chunk: Record<string, unknown>) => {
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
};
send({ type: "start", messageId: "assistant-stream-stability" });
send({
type: "data-progress",
id: "inspect-context",
data: {
id: "inspect-context",
phase: "planning",
status: "running",
title: "正在分析运行上下文",
detail: "核对泵站、液位与降雨过程。",
started_at: Date.now()
}
});
send({
type: "data-todo_update",
id: "stream-stability-plan",
data: {
session_id: "stream-stability-session",
message_id: "assistant-stream-stability",
created_at: Date.now(),
todos: [
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
{ id: "inspect-level", content: "核对液位计连续性", status: "pending" },
{ id: "assess-overflow", content: "判断溢流风险", status: "pending" }
]
}
});
send({ type: "text-start", id: "answer" });
let index = 0;
const timer = setInterval(() => {
const delta = chunks[index];
if (delta !== undefined) {
index += 1;
if (index === 3) {
send({
type: "data-progress",
id: "assess-risk",
data: {
id: "assess-risk",
phase: "analysis",
status: "running",
title: "正在判断溢流风险",
detail: "结合流量突变与液位趋势形成判断。",
started_at: Date.now()
}
});
}
send({ type: "text-delta", id: "answer", delta });
send({
type: "data-stream_token",
data: {
session_id: "stream-stability-session",
message_id: "assistant-stream-stability",
content: delta
},
transient: true
});
return;
}
clearInterval(timer);
send({
type: "data-progress",
id: "assess-risk",
data: {
id: "assess-risk",
phase: "analysis",
status: "completed",
title: "溢流风险判断完成",
detail: "已完成风险点位排序。",
ended_at: Date.now()
}
});
send({ type: "text-end", id: "answer" });
send({ type: "finish", finishReason: "stop" });
response.end();
}, STREAM_CHUNK_DELAY_MS);
response.on("close", () => clearInterval(timer));
});
await new Promise<void>((resolve) => streamServer.listen(0, "127.0.0.1", resolve));
const address = streamServer.address() as AddressInfo;
streamServerUrl = `http://127.0.0.1:${address.port}/stream`;
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) => {
streamServer.close((error) => (error ? reject(error) : resolve()));
});
});
test("stream completion and the plan card remain positionally stable", async ({ page }) => {
await page.route("**/api/v1/agent/chat/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/stream")) {
await route.continue({ url: streamServerUrl });
return;
}
const body = path.endsWith("/models")
? {
default_model: "test/model",
models: [
{
id: "test/model",
label: "测试模型",
description: "流式稳定性测试",
icon: "bolt"
}
]
}
: path.endsWith("/sessions")
? { sessions: [] }
: {};
await route.fulfill({ json: body });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
).toContainText("测试模型");
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
await expect(prompt).toBeVisible();
await prompt.fill("检查流式响应稳定性");
const sendButton = page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first();
await expect(sendButton).toBeEnabled();
await page.evaluate(() => {
type StreamSample = {
clientHeight: number;
planItemTransform: string | null;
planSectionTransform: string | null;
planTransform: string | null;
scrollHeight: number;
scrollTop: number;
time: number;
transform: string;
};
const samples: StreamSample[] = [];
const startedAt = performance.now();
(window as typeof window & { __agentStreamSamples?: StreamSample[] }).__agentStreamSamples = samples;
const sample = () => {
const message = document.querySelector<HTMLElement>(".agent-panel-message");
const wrapper = message?.parentElement?.parentElement;
const scroll = document.querySelector<HTMLElement>(".agent-conversation-scroll");
const plan = Array.from(document.querySelectorAll<HTMLElement>(".agent-panel-nested")).find((element) =>
element.textContent?.includes("计划")
);
const planWrapper = plan?.parentElement;
const planSection = planWrapper?.parentElement;
const planItem = plan?.querySelector("li");
if (message && wrapper && scroll) {
samples.push({
clientHeight: scroll.clientHeight,
planItemTransform: planItem ? getComputedStyle(planItem).transform : null,
planSectionTransform: planSection ? getComputedStyle(planSection).transform : null,
planTransform: planWrapper ? getComputedStyle(planWrapper).transform : null,
scrollHeight: scroll.scrollHeight,
scrollTop: scroll.scrollTop,
time: performance.now() - startedAt,
transform: getComputedStyle(wrapper).transform
});
}
if (performance.now() - startedAt < 8_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
await sendButton.click();
await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({
timeout: 10_000
});
const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first();
await expect(progressToggle).toContainText("共 2 条");
await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }).first()).toBeVisible();
await page.waitForTimeout(400);
const samples = await page.evaluate(() =>
(window as typeof window & {
__agentStreamSamples?: Array<{
clientHeight: number;
planItemTransform: string | null;
planSectionTransform: string | null;
planTransform: string | null;
scrollHeight: number;
scrollTop: number;
time: number;
transform: string;
}>;
}).__agentStreamSamples ?? []
);
expect(samples.length).toBeGreaterThan(5);
const firstSampleTime = samples[0].time;
const settledEntranceSamples = samples.filter((sample) => sample.time - firstSampleTime >= 250);
expect(settledEntranceSamples.length).toBeGreaterThan(0);
expect(settledEntranceSamples.every((sample) => sample.transform === "none")).toBe(true);
const planSamples = samples.filter(
(sample) =>
sample.planItemTransform !== null &&
sample.planSectionTransform !== null &&
sample.planTransform !== null
);
expect(planSamples.length).toBeGreaterThan(0);
const firstPlanSampleTime = planSamples[0].time;
const settledPlanSamples = planSamples.filter((sample) => sample.time - firstPlanSampleTime >= 250);
expect(settledPlanSamples.length).toBeGreaterThan(0);
expect(
settledPlanSamples.every(
(sample) =>
sample.planItemTransform === "none" &&
sample.planSectionTransform === "none" &&
sample.planTransform === "none"
)
).toBe(true);
const finalSample = samples.at(-1);
expect(finalSample).toBeDefined();
expect(
Math.abs((finalSample!.scrollHeight - finalSample!.clientHeight) - finalSample!.scrollTop)
).toBeLessThanOrEqual(2);
await progressToggle.click();
await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible();
});
function createLongMarkdownResponse() {
let content = "## 诊断结果\n\n";
for (let index = 1; index <= 6; index += 1) {
content += `### 区域 ${index}\n\n`;
content += "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
content += "- 检查泵站运行状态与实时流量。\n";
content += "- 核对液位计连续性和异常峰值。\n";
content += "- 结合降雨过程判断溢流风险。\n\n";
}
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
}
function splitIntoCodePointChunks(content: string, chunkSize: number) {
const codePoints = Array.from(content);
const chunks: string[] = [];
for (let index = 0; index < codePoints.length; index += chunkSize) {
chunks.push(codePoints.slice(index, index + chunkSize).join(""));
}
return chunks;
}