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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -119,19 +119,31 @@ export default function MessageItem(props: {
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||||
<Dropdown
|
{isUser ? (
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
|
||||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title="复制">
|
<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>
|
</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 && (
|
{!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)} />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-h5 .bubble {
|
.is-h5 .bubble {
|
||||||
max-width: 94%;
|
/* max-width: 94%; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant p,
|
.bubble.assistant p,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue