fix: fix chat scroll

main
yannyang 2026-07-27 21:32:46 +08:00
parent 1482ef9cf3
commit eea224d759
7 changed files with 137 additions and 78 deletions

View File

@ -15,7 +15,10 @@ npm run dev # http://localhost:5173
如需切换:
```bash
VITE_API_TARGET=http://localhost:4000 npm run dev
VITE_API_TARGET=http://localhost:4001 npm run dev
# 或添加本地环境变量 .env.local
VITE_API_TARGET=http://localhost:4001
```
## 构建

View File

@ -47,10 +47,10 @@ export default function KnowledgeSettingsPanel({
<div className="px-1">
<div className="flex justify-between items-center mb-4">
<span className="text-xs text-gray-500"> AI </span>
<Input
<input
type="file"
multiple
className="agent-editor-file-input"
style={{ display: 'none' }}
id="knowledge-upload"
onChange={async (event) => {
const files = event.target.files;
@ -95,8 +95,11 @@ export default function KnowledgeSettingsPanel({
<span className="agent-editor-indexing-label"></span>
</Tag>
) : (
<Tag color={STATUS_TAG[item.status || 'ready'].color} className="m-0 text-[10px] px-1">
{STATUS_TAG[item.status || 'ready'].text}
<Tag
color={STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.color || 'default'}
className="m-0 text-[10px] px-1"
>
{STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.text || item.status || '未知'}
</Tag>
)}
</span>

View File

@ -1,10 +1,11 @@
import { Divider, Tag, Avatar } from 'antd';
import { useState, useEffect } from 'react';
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender';
import type { CopyMode } from '../utils/copy';
import MessageItem from './messages/MessageItem';
import { RetrievedView, ToolCallView } from './messages/MetaViews';
import { ReasoningView, RetrievedView, ToolCallView } from './messages/MetaViews';
import './ChatBody.css';
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
@ -26,6 +27,15 @@ export default function ChatBody(props: {
}) {
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
// 当开始新的回答时,默认展开推理过程
useEffect(() => {
if (streaming.active && !streaming.answerText) {
setStreamingReasoningExpanded(true);
}
}, [streaming.active, !!streaming.answerText]);
return (
<div ref={bodyRef} className="chat-body">
<div className="messages-container">
@ -100,10 +110,13 @@ export default function ChatBody(props: {
</div>
)}
<div className="streaming-section">
<div className="streaming-section-label"></div>
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
<ReasoningView
reasoning={streaming.reasoningText || '等待推理…'}
expanded={streamingReasoningExpanded}
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)}
/>
</div>
<Divider style={{ margin: '6px 0' }} />
{streamingReasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
<div className="streaming-section">
<div className="streaming-section-label"></div>
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}

View File

@ -55,54 +55,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
}
}, [messages.length]);
// 2. 监听滚动,自动更新大纲激活态
useEffect(() => {
const bodyEl = bodyRef.current;
if (!bodyEl) return;
const handleScroll = () => {
if (isAutoScrolling.current) return;
const messageEls = bodyEl.querySelectorAll('.message-item-container[data-is-agent="true"]');
if (messageEls.length === 0) return;
// 检查是否滚动到底部
const isAtBottom = bodyEl.scrollHeight - bodyEl.scrollTop <= bodyEl.clientHeight + 100;
if (isAtBottom) {
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
const lastAgentMsgId = agentMsgs[agentMsgs.length - 1]?.id;
if (lastAgentMsgId && lastAgentMsgId !== highlightId) {
setHighlightId(lastAgentMsgId);
}
return;
}
let currentActiveId = highlightId;
const containerRect = bodyEl.getBoundingClientRect();
// 找到当前视口中占据主要位置的 Agent 消息
for (let i = 0; i < messageEls.length; i++) {
const el = messageEls[i] as HTMLElement;
const rect = el.getBoundingClientRect();
// 判定准则:消息头部进入视口顶部感应区
if (rect.top >= containerRect.top - 20 && rect.top <= containerRect.top + 150) {
const idAttr = el.getAttribute('data-msg-id');
if (idAttr) {
currentActiveId = idAttr;
break;
}
}
}
if (currentActiveId && currentActiveId !== highlightId) {
setHighlightId(currentActiveId);
}
};
bodyEl.addEventListener('scroll', handleScroll);
return () => bodyEl.removeEventListener('scroll', handleScroll);
}, [messages, highlightId]);
// 2. 移除滚动监听逻辑,避免干扰正常滚动
// 后续若需恢复,需重新设计非阻塞的判定算法
const handleJump = useCallback((msgId: string) => {
isAutoScrolling.current = true;

View File

@ -111,3 +111,45 @@
.bubble.assistant .markdown p {
color: var(--color-text);
}
.message-reasoning-section {
display: flex;
flex-direction: column;
}
.reasoning-container {
margin: 4px 0;
transition: all 0.3s ease;
}
.reasoning-header {
padding: 4px 0;
border-radius: 4px;
transition: background-color 0.2s;
}
.reasoning-header:hover {
background-color: var(--color-surface-2);
}
.reasoning-content {
margin-top: 8px;
animation: reasoningFadeIn 0.3s ease-out;
}
@keyframes reasoningFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-section-label {
font-size: 12px;
color: var(--color-text-tertiary);
margin-bottom: 6px;
}

View File

@ -1,5 +1,5 @@
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
import { useMemo } from 'react';
import { Button, Dropdown, Space, Tag, Tooltip, Avatar, Divider } from 'antd';
import { useMemo, useState } from 'react';
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BranchInfo, ChatMessage } from '../../../../api';
@ -24,6 +24,8 @@ export default function MessageItem(props: {
}) {
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const [reasoningExpanded, setReasoningExpanded] = useState(false);
const formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
@ -85,7 +87,20 @@ export default function MessageItem(props: {
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
}} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* 如果有推理过程且不是用户消息,展示推理部分 */}
{!isUser && message.meta?.reasoning && (
<div className="message-reasoning-section">
<ReasoningView
reasoning={message.meta.reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
/>
{reasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
</div>
)}
<Markdown>{formattedContent}</Markdown>
</div>
)}
</div>
@ -124,13 +139,14 @@ export default function MessageItem(props: {
)}
</div>
{/* {!isMobile && message.meta && !isUser && (
{/* 展示 RAG 和 工具调用 */}
{!isMobile && message.meta && !isUser && (
<div style={{ marginTop: 8 }}>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
</div>
)} */}
)}
</div>
</div>
</div>

View File

@ -1,24 +1,52 @@
import { Collapse, Tag } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
import Markdown from '../../../../components/Markdown';
export function ReasoningView({ reasoning }: { reasoning: string }) {
export function ReasoningView({
reasoning,
expanded,
onToggle
}: {
reasoning: string;
expanded?: boolean;
onToggle?: () => void;
}) {
return (
<Collapse
size="small"
ghost
items={[
{
key: 'reasoning',
label: <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>🧠 </span>,
children: (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
<div className={`reasoning-container ${expanded ? 'is-expanded' : ''}`}>
<div className="reasoning-header" onClick={onToggle} style={{
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
userSelect: 'none',
marginBottom: expanded ? 8 : 0
}}>
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)', fontWeight: 500 }}>
</span>
<RightOutlined style={{
fontSize: 10,
color: 'var(--color-text-tertiary)',
transition: 'transform 0.2s',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)'
}} />
</div>
{expanded && (
<div className="reasoning-content" style={{
fontSize: 13,
color: 'var(--color-text-secondary)',
lineHeight: 1.6,
maxHeight: 300,
overflow: 'auto',
borderLeft: '2px solid var(--color-border-light)',
paddingLeft: 12,
marginLeft: 4
}}>
<Markdown>{reasoning}</Markdown>
</div>
)
}
]}
/>
)}
</div>
);
}