fix: fix chat scroll
parent
1482ef9cf3
commit
eea224d759
|
|
@ -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
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue