♻️ refactor: refactor the main chat (#4773)

* ♻️ refactor: refactor the main chat

* ♻️ refactor: refactor welcome
This commit is contained in:
Arvin Xu
2024-11-24 18:29:19 +08:00
committed by GitHub
parent d41f955009
commit 6973f4eebd
36 changed files with 523 additions and 506 deletions
@@ -0,0 +1,39 @@
import React, { memo, useMemo } from 'react';
import { ChatItem } from '@/features/Conversation';
import ActionsBar from '@/features/Conversation/components/ChatItem/ActionsBar';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
export interface ThreadChatItemProps {
id: string;
index: number;
}
const MainChatItem = memo<ThreadChatItemProps>(({ id, index }) => {
const [historyLength] = useChatStore((s) => [chatSelectors.mainDisplayChatIDs(s).length]);
const enableHistoryDivider = useAgentStore((s) => {
const config = agentSelectors.currentAgentChatConfig(s);
return (
config.enableHistoryCount &&
historyLength > (config.historyCount ?? 0) &&
config.historyCount === historyLength - index
);
});
const actionBar = useMemo(() => <ActionsBar id={id} />, [id]);
return (
<ChatItem
actionBar={actionBar}
enableHistoryDivider={enableHistoryDivider}
id={id}
index={index}
/>
);
});
export default MainChatItem;
@@ -1,35 +1,41 @@
'use client';
import isEqual from 'fast-deep-equal';
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { InboxWelcome, VirtualizedList } from '@/features/Conversation';
import { SkeletonList, VirtualizedList } from '@/features/Conversation';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import MainChatItem from './ChatItem';
import Welcome from './WelcomeChatItem';
interface ListProps {
mobile?: boolean;
}
const Content = memo<ListProps>(({ mobile }) => {
const [activeTopicId, useFetchMessages, showInboxWelcome, isCurrentChatLoaded] = useChatStore(
(s) => [
s.activeTopicId,
s.useFetchMessages,
chatSelectors.showInboxWelcome(s),
chatSelectors.isCurrentChatLoaded(s),
],
);
const [activeTopicId, useFetchMessages, isCurrentChatLoaded] = useChatStore((s) => [
s.activeTopicId,
s.useFetchMessages,
chatSelectors.isCurrentChatLoaded(s),
]);
const [sessionId] = useSessionStore((s) => [s.activeId]);
useFetchMessages(sessionId, activeTopicId);
const data = useChatStore(chatSelectors.currentChatIDsWithGuideMessage, isEqual);
const data = useChatStore(chatSelectors.mainDisplayChatIDs);
if (showInboxWelcome && isCurrentChatLoaded) return <InboxWelcome />;
const itemContent = useCallback(
(index: number, id: string) => <MainChatItem id={id} index={index} />,
[mobile],
);
return <VirtualizedList dataSource={data} mobile={mobile} />;
if (!isCurrentChatLoaded) return <SkeletonList mobile={mobile} />;
if (data.length === 0) return <Welcome />;
return <VirtualizedList dataSource={data} itemContent={itemContent} mobile={mobile} />;
});
Content.displayName = 'ChatListRender';
@@ -0,0 +1,44 @@
import { ChatItem } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
const WelcomeMessage = () => {
const { t } = useTranslation('chat');
const [type = 'chat'] = useAgentStore((s) => {
const config = agentSelectors.currentAgentChatConfig(s);
return [config.displayMode];
});
const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual);
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
const activeId = useChatStore((s) => s.activeId);
const agentSystemRoleMsg = t('agentDefaultMessageWithSystemRole', {
name: meta.title || t('defaultAgent'),
systemRole: meta.description,
});
const agentMsg = t(isAgentEditable ? 'agentDefaultMessage' : 'agentDefaultMessageWithoutEdit', {
name: meta.title || t('defaultAgent'),
url: `/chat/settings?session=${activeId}`,
});
return (
<ChatItem
avatar={meta}
editing={false}
message={!!meta.description ? agentSystemRoleMsg : agentMsg}
placement={'left'}
type={type === 'chat' ? 'block' : 'pure'}
/>
);
};
export default WelcomeMessage;
@@ -0,0 +1,17 @@
import React, { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import InboxWelcome from './InboxWelcome';
import WelcomeMessage from './WelcomeMessage';
const WelcomeChatItem = memo(() => {
const showInboxWelcome = useChatStore(chatSelectors.showInboxWelcome);
if (showInboxWelcome) return <InboxWelcome />;
return <WelcomeMessage />;
});
export default WelcomeChatItem;
@@ -1,23 +1,11 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { XIcon } from 'lucide-react';
import { memo } from 'react';
import SidebarHeader from '@/components/SidebarHeader';
import { PortalHeader } from '@/features/Portal/router';
import { useChatStore } from '@/store/chat';
const Header = memo(() => {
const [toggleInspector] = useChatStore((s) => [s.togglePortal]);
return (
<SidebarHeader
actions={<ActionIcon icon={XIcon} onClick={() => toggleInspector(false)} />}
style={{ paddingBlock: 8, paddingInline: 8 }}
title={<PortalHeader />}
/>
);
return <PortalHeader />;
});
export default Header;
+1
View File
@@ -26,6 +26,7 @@ const BrandWatermark = memo<Omit<FlexboxProps, 'children'>>(({ style, ...rest })
return (
<Flexbox
align={'center'}
dir={'ltr'}
flex={'none'}
gap={4}
horizontal
+1 -1
View File
@@ -2,6 +2,6 @@ export const LOADING_FLAT = '...';
export const MESSAGE_CANCEL_FLAT = 'canceled';
export const MESSAGE_THREAD_DIVIDER_ID = 'thread-divider';
export const MESSAGE_THREAD_DIVIDER_ID = '__THREAD_DIVIDER__';
export const MESSAGE_WELCOME_GUIDE_ID = 'welcome';
@@ -4,8 +4,7 @@ import { memo, useCallback } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import { MessageRoleType } from '@/types/message';
import { renderActions, useActionsClick } from '../../Actions';
import { useChatListActionsBar } from '../../hooks/useChatListActionsBar';
@@ -25,34 +24,31 @@ const ActionsBar = memo<ActionsBarProps>((props) => {
});
interface ActionsProps {
index: number;
setEditing: (edit: boolean) => void;
id: string;
}
const Actions = memo<ActionsProps>(({ index, setEditing }) => {
const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual);
const item = useChatStore(
(s) => chatSelectors.currentChatsWithGuideMessage(meta)(s)[index],
isEqual,
);
const Actions = memo<ActionsProps>(({ id }) => {
const item = useChatStore(chatSelectors.getMessageById(id), isEqual);
const [toggleMessageEditing] = useChatStore((s) => [s.toggleMessageEditing]);
const onActionsClick = useActionsClick();
const handleActionClick = useCallback(
async (action: ActionEvent) => {
switch (action.key) {
case 'edit': {
setEditing(true);
toggleMessageEditing(id, true);
}
}
if (!item) return;
onActionsClick(action, item);
},
[item],
);
const RenderFunction = renderActions[item?.role] ?? ActionsBar;
const RenderFunction = renderActions[(item?.role || '') as MessageRoleType] ?? ActionsBar;
return <RenderFunction {...item} onActionClick={handleActionClick} />;
return <RenderFunction {...item!} onActionClick={handleActionClick} />;
});
export default Actions;
@@ -5,13 +5,12 @@ import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { MouseEventHandler, ReactNode, memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { ChatMessage } from '@/types/message';
@@ -26,7 +25,6 @@ import {
} from '../../Messages';
import History from '../History';
import { markdownElements } from '../MarkdownElements';
import ActionsBar from './ActionsBar';
import { processWithArtifact } from './utils';
const rehypePlugins = markdownElements.map((element) => element.rehypePlugin);
@@ -36,6 +34,7 @@ const useStyles = createStyles(({ css, prefixCls }) => ({
opacity: 0.6;
`,
message: css`
position: relative;
// prevent the textarea too long
.${prefixCls}-input {
max-height: 900px;
@@ -44,218 +43,193 @@ const useStyles = createStyles(({ css, prefixCls }) => ({
}));
export interface ChatListItemProps {
hideActionBar?: boolean;
actionBar?: ReactNode;
className?: string;
enableHistoryDivider?: boolean;
endRender?: ReactNode;
id: string;
index: number;
showThreadDivider?: boolean;
}
const Item = memo<ChatListItemProps>(({ index, id, hideActionBar }) => {
const fontSize = useUserStore(userGeneralSettingsSelectors.fontSize);
const { t } = useTranslation('common');
const { styles, cx } = useStyles();
const [type = 'chat'] = useAgentStore((s) => {
const config = agentSelectors.currentAgentChatConfig(s);
return [config.displayMode];
});
const Item = memo<ChatListItemProps>(
({ className, enableHistoryDivider, id, actionBar, endRender }) => {
const fontSize = useUserStore(userGeneralSettingsSelectors.fontSize);
const { t } = useTranslation('common');
const { styles, cx } = useStyles();
const [type = 'chat'] = useAgentStore((s) => {
const config = agentSelectors.currentAgentChatConfig(s);
return [config.displayMode];
});
const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual);
const item = useChatStore((s) => {
const chats = chatSelectors.currentChatsWithGuideMessage(meta)(s);
const item = useChatStore(chatSelectors.getMessageById(id), isEqual);
if (index >= chats.length) return;
const [
isMessageLoading,
generating,
isInRAGFlow,
editing,
toggleMessageEditing,
updateMessageContent,
] = useChatStore((s) => [
chatSelectors.isMessageLoading(id)(s),
chatSelectors.isMessageGenerating(id)(s),
chatSelectors.isMessageInRAGFlow(id)(s),
chatSelectors.isMessageEditing(id)(s),
s.toggleMessageEditing,
s.modifyMessageContent,
]);
return chats.find((s) => s.id === id);
}, isEqual);
// when the message is in RAG flow or the AI generating, it should be in loading state
const isProcessing = isInRAGFlow || generating;
const [
isMessageLoading,
generating,
isInRAGFlow,
editing,
toggleMessageEditing,
updateMessageContent,
] = useChatStore((s) => [
chatSelectors.isMessageLoading(id)(s),
chatSelectors.isMessageGenerating(id)(s),
chatSelectors.isMessageInRAGFlow(id)(s),
chatSelectors.isMessageEditing(id)(s),
s.toggleMessageEditing,
s.modifyMessageContent,
]);
const onAvatarsClick = useAvatarsClick(item?.role);
// when the message is in RAG flow or the AI generating, it should be in loading state
const isProcessing = isInRAGFlow || generating;
const renderMessage = useCallback(
(editableContent: ReactNode) => {
if (!item?.role) return;
const RenderFunction = renderMessages[item.role] ?? renderMessages['default'];
const onAvatarsClick = useAvatarsClick(item?.role);
if (!RenderFunction) return;
const renderMessage = useCallback(
(editableContent: ReactNode) => {
if (!item?.role) return;
const RenderFunction = renderMessages[item.role] ?? renderMessages['default'];
if (!RenderFunction) return;
return <RenderFunction {...item} editableContent={editableContent} />;
},
[item],
);
const BelowMessage = useCallback(
({ data }: { data: ChatMessage }) => {
if (!item?.role) return;
const RenderFunction = renderBelowMessages[item.role] ?? renderBelowMessages['default'];
if (!RenderFunction) return;
return <RenderFunction {...data} />;
},
[item?.role],
);
const MessageExtra = useCallback(
({ data }: { data: ChatMessage }) => {
if (!item?.role) return;
let RenderFunction;
if (renderMessagesExtra?.[item.role]) RenderFunction = renderMessagesExtra[item.role];
if (!RenderFunction) return;
return <RenderFunction {...data} />;
},
[item?.role],
);
const markdownCustomRender = useCallback(
(dom: ReactNode, { text }: { text: string }) => {
if (!item?.role) return dom;
let RenderFunction;
if (renderMessagesExtra?.[item.role]) RenderFunction = markdownCustomRenders[item.role];
if (!RenderFunction) return dom;
return <RenderFunction displayMode={type} dom={dom} id={id} text={text} />;
},
[item?.role, type],
);
const error = useErrorContent(item?.error);
const [historyLength] = useChatStore((s) => [chatSelectors.currentChats(s).length]);
const enableHistoryDivider = useAgentStore((s) => {
const config = agentSelectors.currentAgentChatConfig(s);
return (
config.enableHistoryCount &&
historyLength > (config.historyCount ?? 0) &&
config.historyCount === historyLength - index
return <RenderFunction {...item} editableContent={editableContent} />;
},
[item],
);
});
// remove line breaks in artifact tag to make the ast transform easier
const message =
!editing && item?.role === 'assistant' ? processWithArtifact(item?.content) : item?.content;
const BelowMessage = useCallback(
({ data }: { data: ChatMessage }) => {
if (!item?.role) return;
const RenderFunction = renderBelowMessages[item.role] ?? renderBelowMessages['default'];
// ======================= Performance Optimization ======================= //
// these useMemo/useCallback are all for the performance optimization
// maybe we can remove it in React 19
// ======================================================================== //
if (!RenderFunction) return;
const components = useMemo(
() =>
Object.fromEntries(
markdownElements.map((element) => {
const Component = element.Component;
return <RenderFunction {...data} />;
},
[item?.role],
);
return [element.tag, (props: any) => <Component {...props} id={id} />];
}),
),
[id],
);
const MessageExtra = useCallback(
({ data }: { data: ChatMessage }) => {
if (!item?.role) return;
let RenderFunction;
if (renderMessagesExtra?.[item.role]) RenderFunction = renderMessagesExtra[item.role];
const markdownProps = useMemo(
() => ({
components,
customRender: markdownCustomRender,
rehypePlugins,
}),
[components, markdownCustomRender],
);
if (!RenderFunction) return;
return <RenderFunction {...data} />;
},
[item?.role],
);
const onChange = useCallback((value: string) => updateMessageContent(id, value), [id]);
const markdownCustomRender = useCallback(
(dom: ReactNode, { text }: { text: string }) => {
if (!item?.role) return dom;
let RenderFunction;
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(
(e) => {
if (!item) return;
if (item.id === 'default' || item.error) return;
if (item.role && ['assistant', 'user'].includes(item.role) && e.altKey) {
toggleMessageEditing(id, true);
}
},
[item],
);
if (renderMessagesExtra?.[item.role]) RenderFunction = markdownCustomRenders[item.role];
if (!RenderFunction) return dom;
const text = useMemo(
() => ({
cancel: t('cancel'),
confirm: t('ok'),
edit: t('edit'),
}),
[t],
);
return <RenderFunction displayMode={type} dom={dom} id={id} text={text} />;
},
[item?.role, type],
);
const onEditingChange = useCallback((edit: boolean) => {
toggleMessageEditing(id, edit);
}, []);
const error = useErrorContent(item?.error);
const actions = useMemo(
() =>
!hideActionBar && (
<ActionsBar
index={index}
setEditing={(edit) => {
toggleMessageEditing(id, edit);
}}
/>
),
[hideActionBar, index, id],
);
// remove line breaks in artifact tag to make the ast transform easier
const message =
!editing && item?.role === 'assistant' ? processWithArtifact(item?.content) : item?.content;
const belowMessage = useMemo(() => item && <BelowMessage data={item} />, [item]);
const errorMessage = useMemo(() => item && <ErrorMessageExtra data={item} />, [item]);
const messageExtra = useMemo(() => item && <MessageExtra data={item} />, [item]);
// ======================= Performance Optimization ======================= //
// these useMemo/useCallback are all for the performance optimization
// maybe we can remove it in React 19
// ======================================================================== //
return (
item && (
<>
{enableHistoryDivider && <History />}
<ChatItem
actions={actions}
avatar={item.meta}
belowMessage={belowMessage}
className={cx(styles.message, isMessageLoading && styles.loading)}
editing={editing}
error={error}
errorMessage={errorMessage}
fontSize={fontSize}
loading={isProcessing}
markdownProps={markdownProps}
message={message}
messageExtra={messageExtra}
onAvatarClick={onAvatarsClick}
onChange={onChange}
onDoubleClick={onDoubleClick}
onEditingChange={onEditingChange}
placement={type === 'chat' ? (item.role === 'user' ? 'right' : 'left') : 'left'}
primary={item.role === 'user'}
renderMessage={renderMessage}
text={text}
time={item.updatedAt || item.createdAt}
type={type === 'chat' ? 'block' : 'pure'}
/>
</>
)
);
});
const components = useMemo(
() =>
Object.fromEntries(
markdownElements.map((element) => {
const Component = element.Component;
return [element.tag, (props: any) => <Component {...props} id={id} />];
}),
),
[id],
);
const markdownProps = useMemo(
() => ({
components,
customRender: markdownCustomRender,
rehypePlugins,
}),
[components, markdownCustomRender],
);
const onChange = useCallback((value: string) => updateMessageContent(id, value), [id]);
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(
(e) => {
if (!item) return;
if (item.id === 'default' || item.error) return;
if (item.role && ['assistant', 'user'].includes(item.role) && e.altKey) {
toggleMessageEditing(id, true);
}
},
[item],
);
const text = useMemo(
() => ({
cancel: t('cancel'),
confirm: t('ok'),
edit: t('edit'),
}),
[t],
);
const onEditingChange = useCallback((edit: boolean) => {
toggleMessageEditing(id, edit);
}, []);
const belowMessage = useMemo(() => item && <BelowMessage data={item} />, [item]);
const errorMessage = useMemo(() => item && <ErrorMessageExtra data={item} />, [item]);
const messageExtra = useMemo(() => item && <MessageExtra data={item} />, [item]);
return (
item && (
<>
{enableHistoryDivider && <History />}
<Flexbox className={cx(styles.message, className, isMessageLoading && styles.loading)}>
<ChatItem
actions={actionBar}
avatar={item.meta}
belowMessage={belowMessage}
editing={editing}
error={error}
errorMessage={errorMessage}
fontSize={fontSize}
loading={isProcessing}
markdownProps={markdownProps}
message={message}
messageExtra={messageExtra}
onAvatarClick={onAvatarsClick}
onChange={onChange}
onDoubleClick={onDoubleClick}
onEditingChange={onEditingChange}
placement={type === 'chat' ? (item.role === 'user' ? 'right' : 'left') : 'left'}
primary={item.role === 'user'}
renderMessage={renderMessage}
text={text}
time={item.updatedAt || item.createdAt}
type={type === 'chat' ? 'block' : 'pure'}
/>
{endRender}
</Flexbox>
</>
)
);
},
);
Item.displayName = 'ChatItem';
@@ -12,105 +12,96 @@ import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import AutoScroll from '../AutoScroll';
import Item from '../ChatItem';
import SkeletonList from '../SkeletonList';
interface VirtualizedListProps {
dataSource: string[];
hideActionBar?: boolean;
itemContent?: (index: number, data: any, context: any) => ReactNode;
itemContent: (index: number, data: any, context: any) => ReactNode;
mobile?: boolean;
}
const VirtualizedList = memo<VirtualizedListProps>(
({ mobile, dataSource, hideActionBar, itemContent }) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [atBottom, setAtBottom] = useState(true);
const [isScrolling, setIsScrolling] = useState(false);
const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemContent }) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [atBottom, setAtBottom] = useState(true);
const [isScrolling, setIsScrolling] = useState(false);
const [id, isFirstLoading, isCurrentChatLoaded] = useChatStore((s) => [
chatSelectors.currentChatKey(s),
chatSelectors.currentChatLoadingState(s),
chatSelectors.isCurrentChatLoaded(s),
]);
const [id, isFirstLoading, isCurrentChatLoaded] = useChatStore((s) => [
chatSelectors.currentChatKey(s),
chatSelectors.currentChatLoadingState(s),
chatSelectors.isCurrentChatLoaded(s),
]);
useEffect(() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
}
}, [id]);
useEffect(() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
}
}, [id]);
const prevDataLengthRef = useRef(dataSource.length);
const prevDataLengthRef = useRef(dataSource.length);
const getFollowOutput = useCallback(() => {
const newFollowOutput = dataSource.length > prevDataLengthRef.current ? 'auto' : false;
prevDataLengthRef.current = dataSource.length;
return newFollowOutput;
}, [dataSource.length]);
const getFollowOutput = useCallback(() => {
const newFollowOutput = dataSource.length > prevDataLengthRef.current ? 'auto' : false;
prevDataLengthRef.current = dataSource.length;
return newFollowOutput;
}, [dataSource.length]);
const theme = useTheme();
// overscan should be 3 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 3 : 0;
const theme = useTheme();
// overscan should be 3 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 3 : 0;
const defaultItemContent = useCallback(
(index: number, id: string) => <Item hideActionBar={hideActionBar} id={id} index={index} />,
[mobile, hideActionBar],
// first time loading or not loaded
if (isFirstLoading) return <SkeletonList mobile={mobile} />;
if (!isCurrentChatLoaded)
// use skeleton list when not loaded in server mode due to the loading duration is much longer than client mode
return isServerMode ? (
<SkeletonList mobile={mobile} />
) : (
// in client mode and switch page, using the center loading for smooth transition
<Center height={'100%'} width={'100%'}>
<Icon
icon={Loader2Icon}
size={{ fontSize: 32 }}
spin
style={{ color: theme.colorTextTertiary }}
/>
</Center>
);
// first time loading or not loaded
if (isFirstLoading) return <SkeletonList mobile={mobile} />;
if (!isCurrentChatLoaded)
// use skeleton list when not loaded in server mode due to the loading duration is much longer than client mode
return isServerMode ? (
<SkeletonList mobile={mobile} />
) : (
// in client mode and switch page, using the center loading for smooth transition
<Center height={'100%'} width={'100%'}>
<Icon
icon={Loader2Icon}
size={{ fontSize: 32 }}
spin
style={{ color: theme.colorTextTertiary }}
/>
</Center>
);
return (
<Flexbox height={'100%'}>
<Virtuoso
atBottomStateChange={setAtBottom}
atBottomThreshold={50 * (mobile ? 2 : 1)}
computeItemKey={(_, item) => item}
data={dataSource}
followOutput={getFollowOutput}
increaseViewportBy={overscan}
initialTopMostItemIndex={dataSource?.length - 1}
isScrolling={setIsScrolling}
itemContent={itemContent ?? defaultItemContent}
overscan={overscan}
ref={virtuosoRef}
/>
<AutoScroll
atBottom={atBottom}
isScrolling={isScrolling}
onScrollToBottom={(type) => {
const virtuoso = virtuosoRef.current;
switch (type) {
case 'auto': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
break;
}
case 'click': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
break;
}
return (
<Flexbox height={'100%'}>
<Virtuoso
atBottomStateChange={setAtBottom}
atBottomThreshold={50 * (mobile ? 2 : 1)}
computeItemKey={(_, item) => item}
data={dataSource}
followOutput={getFollowOutput}
increaseViewportBy={overscan}
initialTopMostItemIndex={dataSource?.length - 1}
isScrolling={setIsScrolling}
itemContent={itemContent}
overscan={overscan}
ref={virtuosoRef}
/>
<AutoScroll
atBottom={atBottom}
isScrolling={isScrolling}
onScrollToBottom={(type) => {
const virtuoso = virtuosoRef.current;
switch (type) {
case 'auto': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
break;
}
}}
/>
</Flexbox>
);
},
);
case 'click': {
virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
break;
}
}
}}
/>
</Flexbox>
);
});
export default VirtualizedList;
-1
View File
@@ -1,4 +1,3 @@
export { default as ChatItem } from './components/ChatItem';
export { default as InboxWelcome } from './components/InboxWelcome';
export { default as SkeletonList } from './components/SkeletonList';
export { default as VirtualizedList } from './components/VirtualizedList';
+1 -1
View File
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
export const Artifacts: PortalImpl = {
Body,
Header,
Title: Header,
useEnable,
};
+1 -1
View File
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
export const FilePreview: PortalImpl = {
Body,
Header,
Title: Header,
useEnable,
};
@@ -4,7 +4,7 @@ import { Typography } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const Header = memo(() => {
const Title = memo(() => {
const { t } = useTranslation('portal');
return (
@@ -14,4 +14,4 @@ const Header = memo(() => {
);
});
export default Header;
export default Title;
+1 -1
View File
@@ -1,2 +1,2 @@
export { default as HomeBody } from './Body';
export { default as HomeHeader } from './Header';
export { default as HomeTitle } from './Title';
+1 -1
View File
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
export const MessageDetail: PortalImpl = {
Body,
Header,
Title: Header,
useEnable,
};
+1 -1
View File
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
export const Plugins: PortalImpl = {
Body,
Header,
Title: Header,
useEnable,
};
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { XIcon } from 'lucide-react';
import { ReactNode, memo } from 'react';
import SidebarHeader from '@/components/SidebarHeader';
import { useChatStore } from '@/store/chat';
const Header = memo<{ title: ReactNode }>(({ title }) => {
const [toggleInspector] = useChatStore((s) => [s.togglePortal]);
return (
<SidebarHeader
actions={
<ActionIcon
icon={XIcon}
onClick={() => {
toggleInspector(false);
}}
/>
}
style={{ paddingBlock: 8, paddingInline: 8 }}
title={title}
/>
);
});
export default Header;
+22 -3
View File
@@ -4,13 +4,32 @@ import { memo } from 'react';
import { Artifacts } from './Artifacts';
import { FilePreview } from './FilePreview';
import { HomeBody, HomeHeader } from './Home';
import { HomeBody, HomeTitle } from './Home';
import { MessageDetail } from './MessageDetail';
import { Plugins } from './Plugins';
import Header from './components/Header';
import { PortalImpl } from './type';
const items: PortalImpl[] = [MessageDetail, Artifacts, Plugins, FilePreview];
export const PortalTitle = memo(() => {
const enabledList: boolean[] = [];
for (const item of items) {
const enabled = item.useEnable();
enabledList.push(enabled);
}
for (const [i, element] of enabledList.entries()) {
const Title = items[i].Title;
if (element) {
return <Title />;
}
}
return <HomeTitle />;
});
export const PortalHeader = memo(() => {
const enabledList: boolean[] = [];
@@ -21,12 +40,12 @@ export const PortalHeader = memo(() => {
for (const [i, element] of enabledList.entries()) {
const Header = items[i].Header;
if (element) {
if (element && Header) {
return <Header />;
}
}
return <HomeHeader />;
return <Header title={<PortalTitle />} />;
});
const PortalBody = memo(() => {
+3 -1
View File
@@ -2,6 +2,8 @@ import { FC } from 'react';
export interface PortalImpl {
Body: FC;
Header: FC;
Header?: FC;
Title: FC;
onClose?: () => void;
useEnable: () => boolean;
}
@@ -1,18 +1,17 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { ChatItem } from '@/features/Conversation';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import Item from '../ChatItem';
const ChatList = memo(() => {
const ids = useChatStore(chatSelectors.currentChatIDsWithGuideMessage);
const ids = useChatStore(chatSelectors.mainDisplayChatIDs);
return (
<Flexbox height={'100%'} style={{ paddingTop: 24, position: 'relative' }}>
{ids.map((id, index) => (
<Item id={id} index={index} key={id} />
<ChatItem id={id} index={index} key={id} />
))}
</Flexbox>
);
@@ -6,7 +6,6 @@ import { Flexbox } from 'react-layout-kit';
import PluginTag from '@/app/(main)/chat/(workspace)/features/PluginTag';
import { ProductLogo } from '@/components/Branding';
import ChatList from '@/features/Conversation/components/ChatList';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useSessionStore } from '@/store/session';
@@ -14,6 +13,7 @@ import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selector
import pkg from '../../../../package.json';
import { useContainerStyles } from '../style';
import ChatList from './ChatList';
import { useStyles } from './style';
import { FieldType } from './type';
+1 -1
View File
@@ -46,7 +46,7 @@ const ShareImage = memo(() => {
];
const systemRole = useAgentStore(agentSelectors.currentAgentSystemRole);
const messages = useChatStore(chatSelectors.currentChats, isEqual);
const messages = useChatStore(chatSelectors.activeBaseChats, isEqual);
const data = generateMessages({ ...fieldValue, messages, systemRole });
const content = JSON.stringify(data, null, 2);
+1 -1
View File
@@ -62,7 +62,7 @@ const ShareText = memo(() => {
];
const [systemRole] = useAgentStore((s) => [agentSelectors.currentAgentSystemRole(s)]);
const messages = useChatStore(chatSelectors.currentChats, isEqual);
const messages = useChatStore(chatSelectors.activeBaseChats, isEqual);
const topic = useChatStore(topicSelectors.currentActiveTopic, isEqual);
const title = topic?.title || t('shareModal.exportTitle');
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Script from 'next/script';
import React, { memo } from 'react';
const Debug = memo(() => {
const searchParams = useSearchParams();
const debug = searchParams.get('debug');
return !!debug && <Script src="https://unpkg.com/react-scan/dist/auto.global.js" />;
});
export default Debug;
+2
View File
@@ -18,6 +18,7 @@ import { getAntdLocale } from '@/utils/locale';
import { isMobileDevice } from '@/utils/server/responsive';
import AppTheme from './AppTheme';
import Debug from './Debug';
import Locale from './Locale';
import QueryProvider from './Query';
import StoreInitialization from './StoreInitialization';
@@ -85,6 +86,7 @@ const GlobalLayout = async ({ children }: PropsWithChildren) => {
<StoreInitialization />
</ServerConfigStoreProvider>
<DebugUI />
<Debug />
</AppTheme>
</Locale>
</StyleRegistry>
@@ -148,7 +148,7 @@ export const generateAIChat: StateCreator<
// if autoCreateTopic is enabled, check to whether we need to create a topic
if (!onlyAddUserMessage && !activeTopicId && agentConfig.enableAutoCreateTopic) {
// check activeTopic and then auto create topic
const chats = chatSelectors.currentChats(get());
const chats = chatSelectors.activeBaseChats(get());
// we will add two messages (user and assistant), so the finial length should +2
const featureLength = chats.length + 2;
@@ -207,7 +207,7 @@ export const generateAIChat: StateCreator<
}
// Get the current messages to generate AI response
const messages = chatSelectors.currentChats(get());
const messages = chatSelectors.mainDisplayChats(get());
const userFiles = chatSelectors.currentUserFiles(get()).map((f) => f.id);
await internal_coreProcessMessage(messages, id, {
@@ -223,7 +223,7 @@ export const generateAIChat: StateCreator<
// check activeTopic and then auto update topic title
if (newTopicId) {
const chats = chatSelectors.currentChats(get());
const chats = chatSelectors.activeBaseChats(get());
await get().summaryTopicTitle(newTopicId, chats);
return;
}
@@ -231,7 +231,7 @@ export const generateAIChat: StateCreator<
const topic = topicSelectors.currentActiveTopic(get());
if (topic && !topic.title) {
const chats = chatSelectors.currentChats(get());
const chats = chatSelectors.activeBaseChats(get());
await get().summaryTopicTitle(topic.id, chats);
}
};
@@ -484,7 +484,7 @@ export const generateAIChat: StateCreator<
internal_resendMessage: async (messageId, traceId) => {
// 1. 构造所有相关的历史记录
const chats = chatSelectors.currentChats(get());
const chats = chatSelectors.mainDisplayChats(get());
const currentIndex = chats.findIndex((c) => c.id === messageId);
if (currentIndex < 0) return;
+2 -2
View File
@@ -128,7 +128,7 @@ export const chatMessage: StateCreator<
if (message.tools) {
const toolMessageIds = message.tools.flatMap((tool) => {
const messages = chatSelectors
.currentChats(get())
.activeBaseChats(get())
.filter((m) => m.tool_call_id === tool.id);
return messages.map((m) => m.id);
@@ -252,7 +252,7 @@ export const chatMessage: StateCreator<
if (!activeId) return;
const messages = messagesReducer(chatSelectors.currentChats(get()), payload);
const messages = messagesReducer(chatSelectors.activeBaseChats(get()), payload);
const nextMap = { ...get().messagesMap, [chatSelectors.currentChatKey(get())]: messages };
@@ -232,62 +232,6 @@ describe('chatSelectors', () => {
});
});
describe('currentChatsWithGuideMessage', () => {
it('should return existing messages except tool message', () => {
const state = merge(initialStore, {
messagesMap: {
[messageMapKey('someActiveId')]: mockMessages,
},
activeId: 'someActiveId',
});
const chats = chatSelectors.currentChatsWithGuideMessage({} as MetaData)(state);
expect(chats).toEqual(mockedChats.slice(0, 2));
});
it('should add a guide message if the chat is brand new', () => {
const state = merge(initialStore, { messages: [], activeId: 'someActiveId' });
const metaData = { title: 'Mock Agent', description: 'Mock Description' };
const chats = chatSelectors.currentChatsWithGuideMessage(metaData)(state);
expect(chats).toHaveLength(1);
expect(chats[0].content).toBeDefined();
expect(chats[0].meta.avatar).toEqual(DEFAULT_INBOX_AVATAR);
expect(chats[0].meta).toEqual(expect.objectContaining(metaData));
});
it('should use inbox message for INBOX_SESSION_ID', () => {
const state = merge(initialStore, { messages: [], activeId: INBOX_SESSION_ID });
const metaData = { title: 'Mock Agent', description: 'Mock Description' };
const chats = chatSelectors.currentChatsWithGuideMessage(metaData)(state);
expect(chats[0].content).toEqual(''); // Assuming translation returns a string containing this
});
it('should use agent default message for non-inbox sessions', () => {
const state = merge(initialStore, { messages: [], activeId: 'someActiveId' });
const metaData = { title: 'Mock Agent' };
const chats = chatSelectors.currentChatsWithGuideMessage(metaData)(state);
expect(chats[0].content).toMatch('agentDefaultMessage'); // Assuming translation returns a string containing this
});
it('should use agent default message without edit button for non-inbox sessions when agent is not editable', () => {
act(() => {
createServerConfigStore().setState({ featureFlags: { edit_agent: false } });
});
const state = merge(initialStore, { messages: [], activeId: 'someActiveId' });
const metaData = { title: 'Mock Agent' };
const chats = chatSelectors.currentChatsWithGuideMessage(metaData)(state);
expect(chats[0].content).toMatch('agentDefaultMessageWithoutEdit');
});
});
describe('chatsMessageString', () => {
it('should concatenate the contents of all messages returned by currentChatsWithHistoryConfig', () => {
// Prepare a state with a few messages
@@ -415,36 +359,6 @@ describe('chatSelectors', () => {
});
});
describe('currentChatIDsWithGuideMessage', () => {
it('should return message IDs including guide message for empty chat', () => {
const state: Partial<ChatStore> = {
activeId: 'test-id',
messagesMap: {
[messageMapKey('test-id')]: [],
},
};
const result = chatSelectors.currentChatIDsWithGuideMessage(state as ChatStore);
expect(result).toHaveLength(1);
expect(result[0]).toBe('default');
});
it('should return existing message IDs for non-empty chat', () => {
const messages = [
{ id: '1', role: 'user', content: 'Hello' },
{ id: '2', role: 'assistant', content: 'Hi' },
] as ChatMessage[];
const state: Partial<ChatStore> = {
activeId: 'test-id',
messagesMap: {
[messageMapKey('test-id')]: messages,
},
};
const result = chatSelectors.currentChatIDsWithGuideMessage(state as ChatStore);
expect(result).toHaveLength(2);
expect(result).toEqual(['1', '2']);
});
});
describe('isToolCallStreaming', () => {
it('should return true when tool call is streaming for given message and index', () => {
const state: Partial<ChatStore> = {
+55 -73
View File
@@ -1,19 +1,13 @@
import { t } from 'i18next';
import { DEFAULT_INBOX_AVATAR, DEFAULT_USER_AVATAR } from '@/const/meta';
import { DEFAULT_USER_AVATAR } from '@/const/meta';
import { INBOX_SESSION_ID } from '@/const/session';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { featureFlagsSelectors } from '@/store/serverConfig';
import { createServerConfigStore } from '@/store/serverConfig/store';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { ChatFileItem, ChatMessage } from '@/types/message';
import { MetaData } from '@/types/meta';
import { merge } from '@/utils/merge';
import { chatHelpers } from '../../helpers';
import type { ChatStoreState } from '../../initialState';
@@ -38,8 +32,10 @@ const getMeta = (message: ChatMessage) => {
const currentChatKey = (s: ChatStoreState) => messageMapKey(s.activeId, s.activeTopicId);
// 当前激活的消息列表
const currentChats = (s: ChatStoreState): ChatMessage[] => {
/**
* Current active raw message list, include thread messages
*/
const activeBaseChats = (s: ChatStoreState): ChatMessage[] => {
if (!s.activeId) return [];
const messages = s.messagesMap[currentChatKey(s)] || [];
@@ -47,14 +43,54 @@ const currentChats = (s: ChatStoreState): ChatMessage[] => {
return messages.map((i) => ({ ...i, meta: getMeta(i) }));
};
/**
* 排除掉所有 tool 消息,在展示时需要使用
*/
const activeBaseChatsWithoutTool = (s: ChatStoreState) => {
const messages = activeBaseChats(s);
return messages.filter((m) => m.role !== 'tool');
};
/**
* Main display chats
* 根据当前不同的状态,返回不同的消息列表
*/
const mainDisplayChats = (s: ChatStoreState): ChatMessage[] => {
// 如果没有 activeThreadId,则返回所有的主消息
return activeBaseChats(s);
// const mains = activeBaseChats(s).filter((m) => !m.threadId);
// if (!s.activeThreadId) return mains;
//
// const thread = s.threadMaps[s.activeTopicId!]?.find((t) => t.id === s.activeThreadId);
//
// if (!thread) return mains;
//
// const sourceIndex = mains.findIndex((m) => m.id === thread.sourceMessageId);
// const sliced = mains.slice(0, sourceIndex + 1);
//
// return [...sliced, ...activeBaseChats(s).filter((m) => m.threadId === s.activeThreadId)];
};
const mainDisplayChatIDs = (s: ChatStoreState) => {
return mainDisplayChats(s).map((s) => s.id);
};
const currentChatsWithHistoryConfig = (s: ChatStoreState): ChatMessage[] => {
const chats = activeBaseChats(s);
const config = agentSelectors.currentAgentChatConfig(useAgentStore.getState());
return chatHelpers.getSlicedMessagesWithConfig(chats, config);
};
const currentToolMessages = (s: ChatStoreState) => {
const messages = currentChats(s);
const messages = activeBaseChats(s);
return messages.filter((m) => m.role === 'tool');
};
const currentUserMessages = (s: ChatStoreState) => {
const messages = currentChats(s);
const messages = activeBaseChats(s);
return messages.filter((m) => m.role === 'user');
};
@@ -68,84 +104,29 @@ const currentUserFiles = (s: ChatStoreState) => {
.filter(Boolean) as ChatFileItem[];
};
const initTime = Date.now();
const showInboxWelcome = (s: ChatStoreState): boolean => {
const isInbox = s.activeId === INBOX_SESSION_ID;
if (!isInbox) return false;
const data = currentChats(s);
const data = activeBaseChats(s);
return data.length === 0;
};
// Custom message for new assistant initialization
const currentChatsWithGuideMessage =
(meta: MetaData) =>
(s: ChatStoreState): ChatMessage[] => {
// skip tool message
const data = currentChats(s).filter((m) => m.role !== 'tool');
const { isAgentEditable } = featureFlagsSelectors(createServerConfigStore().getState());
const isBrandNewChat = data.length === 0;
if (!isBrandNewChat) return data;
const [activeId, isInbox] = [s.activeId, s.activeId === INBOX_SESSION_ID];
const inboxMsg = '';
const agentSystemRoleMsg = t('agentDefaultMessageWithSystemRole', {
name: meta.title || t('defaultAgent'),
ns: 'chat',
systemRole: meta.description,
});
const agentMsg = t(isAgentEditable ? 'agentDefaultMessage' : 'agentDefaultMessageWithoutEdit', {
name: meta.title || t('defaultAgent'),
ns: 'chat',
url: `/chat/settings?session=${activeId}`,
});
const emptyInboxGuideMessage = {
content: isInbox ? inboxMsg : !!meta.description ? agentSystemRoleMsg : agentMsg,
createdAt: initTime,
extra: {},
id: 'default',
meta: merge({ avatar: DEFAULT_INBOX_AVATAR }, meta),
role: 'assistant',
updatedAt: initTime,
} as ChatMessage;
return [emptyInboxGuideMessage];
};
const currentChatIDsWithGuideMessage = (s: ChatStoreState) => {
const meta = sessionMetaSelectors.currentAgentMeta(useSessionStore.getState());
return currentChatsWithGuideMessage(meta)(s).map((s) => s.id);
};
const currentChatsWithHistoryConfig = (s: ChatStoreState): ChatMessage[] => {
const chats = currentChats(s);
const config = agentSelectors.currentAgentChatConfig(useAgentStore.getState());
return chatHelpers.getSlicedMessagesWithConfig(chats, config);
};
const chatsMessageString = (s: ChatStoreState): string => {
const chats = currentChatsWithHistoryConfig(s);
return chats.map((m) => m.content).join('');
};
const getMessageById = (id: string) => (s: ChatStoreState) =>
chatHelpers.getMessageById(currentChats(s), id);
chatHelpers.getMessageById(activeBaseChats(s), id);
const getMessageByToolCallId = (id: string) => (s: ChatStoreState) => {
const messages = currentChats(s);
const messages = activeBaseChats(s);
return messages.find((m) => m.tool_call_id === id);
};
const getTraceIdByMessageId = (id: string) => (s: ChatStoreState) => getMessageById(id)(s)?.traceId;
const latestMessage = (s: ChatStoreState) => currentChats(s).at(-1);
const latestMessage = (s: ChatStoreState) => activeBaseChats(s).at(-1);
const currentChatLoadingState = (s: ChatStoreState) => !s.messagesInit;
@@ -187,12 +168,11 @@ const isSendButtonDisabledByMessage = (s: ChatStoreState) =>
isInRAGFlow(s);
export const chatSelectors = {
activeBaseChats,
activeBaseChatsWithoutTool,
chatsMessageString,
currentChatIDsWithGuideMessage,
currentChatKey,
currentChatLoadingState,
currentChats,
currentChatsWithGuideMessage,
currentChatsWithHistoryConfig,
currentToolMessages,
currentUserFiles,
@@ -211,5 +191,7 @@ export const chatSelectors = {
isSendButtonDisabledByMessage,
isToolCallStreaming,
latestMessage,
mainDisplayChatIDs,
mainDisplayChats,
showInboxWelcome,
};
+2 -2
View File
@@ -149,7 +149,7 @@ describe('ChatPluginAction', () => {
it('should update message content and trigger the ai message', async () => {
// 设置模拟函数的返回值
const mockCurrentChats: any[] = [];
vi.spyOn(chatSelectors, 'currentChats').mockReturnValue(mockCurrentChats);
vi.spyOn(chatSelectors, 'activeBaseChats').mockReturnValue(mockCurrentChats);
// 设置初始状态
const initialState = {
@@ -184,7 +184,7 @@ describe('ChatPluginAction', () => {
it('should update message content and not trigger ai message', async () => {
// 设置模拟函数的返回值
const mockCurrentChats: any[] = [];
vi.spyOn(chatSelectors, 'currentChats').mockReturnValue(mockCurrentChats);
vi.spyOn(chatSelectors, 'activeBaseChats').mockReturnValue(mockCurrentChats);
// 设置初始状态
const initialState = {
+1 -1
View File
@@ -202,7 +202,7 @@ export const chatPlugin: StateCreator<
triggerAIMessage: async ({ parentId, traceId }) => {
const { internal_coreProcessMessage } = get();
const chats = chatSelectors.currentChats(get());
const chats = chatSelectors.activeBaseChats(get());
await internal_coreProcessMessage(chats, parentId ?? chats.at(-1)!.id, { traceId });
},
+2 -2
View File
@@ -79,7 +79,7 @@ export const chatTopic: StateCreator<
createTopic: async () => {
const { activeId, internal_createTopic } = get();
const messages = chatSelectors.currentChats(get());
const messages = chatSelectors.activeBaseChats(get());
set({ creatingTopic: true }, false, n('creatingTopic/start'));
const topicId = await internal_createTopic({
@@ -94,7 +94,7 @@ export const chatTopic: StateCreator<
saveToTopic: async () => {
// if there is no message, stop
const messages = chatSelectors.currentChats(get());
const messages = chatSelectors.activeBaseChats(get());
if (messages.length === 0) return;
const { activeId, summaryTopicTitle, internal_createTopic } = get();