fix: fix chat scroll style

main
yannyang 2026-07-30 22:13:27 +08:00
parent 7faf2cddcf
commit b9cf3bc78a
11 changed files with 151 additions and 38 deletions

View File

@ -56,7 +56,9 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is
<Button type="text" block className="agent-model-dropdown-trigger"> <Button type="text" block className="agent-model-dropdown-trigger">
<span className="agent-model-dropdown-summary">{summary}</span> <span className="agent-model-dropdown-summary">{summary}</span>
<span className="agent-model-dropdown-values"> <span className="agent-model-dropdown-values">
{value.length ? value.join(', ') : '未选择'} {value.length
? value.map(id => models.find(m => m.id === id)?.model_name || id).join(', ')
: '未选择'}
</span> </span>
<DownOutlined className="agent-model-dropdown-arrow" /> <DownOutlined className="agent-model-dropdown-arrow" />
</Button> </Button>

View File

@ -3,12 +3,23 @@
overflow-y: auto; overflow-y: auto;
background: var(--color-bg); background: var(--color-bg);
padding: 24px; padding: 24px;
/* 恢复正向布局 */
display: flex;
flex-direction: column;
/* 解决移动端滚动流畅度 */
-webkit-overflow-scrolling: touch;
} }
.messages-container { .messages-container {
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
/* 核心:当消息较少时,通过 margin-top: auto 将内容推到底部 */
margin-top: auto;
flex-shrink: 0;
} }
.chat-empty-welcome { .chat-empty-welcome {

View File

@ -1,5 +1,5 @@
import { Divider, Tag, Avatar } from 'antd'; import { Divider, Tag, Avatar } from 'antd';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useLayoutEffect } from 'react';
import type { Agent, BranchInfo, ChatMessage } from '../../../api'; import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown'; import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender'; import type { StreamingState } from '../hooks/useChatSender';
@ -23,11 +23,14 @@ export default function ChatBody(props: {
onRegenerate: (assistantId: string) => void; onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void; onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string, mode: CopyMode) => void; onCopy: (text: string, mode: CopyMode) => void;
scrollBottom: (force?: boolean) => void;
initialScrollDoneRef: { current: boolean };
isMobile?: boolean; isMobile?: boolean;
}) { }) {
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props; const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, scrollBottom, initialScrollDoneRef, isMobile } = props;
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true); const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
const bottomAnchorRef = useRef<HTMLDivElement>(null);
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程 // 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
useEffect(() => { useEffect(() => {
@ -40,10 +43,52 @@ export default function ChatBody(props: {
} }
}, [streaming.active, !!streaming.answerText]); }, [streaming.active, !!streaming.answerText]);
// 1. 初始加载:使用 useLayoutEffect 在浏览器绘图前尝试触底,消除跳转感
useLayoutEffect(() => {
if (messages.length > 0 && !initialScrollDoneRef.current) {
const el = bodyRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
// 注意:这里不立即设置 initialScrollDoneRef.current = true
// 留给下面的 useEffect 或 highlight 逻辑处理,确保后续第一次滚动也是 instant
}
}
}, [messages.length, bodyRef]);
// 2. 新消息到达或流式输出时的自动跟进
useEffect(() => {
if (messages.length > 0 || streaming.active) {
const isInitial = !initialScrollDoneRef.current;
scrollBottom(isInitial);
if (isInitial) {
initialScrollDoneRef.current = true;
}
}
}, [messages.length, streaming.answerText, streaming.reasoningText, streaming.active, scrollBottom, initialScrollDoneRef]);
// 3. 监听内容高度变化如图片加载、Markdown 渲染)
useEffect(() => {
const el = bodyRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
scrollBottom();
});
const container = el.querySelector('.messages-container');
if (container) {
observer.observe(container);
}
return () => observer.disconnect();
}, [scrollBottom, bodyRef]);
const isEmpty = messages.length === 0 && !streaming.active;
return ( return (
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
<div className="messages-container"> <div className="messages-container">
{messages.length === 0 && !streaming.active ? ( {isEmpty ? (
<div className="chat-empty-welcome"> <div className="chat-empty-welcome">
<div className="chat-welcome-avatar"> <div className="chat-welcome-avatar">
{isImageUrl(agent.avatar) ? ( {isImageUrl(agent.avatar) ? (
@ -141,6 +186,8 @@ export default function ChatBody(props: {
</div> </div>
</div> </div>
)} )}
{/* 底部锚点:用于辅助滚动定位 */}
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
</> </>
)} )}
</div> </div>

View File

@ -1,21 +1,35 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.h5-chat-shell { .h5-chat-shell {
/* 移除固定高度,允许跟随全局滚动容器 */ height: 100vh;
height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.h5-chat-main { .h5-chat-main {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow: hidden;
} }
.h5-chat-content-row { .h5-chat-content-row {
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */ flex: 1;
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
}
.h5-chat-content-row .chat-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
} }
.h5-chat-content-row .chat-body .messages-container { .h5-chat-content-row .chat-body .messages-container {
padding-bottom: 2rem; padding: 12px 16px 32px;
} }
} }

View File

@ -122,6 +122,8 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => { navigator.clipboard?.writeText(content).then(() => {

View File

@ -113,6 +113,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => { navigator.clipboard?.writeText(content).then(() => {

View File

@ -57,9 +57,11 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)')); navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制'));
}} }}
/> />
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => { <ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {

View File

@ -119,6 +119,17 @@ export default function MessageItem(props: {
</Space> </Space>
)} )}
{message.meta?.aborted && <Tag color="orange"></Tag>} {message.meta?.aborted && <Tag color="orange"></Tag>}
{isUser ? (
<Tooltip title="复制">
<Button
size="small"
className='actions-btn'
type="text"
icon={<CopyOutlined />}
onClick={() => onCopy?.(formattedContent, 'plain')}
/>
</Tooltip>
) : (
<Dropdown <Dropdown
trigger={['click']} trigger={['click']}
menu={{ menu={{
@ -132,6 +143,7 @@ export default function MessageItem(props: {
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} /> <Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
)}
{!isUser && ( {!isUser && (
<Tooltip title="重新生成"> <Tooltip title="重新生成">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} /> <Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />

View File

@ -21,6 +21,11 @@ export function useChatData(args: {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [branches, setBranches] = useState<Record<string, BranchInfo>>({}); const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
const loadSeqRef = useRef(0); const loadSeqRef = useRef(0);
const initialHighlightDoneRef = useRef(false);
useEffect(() => {
initialHighlightDoneRef.current = false;
}, [agentId, roomId]);
const loadAgent = async () => { const loadAgent = async () => {
if (!agentId) { if (!agentId) {
@ -61,18 +66,8 @@ export function useChatData(args: {
setMessages(Array.isArray(his.messages) ? his.messages : []); setMessages(Array.isArray(his.messages) ? his.messages : []);
setBranches(his.branches || {}); setBranches(his.branches || {});
const checkScroll = () => { // 历史消息加载完成后的处理
if (!initialScrollDoneRef.current) { // 注意:这里不再手动触发滚动,全部交给 ChatBody 的 useLayoutEffect 和 useEffect 处理
scrollBottom(true);
initialScrollDoneRef.current = true;
} else {
scrollBottom();
}
};
requestAnimationFrame(checkScroll);
setTimeout(checkScroll, 100);
setTimeout(checkScroll, 500);
}; };
useEffect(() => { useEffect(() => {
@ -109,8 +104,26 @@ export function useChatData(args: {
if (!highlightId) return; if (!highlightId) return;
const el = document.getElementById('msg-' + highlightId); const el = document.getElementById('msg-' + highlightId);
if (!el) return; if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [highlightId]); // 如果是房间加载后的第一次高亮(通常是系统自动选中的最后一条消息)
const isFirstHighlight = !initialHighlightDoneRef.current;
// 逻辑:如果是初始加载,且该消息是最后一条消息,则不触发 scrollIntoView(start)
// 这样可以保留 ChatBody 的 scrollBottom 效果(看到消息的尾部)
const isLastMessage = messages.length > 0 && messages[messages.length - 1].id === highlightId;
if (isFirstHighlight && isLastMessage) {
initialHighlightDoneRef.current = true;
return;
}
const behavior = isFirstHighlight ? 'instant' : 'smooth';
el.scrollIntoView({ behavior, block: 'start' });
if (isFirstHighlight) {
initialHighlightDoneRef.current = true;
}
}, [highlightId, messages]);
return { return {
agent, agent,

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback } from 'react';
export function useChatScroll() { export function useChatScroll() {
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
@ -13,7 +13,7 @@ export function useChatScroll() {
const lastUserScrollTypeRef = useRef<string>(''); const lastUserScrollTypeRef = useRef<string>('');
const attachRetryTimerRef = useRef<number | null>(null); const attachRetryTimerRef = useRef<number | null>(null);
const scrollBottom = (force = false) => { const scrollBottom = useCallback((force = false) => {
if (force) { if (force) {
userScrollLockRef.current = false; userScrollLockRef.current = false;
autoScrollRef.current = true; autoScrollRef.current = true;
@ -32,9 +32,14 @@ export function useChatScroll() {
const el = bodyRef.current; const el = bodyRef.current;
if (!el) return; if (!el) return;
scrollProgrammaticAtRef.current = Date.now(); scrollProgrammaticAtRef.current = Date.now();
el.scrollTop = el.scrollHeight;
// 恢复正向滚动scrollTop = scrollHeight
el.scrollTo({
top: el.scrollHeight,
behavior: force ? 'instant' : 'smooth'
}); });
}; });
}, []);
const cancelAutoScroll = (type: string) => { const cancelAutoScroll = (type: string) => {
userScrollLockRef.current = true; userScrollLockRef.current = true;
@ -57,9 +62,12 @@ export function useChatScroll() {
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50; const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
const nextTop = el.scrollTop; const nextTop = el.scrollTop;
const lastTop = lastScrollTopRef.current; const lastTop = lastScrollTopRef.current;
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
const distance = el.scrollHeight - nextTop - el.clientHeight; const distance = el.scrollHeight - nextTop - el.clientHeight;
const scrollDelta = nextTop - lastTop; const scrollDelta = nextTop - lastTop;
// scrollDelta < 0 表示向上滚动
if (scrollDelta < 0 && !forceScrollPendingRef.current) { if (scrollDelta < 0 && !forceScrollPendingRef.current) {
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)'); cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
} }

View File

@ -516,7 +516,7 @@ body {
} }
.is-h5 .bubble { .is-h5 .bubble {
max-width: 94%; /* max-width: 94%; */
} }
.bubble.assistant p, .bubble.assistant p,