Skip to content

Commit b4b9ede

Browse files
committed
fix: 修复流式输出时消息气泡不自动滚动的问题
1 parent 6144e2e commit b4b9ede

File tree

1 file changed

+98
-2
lines changed

1 file changed

+98
-2
lines changed

app/components/chat.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1997,6 +1997,9 @@ function DualModelView(props: {
19971997
const secondaryVirtuosoRef = useRef<VirtuosoHandle>(null);
19981998
const [primaryHitBottom, setPrimaryHitBottom] = useState(true);
19991999
const [secondaryHitBottom, setSecondaryHitBottom] = useState(true);
2000+
// 使用 ref 跟踪是否应该自动滚动,避免被 atBottomStateChange 覆盖
2001+
const primaryAutoScrollRef = useRef(true);
2002+
const secondaryAutoScrollRef = useRef(true);
20002003
const [primaryVisibleRange, setPrimaryVisibleRange] = useState<{
20012004
startIndex: number;
20022005
endIndex: number;
@@ -2045,6 +2048,8 @@ function DualModelView(props: {
20452048

20462049
const scrollPrimaryToBottom = useCallback(
20472050
(instant?: boolean) => {
2051+
setPrimaryHitBottom(true);
2052+
primaryAutoScrollRef.current = true;
20482053
primaryVirtuosoRef.current?.scrollToIndex({
20492054
index: primaryRenderMessages.length - 1,
20502055
behavior: instant ? "auto" : "smooth",
@@ -2056,6 +2061,8 @@ function DualModelView(props: {
20562061

20572062
const scrollSecondaryToBottom = useCallback(
20582063
(instant?: boolean) => {
2064+
setSecondaryHitBottom(true);
2065+
secondaryAutoScrollRef.current = true;
20592066
secondaryVirtuosoRef.current?.scrollToIndex({
20602067
index: secondaryRenderMessages.length - 1,
20612068
behavior: instant ? "auto" : "smooth",
@@ -2079,6 +2086,61 @@ function DualModelView(props: {
20792086
props.onScrollBothToBottom?.(scrollBothToBottom);
20802087
}, [scrollBothToBottom, props.onScrollBothToBottom]);
20812088

2089+
// 流式输出时自动滚动:followOutput 只在新消息添加时触发,
2090+
// 但不会在现有消息高度变化时自动滚动,需要手动处理
2091+
const primaryLastMessage =
2092+
primaryRenderMessages[primaryRenderMessages.length - 1];
2093+
const secondaryLastMessage =
2094+
secondaryRenderMessages[secondaryRenderMessages.length - 1];
2095+
const primaryIsStreaming =
2096+
primaryLastMessage?.streaming || primaryLastMessage?.preview;
2097+
const secondaryIsStreaming =
2098+
secondaryLastMessage?.streaming || secondaryLastMessage?.preview;
2099+
const primaryStreamingContent =
2100+
primaryIsStreaming && primaryLastMessage
2101+
? getMessageTextContent(primaryLastMessage)
2102+
: "";
2103+
const secondaryStreamingContent =
2104+
secondaryIsStreaming && secondaryLastMessage
2105+
? getMessageTextContent(secondaryLastMessage)
2106+
: "";
2107+
2108+
useEffect(() => {
2109+
if (!primaryAutoScrollRef.current && !primaryHitBottom) return;
2110+
if (!primaryIsStreaming) return;
2111+
const id = requestAnimationFrame(() => {
2112+
primaryVirtuosoRef.current?.scrollToIndex({
2113+
index: primaryRenderMessages.length - 1,
2114+
align: "end",
2115+
behavior: "auto",
2116+
});
2117+
});
2118+
return () => cancelAnimationFrame(id);
2119+
}, [
2120+
primaryHitBottom,
2121+
primaryIsStreaming,
2122+
primaryStreamingContent,
2123+
primaryRenderMessages.length,
2124+
]);
2125+
2126+
useEffect(() => {
2127+
if (!secondaryAutoScrollRef.current && !secondaryHitBottom) return;
2128+
if (!secondaryIsStreaming) return;
2129+
const id = requestAnimationFrame(() => {
2130+
secondaryVirtuosoRef.current?.scrollToIndex({
2131+
index: secondaryRenderMessages.length - 1,
2132+
align: "end",
2133+
behavior: "auto",
2134+
});
2135+
});
2136+
return () => cancelAnimationFrame(id);
2137+
}, [
2138+
secondaryHitBottom,
2139+
secondaryIsStreaming,
2140+
secondaryStreamingContent,
2141+
secondaryRenderMessages.length,
2142+
]);
2143+
20822144
// 计算当前可视范围对应的用户消息索引
20832145
const getCurrentUserIndex = useCallback(
20842146
(
@@ -2131,7 +2193,10 @@ function DualModelView(props: {
21312193
style={{ height: "100%" }}
21322194
data={primaryRenderMessages}
21332195
followOutput={primaryHitBottom ? "smooth" : false}
2134-
atBottomStateChange={setPrimaryHitBottom}
2196+
atBottomStateChange={(atBottom) => {
2197+
setPrimaryHitBottom(atBottom);
2198+
primaryAutoScrollRef.current = atBottom;
2199+
}}
21352200
atBottomThreshold={64}
21362201
increaseViewportBy={{ top: 400, bottom: 800 }}
21372202
computeItemKey={(index, m) => `primary-${m.id || index}`}
@@ -2199,7 +2264,10 @@ function DualModelView(props: {
21992264
style={{ height: "100%" }}
22002265
data={secondaryRenderMessages}
22012266
followOutput={secondaryHitBottom ? "smooth" : false}
2202-
atBottomStateChange={setSecondaryHitBottom}
2267+
atBottomStateChange={(atBottom) => {
2268+
setSecondaryHitBottom(atBottom);
2269+
secondaryAutoScrollRef.current = atBottom;
2270+
}}
22032271
atBottomThreshold={64}
22042272
increaseViewportBy={{ top: 400, bottom: 800 }}
22052273
computeItemKey={(index, m) => `secondary-${m.id || index}`}
@@ -2894,6 +2962,10 @@ function ChatComponent() {
28942962
if (!isMobileScreen) inputRef.current?.focus();
28952963
// setAutoScroll(true);
28962964
scrollToBottom();
2965+
// 双模型模式下同时滚动两个面板
2966+
if (isDualMode) {
2967+
dualModelScrollToBottomRef.current?.(true);
2968+
}
28972969
setLastExpansion(null);
28982970
};
28992971

@@ -3660,6 +3732,9 @@ function ChatComponent() {
36603732
const v = virtuosoRef.current;
36613733
if (!v) return;
36623734

3735+
// 显式滚动到底部时,启用自动跟随
3736+
setHitBottom(true);
3737+
36633738
const behavior: ScrollBehavior = instant ? "auto" : "smooth";
36643739

36653740
// Footer 内有预览,或者强制要求到底,就直接滚到最底(含 Footer)
@@ -3693,6 +3768,27 @@ function ChatComponent() {
36933768
hitBottom,
36943769
]);
36953770

3771+
// 流式输出时自动滚动:followOutput 只在新消息添加时触发,
3772+
// 但不会在现有消息高度变化时自动滚动,需要手动处理
3773+
const lastMessage = messages[messages.length - 1];
3774+
const isStreaming = lastMessage?.streaming || lastMessage?.preview;
3775+
const streamingContent =
3776+
isStreaming && lastMessage ? getMessageTextContent(lastMessage) : "";
3777+
3778+
useEffect(() => {
3779+
if (!hitBottom) return;
3780+
if (!isStreaming) return;
3781+
// 流式输出时滚动到底部
3782+
const id = requestAnimationFrame(() => {
3783+
virtuosoRef.current?.scrollToIndex({
3784+
index: messages.length - 1,
3785+
align: "end",
3786+
behavior: "auto",
3787+
});
3788+
});
3789+
return () => cancelAnimationFrame(id);
3790+
}, [hitBottom, isStreaming, streamingContent, messages.length]);
3791+
36963792
const location = useLocation() as { state?: { jumpToIndex?: number } };
36973793
const jumpToIndex =
36983794
typeof location.state?.jumpToIndex === "number"

0 commit comments

Comments
 (0)