mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
♻️ refactor: refactor the main chat (#4773)
* ♻️ refactor: refactor the main chat * ♻️ refactor: refactor welcome
This commit is contained in:
@@ -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';
|
||||
|
||||
+44
@@ -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;
|
||||
+17
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ const BrandWatermark = memo<Omit<FlexboxProps, 'children'>>(({ style, ...rest })
|
||||
return (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
dir={'ltr'}
|
||||
flex={'none'}
|
||||
gap={4}
|
||||
horizontal
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
|
||||
|
||||
export const Artifacts: PortalImpl = {
|
||||
Body,
|
||||
Header,
|
||||
Title: Header,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
export { default as HomeBody } from './Body';
|
||||
export { default as HomeHeader } from './Header';
|
||||
export { default as HomeTitle } from './Title';
|
||||
|
||||
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
|
||||
|
||||
export const MessageDetail: PortalImpl = {
|
||||
Body,
|
||||
Header,
|
||||
Title: Header,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,6 @@ import { useEnable } from './useEnable';
|
||||
|
||||
export const Plugins: PortalImpl = {
|
||||
Body,
|
||||
Header,
|
||||
Title: Header,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FC } from 'react';
|
||||
|
||||
export interface PortalImpl {
|
||||
Body: FC;
|
||||
Header: FC;
|
||||
Header?: FC;
|
||||
Title: FC;
|
||||
onClose?: () => void;
|
||||
useEnable: () => boolean;
|
||||
}
|
||||
|
||||
+3
-4
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user