fix: fix chat outline active
parent
2a4dc72059
commit
9da58f0cb1
|
|
@ -13,7 +13,8 @@ import {
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
CreditCardOutlined
|
CreditCardOutlined,
|
||||||
|
ShoppingCartOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../store/auth';
|
import { useAuth } from '../store/auth';
|
||||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||||
|
|
@ -55,7 +56,7 @@ const NAV_GROUPS: Array<{
|
||||||
{
|
{
|
||||||
label: '商城',
|
label: '商城',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token 商城' },
|
{ to: '/points-mall', icon: <ShoppingCartOutlined />, label: 'Token 商城' },
|
||||||
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
|
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,67 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed {
|
||||||
|
width: 68px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-sidebar-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1001;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-sidebar-toggle:hover {
|
||||||
|
color: var(--color-brand);
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-sidebar-header {
|
||||||
|
padding: 16px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-create-btn {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 40px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-sidebar-list {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item.active {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item.active::after {
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-side.is-full {
|
.chat-side.is-full {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button, Spin, Empty, Tabs } from 'antd';
|
import { Button, Spin, Empty, Tabs, Tooltip } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import type { Agent } from '../../../api';
|
import type { Agent } from '../../../api';
|
||||||
import './AgentSidebar.css';
|
import './AgentSidebar.css';
|
||||||
|
|
@ -12,8 +12,18 @@ export default function AgentSidebar(props: {
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onSelect: (agentId: string) => void;
|
onSelect: (agentId: string) => void;
|
||||||
isSidebar?: boolean;
|
isSidebar?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
|
const {
|
||||||
|
agentList,
|
||||||
|
activeAgentId,
|
||||||
|
onCreate,
|
||||||
|
onSelect,
|
||||||
|
isSidebar = true,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse
|
||||||
|
} = props;
|
||||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('mine');
|
const [activeTab, setActiveTab] = useState('mine');
|
||||||
|
|
||||||
|
|
@ -52,7 +62,13 @@ export default function AgentSidebar(props: {
|
||||||
}, [filteredList]);
|
}, [filteredList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
|
<aside className={`chat-side${isSidebar ? '' : ' is-full'}${collapsed ? ' is-collapsed' : ''}`}>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<div className="chat-agent-sidebar-toggle" onClick={onToggleCollapse}>
|
||||||
|
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="chat-agent-sidebar-header">
|
<div className="chat-agent-sidebar-header">
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
|
|
@ -61,33 +77,37 @@ export default function AgentSidebar(props: {
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
className="chat-agent-create-btn"
|
className="chat-agent-create-btn"
|
||||||
>
|
>
|
||||||
创建智能体
|
{!collapsed && '创建智能体'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-agent-tabs">
|
{!collapsed && (
|
||||||
<Tabs
|
<div className="chat-agent-tabs">
|
||||||
activeKey={activeTab}
|
<Tabs
|
||||||
onChange={setActiveTab}
|
activeKey={activeTab}
|
||||||
items={[
|
onChange={setActiveTab}
|
||||||
{ key: 'mine', label: '我的' },
|
items={[
|
||||||
{ key: 'public', label: '公开' },
|
{ key: 'mine', label: '我的' },
|
||||||
{ key: 'private', label: '私有' }
|
{ key: 'public', label: '公开' },
|
||||||
]}
|
{ key: 'private', label: '私有' }
|
||||||
/>
|
]}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="chat-agent-sidebar-list">
|
<div className="chat-agent-sidebar-list">
|
||||||
{agentList.length > 0 ? (
|
{agentList.length > 0 ? (
|
||||||
groupedAgents.map(([groupName, list]) => (
|
groupedAgents.map(([groupName, list]) => (
|
||||||
<div key={groupName} className="chat-agent-group">
|
<div key={groupName} className="chat-agent-group">
|
||||||
<div className="chat-agent-group-label">
|
{!collapsed && (
|
||||||
<span>{groupName}</span>
|
<div className="chat-agent-group-label">
|
||||||
<span className="chat-agent-group-count">{list.length}</span>
|
<span>{groupName}</span>
|
||||||
</div>
|
<span className="chat-agent-group-count">{list.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{list.map((a) => {
|
{list.map((a) => {
|
||||||
const isActive = a.id === activeAgentId;
|
const isActive = a.id === activeAgentId;
|
||||||
return (
|
const content = (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => onSelect(a.id)}
|
onClick={() => onSelect(a.id)}
|
||||||
|
|
@ -103,28 +123,41 @@ export default function AgentSidebar(props: {
|
||||||
(a.name?.charAt(0) || '?').toUpperCase()
|
(a.name?.charAt(0) || '?').toUpperCase()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-agent-info">
|
{!collapsed && (
|
||||||
<div className="chat-agent-name">{a.name}</div>
|
<div className="chat-agent-info">
|
||||||
</div>
|
<div className="chat-agent-name">{a.name}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.id} title={a.name} placement="right">
|
||||||
|
{content}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : !loadingTimeout ? (
|
) : !loadingTimeout ? (
|
||||||
<div className="chat-agent-sidebar-status">
|
<div className="chat-agent-sidebar-status">
|
||||||
<Spin />
|
<Spin />
|
||||||
<div className="chat-agent-loading-text">正在加载智能体...</div>
|
{!collapsed && <div className="chat-agent-loading-text">正在加载智能体...</div>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-agent-sidebar-status">
|
<div className="chat-agent-sidebar-status">
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={
|
description={
|
||||||
<span className="chat-agent-empty-text">
|
!collapsed && (
|
||||||
暂无智能体<br />
|
<span className="chat-agent-empty-text">
|
||||||
点击上方按钮开始创建
|
暂无智能体<br />
|
||||||
</span>
|
点击上方按钮开始创建
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ function formatAgentModel(raw: string | null | undefined) {
|
||||||
const names = parsed
|
const names = parsed
|
||||||
.map((x: any) => String(x?.name || x?.model || x?.id || '').trim())
|
.map((x: any) => String(x?.name || x?.model || x?.id || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
if (names.length > 2) {
|
||||||
|
return names.slice(0, 2).join(', ') + '...';
|
||||||
|
}
|
||||||
if (names.length) return names.join(', ');
|
if (names.length) return names.join(', ');
|
||||||
} else if (parsed && typeof parsed === 'object') {
|
} else if (parsed && typeof parsed === 'object') {
|
||||||
const name = String((parsed as any).name || (parsed as any).model || (parsed as any).id || '').trim();
|
const name = String((parsed as any).name || (parsed as any).model || (parsed as any).id || '').trim();
|
||||||
|
|
@ -53,7 +56,7 @@ export default function ChatHeader(props: {
|
||||||
<span className="chat-header-name">{agent.name}</span>
|
<span className="chat-header-name">{agent.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-header-desc">
|
<div className="chat-header-desc">
|
||||||
<span className="chat-header-model">#{modelText}</span>
|
<span className="chat-header-model">{modelText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@
|
||||||
background: var(--color-brand-soft);
|
background: var(--color-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-item.active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-outline-index {
|
.chat-outline-index {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { App as AntApp, Empty } from 'antd';
|
import { App as AntApp, Empty } from 'antd';
|
||||||
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
||||||
import { markdownToPlainText } from '../utils/copy';
|
import { markdownToPlainText } from '../utils/copy';
|
||||||
|
|
@ -16,6 +16,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
const viewport = useDesktopViewport();
|
const viewport = useDesktopViewport();
|
||||||
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
||||||
|
const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false);
|
||||||
|
const isAutoScrolling = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
@ -43,11 +45,85 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
} = logic;
|
} = logic;
|
||||||
|
|
||||||
|
// 1. 初始化时,如果有历史消息,激活最后一条 Agent 消息
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 && !highlightId) {
|
||||||
|
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
|
||||||
|
if (agentMsgs.length > 0) {
|
||||||
|
setHighlightId(agentMsgs[agentMsgs.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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]);
|
||||||
|
|
||||||
|
const handleJump = useCallback((msgId: string) => {
|
||||||
|
isAutoScrolling.current = true;
|
||||||
|
setHighlightId(msgId);
|
||||||
|
const el = document.getElementById('msg-' + msgId);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
// 滚动结束后重置标志位,避免触发 handleScroll
|
||||||
|
setTimeout(() => {
|
||||||
|
isAutoScrolling.current = false;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}, [setHighlightId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
|
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
|
||||||
<AgentSidebar
|
<AgentSidebar
|
||||||
agentList={agentList}
|
agentList={agentList}
|
||||||
activeAgentId={id}
|
activeAgentId={id}
|
||||||
|
collapsed={agentSidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setAgentSidebarCollapsed(!agentSidebarCollapsed)}
|
||||||
onCreate={() => navigate('/agents/new')}
|
onCreate={() => navigate('/agents/new')}
|
||||||
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -125,11 +201,7 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
activeId={highlightId}
|
activeId={highlightId}
|
||||||
collapsed={outlineCollapsed}
|
collapsed={outlineCollapsed}
|
||||||
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||||
onJump={(msgId) => {
|
onJump={handleJump}
|
||||||
setHighlightId(msgId);
|
|
||||||
const el = document.getElementById('msg-' + msgId);
|
|
||||||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-container.highlighted {
|
.message-item-container.highlighted {
|
||||||
background: var(--color-surface-2);
|
/* background: var(--color-surface-2); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant,
|
.message-item-assistant,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ export default function MessageItem(props: {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={'msg-' + message.id}
|
id={'msg-' + message.id}
|
||||||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
className={`message-item-container ${highlighted ? 'highlighted' : ''} ${bubbleRole}`}
|
||||||
|
data-msg-id={message.id}
|
||||||
|
data-is-agent={!isUser}
|
||||||
>
|
>
|
||||||
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,19 @@ export function useChatData(args: {
|
||||||
if (rid !== roomId) return;
|
if (rid !== roomId) return;
|
||||||
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
||||||
setBranches(his.branches || {});
|
setBranches(his.branches || {});
|
||||||
requestAnimationFrame(() => {
|
|
||||||
|
const checkScroll = () => {
|
||||||
if (!initialScrollDoneRef.current) {
|
if (!initialScrollDoneRef.current) {
|
||||||
scrollBottom(true);
|
scrollBottom(true);
|
||||||
initialScrollDoneRef.current = true;
|
initialScrollDoneRef.current = true;
|
||||||
} else {
|
} else {
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(checkScroll);
|
||||||
|
setTimeout(checkScroll, 100);
|
||||||
|
setTimeout(checkScroll, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -104,10 +109,8 @@ 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: 'center' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
const t = setTimeout(() => setHighlightId(null), 3000);
|
}, [highlightId]);
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [highlightId, setHighlightId]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent,
|
agent,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export function useChatScroll() {
|
||||||
const initialScrollDoneRef = useRef(false);
|
const initialScrollDoneRef = useRef(false);
|
||||||
const scrollRafRef = useRef<number | null>(null);
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
const scrollProgrammaticAtRef = useRef(0);
|
const scrollProgrammaticAtRef = useRef(0);
|
||||||
|
const forceScrollPendingRef = useRef(false);
|
||||||
const lastScrollTopRef = useRef(0);
|
const lastScrollTopRef = useRef(0);
|
||||||
const userScrollLockRef = useRef(false);
|
const userScrollLockRef = useRef(false);
|
||||||
const lastUserScrollAtRef = useRef(0);
|
const lastUserScrollAtRef = useRef(0);
|
||||||
|
|
@ -16,14 +17,18 @@ export function useChatScroll() {
|
||||||
if (force) {
|
if (force) {
|
||||||
userScrollLockRef.current = false;
|
userScrollLockRef.current = false;
|
||||||
autoScrollRef.current = true;
|
autoScrollRef.current = true;
|
||||||
|
forceScrollPendingRef.current = true;
|
||||||
|
}
|
||||||
|
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
|
|
||||||
if (scrollRafRef.current) {
|
if (scrollRafRef.current) {
|
||||||
cancelAnimationFrame(scrollRafRef.current);
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
}
|
}
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
|
forceScrollPendingRef.current = false;
|
||||||
const el = bodyRef.current;
|
const el = bodyRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
scrollProgrammaticAtRef.current = Date.now();
|
scrollProgrammaticAtRef.current = Date.now();
|
||||||
|
|
@ -55,7 +60,7 @@ export function useChatScroll() {
|
||||||
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
||||||
|
|
||||||
const scrollDelta = nextTop - lastTop;
|
const scrollDelta = nextTop - lastTop;
|
||||||
if (scrollDelta < 0) {
|
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
|
||||||
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
|
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
|
||||||
}
|
}
|
||||||
lastScrollTopRef.current = nextTop;
|
lastScrollTopRef.current = nextTop;
|
||||||
|
|
@ -67,10 +72,18 @@ export function useChatScroll() {
|
||||||
userScrollLockRef.current = false;
|
userScrollLockRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
// 只有当不是程序化滚动,且没有强制滚动在等待时,才根据距离更新 autoScroll 状态
|
||||||
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
|
if (!isProgrammatic && !forceScrollPendingRef.current) {
|
||||||
cancelAnimationFrame(scrollRafRef.current);
|
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
||||||
scrollRafRef.current = null;
|
}
|
||||||
|
|
||||||
|
// 如果 autoScroll 被关闭或者是用户锁定状态,且当前有一个待执行的 RAF
|
||||||
|
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current && !isProgrammatic && !forceScrollPendingRef.current) {
|
||||||
|
// 只有当明确是用户向上滚动了,才取消待执行的程序化滚动
|
||||||
|
if (scrollDelta < 0) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-standardPc .chat-side {
|
.chat-shell.desktop-standardPc .chat-side {
|
||||||
width: 300px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue