fix: fix chat scroll style
parent
7faf2cddcf
commit
b9cf3bc78a
|
|
@ -56,7 +56,9 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is
|
|||
<Button type="text" block className="agent-model-dropdown-trigger">
|
||||
<span className="agent-model-dropdown-summary">{summary}</span>
|
||||
<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>
|
||||
<DownOutlined className="agent-model-dropdown-arrow" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,23 @@
|
|||
overflow-y: auto;
|
||||
background: var(--color-bg);
|
||||
padding: 24px;
|
||||
/* 恢复正向布局 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 解决移动端滚动流畅度 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* 核心:当消息较少时,通过 margin-top: auto 将内容推到底部 */
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-empty-welcome {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 Markdown from '../../../components/Markdown';
|
||||
import type { StreamingState } from '../hooks/useChatSender';
|
||||
|
|
@ -23,11 +23,14 @@ export default function ChatBody(props: {
|
|||
onRegenerate: (assistantId: string) => void;
|
||||
onSwitchBranch: (userMsgId: string, branchId: string) => void;
|
||||
onCopy: (text: string, mode: CopyMode) => void;
|
||||
scrollBottom: (force?: boolean) => void;
|
||||
initialScrollDoneRef: { current: 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 bottomAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
|
||||
useEffect(() => {
|
||||
|
|
@ -40,10 +43,52 @@ export default function ChatBody(props: {
|
|||
}
|
||||
}, [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 (
|
||||
<div ref={bodyRef} className="chat-body">
|
||||
<div className="messages-container">
|
||||
{messages.length === 0 && !streaming.active ? (
|
||||
{isEmpty ? (
|
||||
<div className="chat-empty-welcome">
|
||||
<div className="chat-welcome-avatar">
|
||||
{isImageUrl(agent.avatar) ? (
|
||||
|
|
@ -141,6 +186,8 @@ export default function ChatBody(props: {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 底部锚点:用于辅助滚动定位 */}
|
||||
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,35 @@
|
|||
@media (max-width: 768px) {
|
||||
.h5-chat-shell {
|
||||
/* 移除固定高度,允许跟随全局滚动容器 */
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.h5-chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.h5-chat-content-row {
|
||||
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
padding-bottom: 2rem;
|
||||
padding: 12px 16px 32px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
streaming={sender.streaming}
|
||||
onRegenerate={sender.handleRegenerate}
|
||||
onSwitchBranch={sender.handleSwitchBranch}
|
||||
scrollBottom={logic.scrollBottom}
|
||||
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||
onCopy={(text, mode) => {
|
||||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||
navigator.clipboard?.writeText(content).then(() => {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
streaming={sender.streaming}
|
||||
onRegenerate={sender.handleRegenerate}
|
||||
onSwitchBranch={sender.handleSwitchBranch}
|
||||
scrollBottom={logic.scrollBottom}
|
||||
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||
onCopy={(text, mode) => {
|
||||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||
navigator.clipboard?.writeText(content).then(() => {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,11 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
|
|||
streaming={sender.streaming}
|
||||
onRegenerate={sender.handleRegenerate}
|
||||
onSwitchBranch={sender.handleSwitchBranch}
|
||||
scrollBottom={logic.scrollBottom}
|
||||
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||
onCopy={(text, mode) => {
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -119,19 +119,31 @@ export default function MessageItem(props: {
|
|||
</Space>
|
||||
)}
|
||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
||||
]
|
||||
}}
|
||||
>
|
||||
{isUser ? (
|
||||
<Tooltip title="复制">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||
<Button
|
||||
size="small"
|
||||
className='actions-btn'
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy?.(formattedContent, 'plain')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
)}
|
||||
{!isUser && (
|
||||
<Tooltip title="重新生成">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ export function useChatData(args: {
|
|||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
|
||||
const loadSeqRef = useRef(0);
|
||||
const initialHighlightDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
initialHighlightDoneRef.current = false;
|
||||
}, [agentId, roomId]);
|
||||
|
||||
const loadAgent = async () => {
|
||||
if (!agentId) {
|
||||
|
|
@ -61,18 +66,8 @@ export function useChatData(args: {
|
|||
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
||||
setBranches(his.branches || {});
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!initialScrollDoneRef.current) {
|
||||
scrollBottom(true);
|
||||
initialScrollDoneRef.current = true;
|
||||
} else {
|
||||
scrollBottom();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(checkScroll);
|
||||
setTimeout(checkScroll, 100);
|
||||
setTimeout(checkScroll, 500);
|
||||
// 历史消息加载完成后的处理
|
||||
// 注意:这里不再手动触发滚动,全部交给 ChatBody 的 useLayoutEffect 和 useEffect 处理
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -109,8 +104,26 @@ export function useChatData(args: {
|
|||
if (!highlightId) return;
|
||||
const el = document.getElementById('msg-' + highlightId);
|
||||
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 {
|
||||
agent,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export function useChatScroll() {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -13,7 +13,7 @@ export function useChatScroll() {
|
|||
const lastUserScrollTypeRef = useRef<string>('');
|
||||
const attachRetryTimerRef = useRef<number | null>(null);
|
||||
|
||||
const scrollBottom = (force = false) => {
|
||||
const scrollBottom = useCallback((force = false) => {
|
||||
if (force) {
|
||||
userScrollLockRef.current = false;
|
||||
autoScrollRef.current = true;
|
||||
|
|
@ -32,9 +32,14 @@ export function useChatScroll() {
|
|||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
scrollProgrammaticAtRef.current = Date.now();
|
||||
el.scrollTop = el.scrollHeight;
|
||||
|
||||
// 恢复正向滚动:scrollTop = scrollHeight
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: force ? 'instant' : 'smooth'
|
||||
});
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelAutoScroll = (type: string) => {
|
||||
userScrollLockRef.current = true;
|
||||
|
|
@ -57,9 +62,12 @@ export function useChatScroll() {
|
|||
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
|
||||
const nextTop = el.scrollTop;
|
||||
const lastTop = lastScrollTopRef.current;
|
||||
|
||||
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
|
||||
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
||||
|
||||
const scrollDelta = nextTop - lastTop;
|
||||
// scrollDelta < 0 表示向上滚动
|
||||
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
|
||||
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ body {
|
|||
}
|
||||
|
||||
.is-h5 .bubble {
|
||||
max-width: 94%;
|
||||
/* max-width: 94%; */
|
||||
}
|
||||
|
||||
.bubble.assistant p,
|
||||
|
|
|
|||
Loading…
Reference in New Issue