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