From 93a5341688169b2440a40a662704416e72c1d5c8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:04:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=88=87=E5=9B=9E=E7=9C=8B?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=9B=B4=E6=8E=A5=E9=93=BA?= =?UTF-8?q?=E5=87=BA=E5=8E=86=E5=8F=B2,=E9=95=BF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BB=8E=E9=A1=B6=E6=BB=9A=E5=88=B0=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 事件流按会话缓存最近 30 条会话的已读事件,切回时直接用缓存渲染,再从最后一个 seq 之后补读 - 消息区跟随:一次多出一屏以上直接跳到底,流式小段增长才平滑跟随 (原来只有第一次 ResizeObserver 回调算首屏,事件补读回来时整段平滑滚动) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../app/components/agent/agent-nav.test.ts | 31 +++++++++++++++ .../app/components/agent/use-agent-stream.ts | 39 ++++++++++++++++--- .../components/agentui/message-scroller.tsx | 18 ++++++--- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 3e34ab87..eeaeea75 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -1,7 +1,9 @@ +import type { AgentRunEvent } from "@umt/contracts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { runGroup } from "~/api/agent.ts"; +import { followBehavior } from "~/components/agentui/message-scroller.tsx"; import { agentListHref, agentRunHref, @@ -16,6 +18,7 @@ import { } from "./agent-nav.ts"; import { CompactPager } from "./agent-session-list.tsx"; import { isAgentPage, outletKey } from "./agent-store.ts"; +import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts"; // 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 @@ -119,3 +122,31 @@ describe("输入草稿", () => { expect(readDraft(undefined)).toBe(""); }); }); + +describe("消息区滚动策略", () => { + it("流式的小段增长平滑跟随", () => { + expect(followBehavior({ gap: 48, viewport: 600, reduce: false })).toBe("smooth"); + }); + it("一次多出一屏以上(打开 / 切回会话铺开历史)直接跳到底", () => { + expect(followBehavior({ gap: 1400, viewport: 600, reduce: false })).toBe("auto"); + }); + it("减少动态效果时一律直接跳", () => { + expect(followBehavior({ gap: 48, viewport: 600, reduce: true })).toBe("auto"); + }); +}); + +describe("切回看过的会话:事件缓存", () => { + const ev = (seq: number) => ({ seq, at: "2026-10-06T00:00:00Z", type: "text_delta", delta: "x" }) as AgentRunEvent; + it("记住最近读到的事件,切回时直接可用", () => { + cacheRunEvents("live:a", [ev(0), ev(1)]); + expect(cachedRunEvents("live:a").map((e) => e.seq)).toEqual([0, 1]); + expect(cachedRunEvents("mock:a")).toEqual([]); + }); + it("空列表不覆盖已有缓存;超过 30 条会话丢掉最早看的", () => { + cacheRunEvents("live:a", []); + expect(cachedRunEvents("live:a")).toHaveLength(2); + for (let i = 0; i < 30; i++) cacheRunEvents(`live:n${i}`, [ev(i)]); + expect(cachedRunEvents("live:a")).toEqual([]); + expect(cachedRunEvents("live:n29")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/components/agent/use-agent-stream.ts b/apps/web/app/components/agent/use-agent-stream.ts index 67b43ffa..b5faf7dc 100644 --- a/apps/web/app/components/agent/use-agent-stream.ts +++ b/apps/web/app/components/agent/use-agent-stream.ts @@ -284,20 +284,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] { return items; } +/** + * 最近看过的会话的事件(模块级,最多 30 条会话):切回看过的会话时直接用已读到的事件铺出来, + * 再从最后一个 seq 之后补读;不再先空一帧、再整段补读回来。 + */ +const EVENT_CACHE_MAX = 30; +const eventCache = new Map(); + +export function cachedRunEvents(key: string): AgentRunEvent[] { + return eventCache.get(key) ?? []; +} + +export function cacheRunEvents(key: string, events: AgentRunEvent[]): void { + if (!key || !events.length) return; + eventCache.delete(key); + eventCache.set(key, events); + while (eventCache.size > EVENT_CACHE_MAX) { + const oldest = eventCache.keys().next().value; + if (oldest === undefined) break; + eventCache.delete(oldest); + } +} + +const lastSeqOf = (events: AgentRunEvent[]) => events[events.length - 1]?.seq ?? -1; + export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState { - const [events, setEvents] = useState([]); + // 演示数据与真实会话分开缓存 + const cacheKey = runId ? `${agentMockEnabled() ? "mock" : "live"}:${runId}` : ""; + const [events, setEvents] = useState(() => cachedRunEvents(cacheKey)); const [error, setError] = useState(null); const [connected, setConnected] = useState("idle"); const [nonce, setNonce] = useState(0); - const lastSeq = useRef(-1); + const lastSeq = useRef(lastSeqOf(events)); - // biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置 + // biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置(有缓存就从缓存接着读) useEffect(() => { - setEvents([]); + const cached = cachedRunEvents(cacheKey); + setEvents(cached); setError(null); - lastSeq.current = -1; + lastSeq.current = lastSeqOf(cached); }, [runId]); + useEffect(() => cacheRunEvents(cacheKey, events), [cacheKey, events]); + // biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读 useEffect(() => { if (!runId) return; diff --git a/apps/web/app/components/agentui/message-scroller.tsx b/apps/web/app/components/agentui/message-scroller.tsx index 46c53bf6..4a17779d 100644 --- a/apps/web/app/components/agentui/message-scroller.tsx +++ b/apps/web/app/components/agentui/message-scroller.tsx @@ -33,6 +33,14 @@ export interface MessageScrollerProps extends Omit, jumpLabel?: string; } +/** + * 跟随内容增长时的滚动方式:流式输出的小段增长平滑跟随;一次多出一屏以上(打开 / 切回会话时历史铺开、 + * 补读回一大段)直接跳到底,不播一段从上滚到底的动画;减少动态效果时一律直接跳。 + */ +export function followBehavior(o: { gap: number; viewport: number; reduce: boolean }): ScrollBehavior { + return o.reduce || o.gap > o.viewport ? "auto" : "smooth"; +} + function layoutMs(el: Element | null): number { if (!el) return 0; const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim(); @@ -113,12 +121,12 @@ export function MessageScroller({ useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; - // 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随 - let first = true; + // 首次铺满内容、一次多出一屏以上直接跳到底,流式的小段增长才平滑跟随 const ro = new ResizeObserver(() => { - if (!followOutput || !followingRef.current) return; - scrollToEnd(reduce || first ? "auto" : "smooth"); - first = false; + const vp = viewportRef.current; + if (!vp || !followOutput || !followingRef.current) return; + const gap = vp.scrollHeight - vp.scrollTop - vp.clientHeight; + scrollToEnd(followBehavior({ gap, viewport: vp.clientHeight, reduce })); }); ro.observe(content); return () => ro.disconnect();