fix: fix chat scroll
parent
1482ef9cf3
commit
eea224d759
|
|
@ -15,7 +15,10 @@ npm run dev # http://localhost:5173
|
||||||
如需切换:
|
如需切换:
|
||||||
|
|
||||||
```bash
|
```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="px-1">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<span className="text-xs text-gray-500">上传文档以增强 AI 知识</span>
|
<span className="text-xs text-gray-500">上传文档以增强 AI 知识</span>
|
||||||
<Input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="agent-editor-file-input"
|
style={{ display: 'none' }}
|
||||||
id="knowledge-upload"
|
id="knowledge-upload"
|
||||||
onChange={async (event) => {
|
onChange={async (event) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
|
|
@ -95,8 +95,11 @@ export default function KnowledgeSettingsPanel({
|
||||||
<span className="agent-editor-indexing-label">索引中…</span>
|
<span className="agent-editor-indexing-label">索引中…</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color={STATUS_TAG[item.status || 'ready'].color} className="m-0 text-[10px] px-1">
|
<Tag
|
||||||
{STATUS_TAG[item.status || 'ready'].text}
|
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>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Divider, Tag, Avatar } from 'antd';
|
import { Divider, Tag, Avatar } from 'antd';
|
||||||
|
import { useState, useEffect } 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';
|
||||||
import type { CopyMode } from '../utils/copy';
|
import type { CopyMode } from '../utils/copy';
|
||||||
import MessageItem from './messages/MessageItem';
|
import MessageItem from './messages/MessageItem';
|
||||||
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
import { ReasoningView, RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||||
import './ChatBody.css';
|
import './ChatBody.css';
|
||||||
|
|
||||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
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 { 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 (
|
return (
|
||||||
<div ref={bodyRef} className="chat-body">
|
<div ref={bodyRef} className="chat-body">
|
||||||
<div className="messages-container">
|
<div className="messages-container">
|
||||||
|
|
@ -100,10 +110,13 @@ export default function ChatBody(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="streaming-section">
|
<div className="streaming-section">
|
||||||
<div className="streaming-section-label">推理过程</div>
|
<ReasoningView
|
||||||
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
reasoning={streaming.reasoningText || '等待推理…'}
|
||||||
|
expanded={streamingReasoningExpanded}
|
||||||
|
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '6px 0' }} />
|
{streamingReasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
|
||||||
<div className="streaming-section">
|
<div className="streaming-section">
|
||||||
<div className="streaming-section-label">正式回答</div>
|
<div className="streaming-section-label">正式回答</div>
|
||||||
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
{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]);
|
}, [messages.length]);
|
||||||
|
|
||||||
// 2. 监听滚动,自动更新大纲激活态
|
// 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]);
|
|
||||||
|
|
||||||
const handleJump = useCallback((msgId: string) => {
|
const handleJump = useCallback((msgId: string) => {
|
||||||
isAutoScrolling.current = true;
|
isAutoScrolling.current = true;
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,45 @@
|
||||||
.bubble.assistant .markdown p {
|
.bubble.assistant .markdown p {
|
||||||
color: var(--color-text);
|
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 { Button, Dropdown, Space, Tag, Tooltip, Avatar, Divider } from 'antd';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
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 { 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 formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
|
||||||
|
|
||||||
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
|
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>')
|
__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>
|
<Markdown>{formattedContent}</Markdown>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -124,13 +139,14 @@ export default function MessageItem(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* {!isMobile && message.meta && !isUser && (
|
{/* 展示 RAG 和 工具调用 */}
|
||||||
|
{!isMobile && message.meta && !isUser && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||||||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||||||
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,52 @@
|
||||||
import { Collapse, Tag } from 'antd';
|
import { Collapse, Tag } from 'antd';
|
||||||
|
import { RightOutlined } from '@ant-design/icons';
|
||||||
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
|
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
|
||||||
import Markdown from '../../../../components/Markdown';
|
import Markdown from '../../../../components/Markdown';
|
||||||
|
|
||||||
export function ReasoningView({ reasoning }: { reasoning: string }) {
|
export function ReasoningView({
|
||||||
|
reasoning,
|
||||||
|
expanded,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
reasoning: string;
|
||||||
|
expanded?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<div className={`reasoning-container ${expanded ? 'is-expanded' : ''}`}>
|
||||||
size="small"
|
<div className="reasoning-header" onClick={onToggle} style={{
|
||||||
ghost
|
display: 'flex',
|
||||||
items={[
|
alignItems: 'center',
|
||||||
{
|
gap: 6,
|
||||||
key: 'reasoning',
|
cursor: 'pointer',
|
||||||
label: <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>🧠 推理过程</span>,
|
userSelect: 'none',
|
||||||
children: (
|
marginBottom: expanded ? 8 : 0
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
|
}}>
|
||||||
|
<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>
|
<Markdown>{reasoning}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue