fix: refine agent progress feedback
This commit is contained in:
@@ -37,6 +37,8 @@ type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
|
|||||||
type PermissionStatus = AgentPermissionRequest["status"];
|
type PermissionStatus = AgentPermissionRequest["status"];
|
||||||
type QuestionStatus = AgentQuestionRequest["status"];
|
type QuestionStatus = AgentQuestionRequest["status"];
|
||||||
|
|
||||||
|
const STREAMING_PROGRESS_LIMIT = 3;
|
||||||
|
|
||||||
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
|
||||||
running: {
|
running: {
|
||||||
label: "进行中",
|
label: "进行中",
|
||||||
@@ -155,7 +157,7 @@ export function AgentMessageProgress({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProgressList progress={progress} running={running} />;
|
return <ProgressList key={running ? "running" : "settled"} progress={progress} running={running} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentNestedBlock({
|
function AgentNestedBlock({
|
||||||
@@ -198,30 +200,35 @@ 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 visibleProgress = expanded ? progress : progress.slice(-1);
|
const visibleProgress = running
|
||||||
const listMode = expanded ? "expanded" : "summary";
|
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||||
|
: expanded
|
||||||
|
? progress
|
||||||
|
: [];
|
||||||
|
const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600 disabled:cursor-default"
|
||||||
aria-expanded={expanded}
|
aria-expanded={running || expanded}
|
||||||
|
disabled={running}
|
||||||
onClick={() => setExpanded((current) => !current)}
|
onClick={() => setExpanded((current) => !current)}
|
||||||
>
|
>
|
||||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||||
<span>执行进度</span>
|
<span>执行进度</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<span className="shrink-0 font-normal text-slate-400">
|
<span className="shrink-0 font-normal text-slate-400">
|
||||||
{expanded
|
{running
|
||||||
? `全部 ${progress.length} 条`
|
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||||
: running
|
: expanded
|
||||||
? "当前步骤"
|
? `全部 ${progress.length} 条`
|
||||||
: `共 ${progress.length} 条`}
|
: `共 ${progress.length} 条`}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
|
className={cn("shrink-0 text-slate-400 transition-transform", !running && expanded && "rotate-180")}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -229,7 +236,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
{visibleProgress.length ? (
|
{visibleProgress.length ? (
|
||||||
<motion.ol
|
<motion.ol
|
||||||
key={listMode}
|
key={listMode}
|
||||||
aria-label={expanded ? "全部执行进度" : "当前执行进度"}
|
aria-label={!running && 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 }}
|
||||||
@@ -240,7 +247,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
{visibleProgress.map((item) => (
|
{visibleProgress.map((item) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="grid min-h-7 grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
|
className="grid h-12 grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 overflow-hidden text-xs"
|
||||||
initial={{ opacity: 0, y: 4 }}
|
initial={{ opacity: 0, y: 4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -2 }}
|
exit={{ opacity: 0, y: -2 }}
|
||||||
@@ -252,10 +259,18 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
|
||||||
progressStatusMeta[item.status].dotClassName
|
progressStatusMeta[item.status].dotClassName
|
||||||
)}
|
)}
|
||||||
animate={item.status === "running" ? { opacity: [0.45, 1, 0.45] } : { opacity: 1 }}
|
animate={
|
||||||
|
item.status === "running"
|
||||||
|
? { opacity: [0.45, 1, 0.45], scale: 1 }
|
||||||
|
: item.status === "error"
|
||||||
|
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
|
||||||
|
: { opacity: 1, scale: 1 }
|
||||||
|
}
|
||||||
transition={
|
transition={
|
||||||
item.status === "running"
|
item.status === "running"
|
||||||
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
|
||||||
|
: item.status === "error"
|
||||||
|
? { duration: 0.28, ease: agentEase }
|
||||||
: { duration: 0.14 }
|
: { duration: 0.14 }
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -264,7 +279,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
{item.detail ? (
|
{item.detail ? (
|
||||||
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">
|
<span className="block truncate leading-5 text-slate-500" title={item.detail}>
|
||||||
{item.detail}
|
{item.detail}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -272,14 +287,23 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
|||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
<motion.span
|
<motion.span
|
||||||
key={item.status}
|
key={item.status}
|
||||||
|
data-progress-status={item.status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
|
||||||
progressStatusMeta[item.status].className
|
progressStatusMeta[item.status].className
|
||||||
)}
|
)}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={
|
||||||
|
item.status === "error"
|
||||||
|
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
|
||||||
|
: { opacity: 1, x: 0 }
|
||||||
|
}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.14 }}
|
transition={
|
||||||
|
item.status === "error"
|
||||||
|
? { duration: 0.24, ease: agentEase }
|
||||||
|
: { duration: 0.14 }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{progressStatusMeta[item.status].label}
|
{progressStatusMeta[item.status].label}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function AgentOperationalBrief({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-control rounded-2xl p-3">
|
<div className="agent-panel-control rounded-2xl p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
|
||||||
@@ -38,9 +38,6 @@ export function AgentOperationalBrief({
|
|||||||
{brief.task}
|
{brief.task}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-1 text-xs font-semibold text-violet-700">
|
|
||||||
Agent 推断
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 grid grid-cols-3 gap-2">
|
<dl className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const STREAM_CHUNK_DELAY_MS = 20;
|
|||||||
|
|
||||||
let streamServer: Server;
|
let streamServer: Server;
|
||||||
let streamServerUrl: string;
|
let streamServerUrl: string;
|
||||||
|
let failProgress: (() => void) | null = null;
|
||||||
|
let releaseStream: (() => void) | null = null;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
streamServer = createServer((_request, response) => {
|
streamServer = createServer((_request, response) => {
|
||||||
@@ -36,6 +38,30 @@ test.beforeAll(async () => {
|
|||||||
started_at: Date.now()
|
started_at: Date.now()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
send({
|
||||||
|
type: "data-progress",
|
||||||
|
id: "inspect-rainfall",
|
||||||
|
data: {
|
||||||
|
id: "inspect-rainfall",
|
||||||
|
phase: "evidence",
|
||||||
|
status: "completed",
|
||||||
|
title: "正在核对降雨过程与汇水区响应关系",
|
||||||
|
detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。",
|
||||||
|
started_at: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "data-progress",
|
||||||
|
id: "inspect-level",
|
||||||
|
data: {
|
||||||
|
id: "inspect-level",
|
||||||
|
phase: "evidence",
|
||||||
|
status: "completed",
|
||||||
|
title: "正在核对液位连续性",
|
||||||
|
detail: "排除缺测与异常峰值。",
|
||||||
|
started_at: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
send({
|
send({
|
||||||
type: "data-todo_update",
|
type: "data-todo_update",
|
||||||
id: "stream-stability-plan",
|
id: "stream-stability-plan",
|
||||||
@@ -53,24 +79,11 @@ test.beforeAll(async () => {
|
|||||||
send({ type: "text-start", id: "answer" });
|
send({ type: "text-start", id: "answer" });
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const timer = setInterval(() => {
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const sendNextTextChunk = () => {
|
||||||
const delta = chunks[index];
|
const delta = chunks[index];
|
||||||
if (delta !== undefined) {
|
if (delta !== undefined) {
|
||||||
index += 1;
|
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: "text-delta", id: "answer", delta });
|
||||||
send({
|
send({
|
||||||
type: "data-stream_token",
|
type: "data-stream_token",
|
||||||
@@ -84,7 +97,10 @@ test.beforeAll(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInterval(timer);
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
send({
|
send({
|
||||||
type: "data-progress",
|
type: "data-progress",
|
||||||
id: "assess-risk",
|
id: "assess-risk",
|
||||||
@@ -100,9 +116,50 @@ test.beforeAll(async () => {
|
|||||||
send({ type: "text-end", id: "answer" });
|
send({ type: "text-end", id: "answer" });
|
||||||
send({ type: "finish", finishReason: "stop" });
|
send({ type: "finish", finishReason: "stop" });
|
||||||
response.end();
|
response.end();
|
||||||
}, STREAM_CHUNK_DELAY_MS);
|
};
|
||||||
|
|
||||||
response.on("close", () => clearInterval(timer));
|
while (index < 3 && index < chunks.length) {
|
||||||
|
sendNextTextChunk();
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
type: "data-progress",
|
||||||
|
id: "assess-risk",
|
||||||
|
data: {
|
||||||
|
id: "assess-risk",
|
||||||
|
phase: "analysis",
|
||||||
|
status: "running",
|
||||||
|
title: "正在判断溢流风险",
|
||||||
|
detail: "结合流量突变与液位趋势形成判断。",
|
||||||
|
started_at: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
failProgress = () => {
|
||||||
|
send({
|
||||||
|
type: "data-progress",
|
||||||
|
id: "inspect-level",
|
||||||
|
data: {
|
||||||
|
id: "inspect-level",
|
||||||
|
phase: "evidence",
|
||||||
|
status: "error",
|
||||||
|
title: "液位连续性核对失败",
|
||||||
|
detail: "监测数据读取超时。",
|
||||||
|
ended_at: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
releaseStream = () => {
|
||||||
|
if (!timer) {
|
||||||
|
timer = setInterval(sendNextTextChunk, STREAM_CHUNK_DELAY_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
response.on("close", () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve) => streamServer.listen(0, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => streamServer.listen(0, "127.0.0.1", resolve));
|
||||||
@@ -204,13 +261,55 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await sendButton.click();
|
await sendButton.click();
|
||||||
|
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
|
||||||
|
await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible();
|
||||||
|
await expect(liveProgress.locator("li")).toHaveCount(3);
|
||||||
|
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
const liveProgressRowHeights = await liveProgress.locator("li").evaluateAll((rows) =>
|
||||||
|
rows.map((row) => row.getBoundingClientRect().height)
|
||||||
|
);
|
||||||
|
expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
|
||||||
|
const startedAt = performance.now();
|
||||||
|
state.__agentFailureMotionObserved = false;
|
||||||
|
|
||||||
|
const sample = () => {
|
||||||
|
const badge = document.querySelector<HTMLElement>('[data-progress-status="error"]');
|
||||||
|
if (badge && getComputedStyle(badge).transform !== "none") {
|
||||||
|
state.__agentFailureMotionObserved = true;
|
||||||
|
}
|
||||||
|
if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) {
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
});
|
||||||
|
triggerProgressFailure();
|
||||||
|
await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible();
|
||||||
|
await expect.poll(() =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
(window as typeof window & { __agentFailureMotionObserved?: boolean }).__agentFailureMotionObserved ?? false
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
resumeStream();
|
||||||
|
|
||||||
await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
||||||
timeout: 10_000
|
timeout: 10_000
|
||||||
});
|
});
|
||||||
const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first();
|
const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first();
|
||||||
await expect(progressToggle).toContainText("共 2 条");
|
await expect(progressToggle).toContainText("共 4 条");
|
||||||
await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }).first()).toBeVisible();
|
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
|
||||||
await page.waitForTimeout(400);
|
await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })).toHaveCount(0);
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const scroll = document.querySelector<HTMLElement>(".agent-conversation-scroll");
|
||||||
|
return scroll ? Math.abs((scroll.scrollHeight - scroll.clientHeight) - scroll.scrollTop) : Number.POSITIVE_INFINITY;
|
||||||
|
})
|
||||||
|
).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
const samples = await page.evaluate(() =>
|
const samples = await page.evaluate(() =>
|
||||||
(window as typeof window & {
|
(window as typeof window & {
|
||||||
@@ -259,6 +358,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
|||||||
).toBeLessThanOrEqual(2);
|
).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
await progressToggle.click();
|
await progressToggle.click();
|
||||||
|
await expect(page.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4);
|
||||||
await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible();
|
await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,3 +382,23 @@ function splitIntoCodePointChunks(content: string, chunkSize: number) {
|
|||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resumeStream() {
|
||||||
|
if (!releaseStream) {
|
||||||
|
throw new Error("Stream checkpoint was not reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = releaseStream;
|
||||||
|
releaseStream = null;
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerProgressFailure() {
|
||||||
|
if (!failProgress) {
|
||||||
|
throw new Error("Progress failure checkpoint was not reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = failProgress;
|
||||||
|
failProgress = null;
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user