fix: stabilize agent UI and SCADA card
This commit is contained in:
+10
-3
@@ -324,6 +324,16 @@ input:focus-visible {
|
||||
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 {
|
||||
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-fade 150ms ease-out;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-panel-collapse {
|
||||
@@ -516,7 +525,6 @@ input:focus-visible {
|
||||
agent-panel-dim 150ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-rail-enter {
|
||||
@@ -524,7 +532,6 @@ input:focus-visible {
|
||||
agent-rail-shell 170ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
agent-panel-fade 130ms ease-out;
|
||||
transform-origin: left top;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-rail-item {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return (
|
||||
<DrainageIconFrame {...props}>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Database,
|
||||
MapPin,
|
||||
Radio,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Check, Copy, Database, X } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -17,11 +9,9 @@ import {
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getFeaturePanelModel,
|
||||
type FeaturePanelBadgeTone
|
||||
} from "../utils/feature-properties";
|
||||
@@ -31,6 +21,7 @@ import {
|
||||
OrificeFeatureIcon,
|
||||
OutfallFeatureIcon,
|
||||
PumpFeatureIcon,
|
||||
ScadaFeatureIcon,
|
||||
type DrainageFeatureIconProps
|
||||
} 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" },
|
||||
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" },
|
||||
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> = {
|
||||
@@ -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 (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -199,104 +178,3 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
</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;
|
||||
}
|
||||
Reference in New Issue
Block a user