diff --git a/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx b/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx index 5ddfe93..f9e0be3 100644 --- a/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx +++ b/src/pages/AgentEditor/components/ModelCheckboxDropdown.tsx @@ -56,7 +56,9 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is diff --git a/src/pages/chat/components/ChatBody.css b/src/pages/chat/components/ChatBody.css index efce105..6615304 100644 --- a/src/pages/chat/components/ChatBody.css +++ b/src/pages/chat/components/ChatBody.css @@ -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 { diff --git a/src/pages/chat/components/ChatBody.tsx b/src/pages/chat/components/ChatBody.tsx index 858e97c..43caaca 100644 --- a/src/pages/chat/components/ChatBody.tsx +++ b/src/pages/chat/components/ChatBody.tsx @@ -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(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 (
- {messages.length === 0 && !streaming.active ? ( + {isEmpty ? (
{isImageUrl(agent.avatar) ? ( @@ -141,6 +186,8 @@ export default function ChatBody(props: {
)} + {/* 底部锚点:用于辅助滚动定位 */} +
)}
diff --git a/src/pages/chat/components/ChatPageH5.css b/src/pages/chat/components/ChatPageH5.css index c788ba9..eecc9df 100644 --- a/src/pages/chat/components/ChatPageH5.css +++ b/src/pages/chat/components/ChatPageH5.css @@ -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; } } diff --git a/src/pages/chat/components/ChatPageH5.tsx b/src/pages/chat/components/ChatPageH5.tsx index d3d51e4..7220fab 100644 --- a/src/pages/chat/components/ChatPageH5.tsx +++ b/src/pages/chat/components/ChatPageH5.tsx @@ -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(() => { diff --git a/src/pages/chat/components/ChatPageWeb.tsx b/src/pages/chat/components/ChatPageWeb.tsx index 1a57816..e6bd0f6 100644 --- a/src/pages/chat/components/ChatPageWeb.tsx +++ b/src/pages/chat/components/ChatPageWeb.tsx @@ -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(() => { diff --git a/src/pages/chat/components/ChatPageWebBase.tsx b/src/pages/chat/components/ChatPageWebBase.tsx index 7e15357..63cf57b 100644 --- a/src/pages/chat/components/ChatPageWebBase.tsx +++ b/src/pages/chat/components/ChatPageWebBase.tsx @@ -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)' : '已复制')); }} /> { diff --git a/src/pages/chat/components/messages/MessageItem.tsx b/src/pages/chat/components/messages/MessageItem.tsx index 2771204..771d046 100644 --- a/src/pages/chat/components/messages/MessageItem.tsx +++ b/src/pages/chat/components/messages/MessageItem.tsx @@ -119,19 +119,31 @@ export default function MessageItem(props: { )} {message.meta?.aborted && 已停止} - onCopy?.(formattedContent, 'plain') }, - { key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') } - ] - }} - > + {isUser ? ( -