mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 05:18:31 +00:00
✨ feat: display assistant message in group (#9941)
* use message group * refactor * fix tests * fix tests
This commit is contained in:
@@ -24,13 +24,13 @@ const LabsPage = memo(() => {
|
||||
const [
|
||||
isPreferenceInit,
|
||||
enableInputMarkdown,
|
||||
// enableAssistantMessageGroup,
|
||||
enableAssistantMessageGroup,
|
||||
// enableGroupChat,
|
||||
updateLab,
|
||||
] = useUserStore((s) => [
|
||||
preferenceSelectors.isPreferenceInit(s),
|
||||
labPreferSelectors.enableInputMarkdown(s),
|
||||
// labPreferSelectors.enableAssistantMessageGroup(s),
|
||||
labPreferSelectors.enableAssistantMessageGroup(s),
|
||||
// labPreferSelectors.enableGroupChat(s),
|
||||
s.updateLab,
|
||||
]);
|
||||
@@ -43,12 +43,13 @@ const LabsPage = memo(() => {
|
||||
key: 'enableInputMarkdown',
|
||||
title: t('features.inputMarkdown.title'),
|
||||
},
|
||||
// {
|
||||
// checked: enableAssistantMessageGroup,
|
||||
// desc: t('features.assistantMessageGroup.desc'),
|
||||
// key: 'enableAssistantMessageGroup',
|
||||
// title: t('features.assistantMessageGroup.title'),
|
||||
// },
|
||||
{
|
||||
checked: enableAssistantMessageGroup,
|
||||
cover: 'https://github.com/user-attachments/assets/ba517751-1f3b-4269-979e-f8471e3ebb89',
|
||||
desc: t('features.assistantMessageGroup.desc'),
|
||||
key: 'enableAssistantMessageGroup',
|
||||
title: t('features.assistantMessageGroup.title'),
|
||||
},
|
||||
// {
|
||||
// checked: enableGroupChat,
|
||||
// cover: 'https://github.com/user-attachments/assets/72894d24-a96a-4d7c-a823-ff9e6a1a8b6d',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AssistantContentBlock, UIChatMessage } from '@lobechat/types';
|
||||
import { ActionIconGroup, type ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { memo, use, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ShareMessageModal from '@/features/Conversation/components/ShareMessageModal';
|
||||
import { VirtuosoContext } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { threadSelectors } from '@/store/chat/selectors';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import { InPortalThreadContext } from '../../../context/InPortalThreadContext';
|
||||
import { useChatListActionsBar } from '../../../hooks/useChatListActionsBar';
|
||||
|
||||
interface GroupActionsProps {
|
||||
contentBlock?: AssistantContentBlock;
|
||||
data: UIChatMessage;
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const WithContentId = memo<GroupActionsProps>(({ id, data, index, contentBlock }) => {
|
||||
const { tools } = data;
|
||||
const [isThreadMode, hasThread] = useChatStore((s) => [
|
||||
!!s.activeThreadId,
|
||||
threadSelectors.hasThreadBySourceMsgId(id)(s),
|
||||
]);
|
||||
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
|
||||
const [showShareModal, setShareModal] = useState(false);
|
||||
|
||||
const { edit, delAndRegenerate, copy, divider, del, branching, share } = useChatListActionsBar({
|
||||
hasThread,
|
||||
});
|
||||
|
||||
const hasTools = !!tools;
|
||||
|
||||
const inPortalThread = useContext(InPortalThreadContext);
|
||||
const inThread = isThreadMode || inPortalThread;
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (hasTools) return [delAndRegenerate, copy];
|
||||
|
||||
return [edit, copy, inThread || isGroupSession ? null : branching].filter(
|
||||
Boolean,
|
||||
) as ActionIconGroupItemType[];
|
||||
}, [inThread, hasTools, isGroupSession]);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const searchParams = useSearchParams();
|
||||
const topic = searchParams.get('topic');
|
||||
const [
|
||||
deleteMessage,
|
||||
translateMessage,
|
||||
delAndRegenerateMessage,
|
||||
copyMessage,
|
||||
openThreadCreator,
|
||||
delAndResendThreadMessage,
|
||||
toggleMessageEditing,
|
||||
] = useChatStore((s) => [
|
||||
s.deleteMessage,
|
||||
s.translateMessage,
|
||||
s.delAndRegenerateMessage,
|
||||
s.copyMessage,
|
||||
s.openThreadCreator,
|
||||
s.delAndResendThreadMessage,
|
||||
s.toggleMessageEditing,
|
||||
]);
|
||||
const { message } = App.useApp();
|
||||
const virtuosoRef = use(VirtuosoContext);
|
||||
|
||||
const onActionClick = useCallback(
|
||||
async (action: ActionIconGroupEvent) => {
|
||||
switch (action.key) {
|
||||
case 'edit': {
|
||||
toggleMessageEditing(id, true);
|
||||
|
||||
virtuosoRef?.current?.scrollIntoView({ align: 'start', behavior: 'auto', index });
|
||||
}
|
||||
}
|
||||
if (!data) return;
|
||||
|
||||
switch (action.key) {
|
||||
case 'copy': {
|
||||
if (!contentBlock) return;
|
||||
await copyMessage(id, contentBlock.content);
|
||||
message.success(t('copySuccess', { defaultValue: 'Copy Success' }));
|
||||
break;
|
||||
}
|
||||
case 'branching': {
|
||||
if (!topic) {
|
||||
message.warning(t('branchingRequiresSavedTopic'));
|
||||
break;
|
||||
}
|
||||
openThreadCreator(id);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'del': {
|
||||
deleteMessage(id);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delAndRegenerate': {
|
||||
if (inPortalThread) {
|
||||
delAndResendThreadMessage(id);
|
||||
} else {
|
||||
delAndRegenerateMessage(id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'share': {
|
||||
setShareModal(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.keyPath.at(-1) === 'translate') {
|
||||
// click the menu data with translate data, the result is:
|
||||
// key: 'en-US'
|
||||
// keyPath: ['en-US','translate']
|
||||
const lang = action.keyPath[0];
|
||||
translateMessage(id, lang);
|
||||
}
|
||||
},
|
||||
[data, topic],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionIconGroup
|
||||
items={items}
|
||||
menu={{
|
||||
items: [edit, copy, divider, share, divider, delAndRegenerate, del],
|
||||
}}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
<ShareMessageModal
|
||||
message={data!}
|
||||
onCancel={() => {
|
||||
setShareModal(false);
|
||||
}}
|
||||
open={showShareModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WithContentId;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { UIChatMessage } from '@lobechat/types';
|
||||
import { ActionIconGroup, type ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { threadSelectors } from '@/store/chat/selectors';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import { InPortalThreadContext } from '../../../context/InPortalThreadContext';
|
||||
import { useChatListActionsBar } from '../../../hooks/useChatListActionsBar';
|
||||
|
||||
interface GroupActionsProps {
|
||||
data: UIChatMessage;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const WithoutContentId = memo<GroupActionsProps>(({ id, data }) => {
|
||||
const [isThreadMode, hasThread] = useChatStore((s) => [
|
||||
!!s.activeThreadId,
|
||||
threadSelectors.hasThreadBySourceMsgId(id)(s),
|
||||
]);
|
||||
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
|
||||
|
||||
const { delAndRegenerate, del } = useChatListActionsBar({ hasThread });
|
||||
|
||||
const inPortalThread = useContext(InPortalThreadContext);
|
||||
const inThread = isThreadMode || inPortalThread;
|
||||
|
||||
const items = useMemo(() => {
|
||||
return [delAndRegenerate, del].filter(Boolean) as ActionIconGroupItemType[];
|
||||
}, [inThread, isGroupSession]);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const topic = searchParams.get('topic');
|
||||
|
||||
const [deleteMessage, delAndRegenerateMessage, delAndResendThreadMessage] = useChatStore((s) => [
|
||||
s.deleteMessage,
|
||||
s.delAndRegenerateMessage,
|
||||
s.delAndResendThreadMessage,
|
||||
]);
|
||||
|
||||
const onActionClick = useCallback(
|
||||
async (action: ActionIconGroupEvent) => {
|
||||
if (!data) return;
|
||||
|
||||
switch (action.key) {
|
||||
case 'del': {
|
||||
deleteMessage(id);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delAndRegenerate': {
|
||||
if (inPortalThread) {
|
||||
delAndResendThreadMessage(id);
|
||||
} else {
|
||||
delAndRegenerateMessage(id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[data, topic],
|
||||
);
|
||||
|
||||
return <ActionIconGroup items={items} onActionClick={onActionClick} />;
|
||||
});
|
||||
|
||||
export default WithoutContentId;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AssistantContentBlock, UIChatMessage } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import WithContentId from './WithContentId';
|
||||
import WithoutContentId from './WithoutContentId';
|
||||
|
||||
interface GroupActionsProps {
|
||||
contentBlock?: AssistantContentBlock;
|
||||
contentId?: string;
|
||||
data: UIChatMessage;
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const GroupActionsBar = memo<GroupActionsProps>(
|
||||
({ id, data, contentBlock, index, contentId }) => {
|
||||
if (!contentId) return <WithoutContentId data={data} id={id} />;
|
||||
|
||||
return <WithContentId contentBlock={contentBlock} data={data} id={contentId} index={index} />;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AssistantContentBlock } from '@lobechat/types';
|
||||
import { MarkdownProps } from '@lobehub/ui';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
import { markdownElements } from '@/features/Conversation/MarkdownElements';
|
||||
import Reasoning from '@/features/Conversation/Messages/Assistant/Reasoning';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { aiChatSelectors, messageStateSelectors } from '@/store/chat/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
import ImageFileListViewer from '../User/ImageFileListViewer';
|
||||
import ErrorContent from './Error';
|
||||
import MessageContent from './MessageContent';
|
||||
import { Tools } from './Tools';
|
||||
|
||||
const rehypePlugins = markdownElements.map((element) => element.rehypePlugin).filter(Boolean);
|
||||
const remarkPlugins = markdownElements.map((element) => element.remarkPlugin).filter(Boolean);
|
||||
|
||||
interface ContentBlockProps extends AssistantContentBlock {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const ContentBlock = memo<ContentBlockProps>((props) => {
|
||||
const { id, tools, content, imageList, reasoning, error } = props;
|
||||
const showImageItems = !!imageList && imageList.length > 0;
|
||||
const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
|
||||
|
||||
const hasTools = tools && tools.length > 0;
|
||||
const showReasoning =
|
||||
(!!reasoning && reasoning.content?.trim() !== '') || (!reasoning && isReasoning);
|
||||
|
||||
const { transitionMode, highlighterTheme, mermaidTheme } = useUserStore(
|
||||
userGeneralSettingsSelectors.config,
|
||||
);
|
||||
|
||||
const generating = useChatStore(messageStateSelectors.isMessageGenerating(id));
|
||||
|
||||
const animated = transitionMode === 'fadeIn' && generating;
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
markdownElements.map((element) => {
|
||||
const Component = element.Component;
|
||||
|
||||
return [element.tag, (props: any) => <Component {...props} id={id} />];
|
||||
}),
|
||||
),
|
||||
[id],
|
||||
);
|
||||
|
||||
const markdownProps: Omit<MarkdownProps, 'className' | 'style' | 'children'> = useMemo(
|
||||
() => ({
|
||||
animated,
|
||||
componentProps: {
|
||||
highlight: {
|
||||
theme: highlighterTheme,
|
||||
},
|
||||
mermaid: { theme: mermaidTheme },
|
||||
},
|
||||
components,
|
||||
enableCustomFootnotes: true,
|
||||
rehypePlugins,
|
||||
remarkPlugins,
|
||||
}),
|
||||
[animated, components, highlighterTheme, mermaidTheme],
|
||||
);
|
||||
|
||||
if (error && (content === LOADING_FLAT || !content))
|
||||
return <ErrorContent error={error} id={id} />;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8} id={id}>
|
||||
{showReasoning && <Reasoning {...reasoning} id={id} />}
|
||||
|
||||
{/* Content - markdown text */}
|
||||
{content && (
|
||||
<MessageContent content={content} hasTools={hasTools} markdownProps={markdownProps} />
|
||||
)}
|
||||
|
||||
{/* Image files */}
|
||||
{showImageItems && <ImageFileListViewer items={imageList} />}
|
||||
|
||||
{/* Tools */}
|
||||
{hasTools && <Tools messageId={id} tools={tools} />}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MessageInput } from '@lobehub/ui/chat';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
export interface EditStateProps {
|
||||
content: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const EditState = memo<EditStateProps>(({ id, content }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const text = useMemo(
|
||||
() => ({
|
||||
cancel: t('cancel'),
|
||||
confirm: t('ok'),
|
||||
edit: t('edit'),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [toggleMessageEditing, updateMessageContent] = useChatStore((s) => [
|
||||
s.toggleMessageEditing,
|
||||
s.modifyMessageContent,
|
||||
]);
|
||||
|
||||
const onEditingChange = (value: string) => {
|
||||
updateMessageContent(id, value);
|
||||
toggleMessageEditing(id, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox paddingBlock={'0 8px'}>
|
||||
<MessageInput
|
||||
defaultValue={content ? String(content) : ''}
|
||||
editButtonSize={'small'}
|
||||
onCancel={() => {
|
||||
toggleMessageEditing(id, false);
|
||||
}}
|
||||
onConfirm={onEditingChange}
|
||||
text={text}
|
||||
variant={'outlined'}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditState;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChatMessageError } from '@lobechat/types';
|
||||
import { Alert } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import ErrorMessageExtra, { useErrorContent } from '@/features/Conversation/Error';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
export interface ErrorContentProps {
|
||||
error?: ChatMessageError;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const ErrorContent = memo<ErrorContentProps>(({ error, id }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const errorProps = useErrorContent(error);
|
||||
|
||||
const [deleteMessage] = useChatStore((s) => [s.deleteMessage]);
|
||||
const message = <ErrorMessageExtra block data={{ error, id }} />;
|
||||
|
||||
if (!error?.message) {
|
||||
if (!message) return null;
|
||||
return <Flexbox>{message}</Flexbox>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox>
|
||||
<Alert
|
||||
action={
|
||||
<Button
|
||||
color={'default'}
|
||||
onClick={() => {
|
||||
deleteMessage(id);
|
||||
}}
|
||||
size={'small'}
|
||||
variant={'filled'}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
}
|
||||
closable={false}
|
||||
extra={message}
|
||||
showIcon
|
||||
type={'error'}
|
||||
{...errorProps}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default ErrorContent;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AssistantContentBlock } from '@lobechat/types';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo, use } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { VirtuosoContext } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { ContentBlock } from './ContentBlock';
|
||||
|
||||
const useStyles = createStyles(({ css }) => {
|
||||
return {
|
||||
container: css`
|
||||
&:has(.tool-blocks) {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
interface GroupChildrenProps {
|
||||
blocks: AssistantContentBlock[];
|
||||
contentId?: string;
|
||||
disableEditing?: boolean;
|
||||
messageIndex: number;
|
||||
}
|
||||
|
||||
const GroupChildren = memo<GroupChildrenProps>(
|
||||
({ blocks, contentId, disableEditing, messageIndex }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [toggleMessageEditing] = useChatStore((s) => [s.toggleMessageEditing]);
|
||||
const virtuosoRef = use(VirtuosoContext);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
{blocks.map((item, index) => {
|
||||
return item.id === contentId ? (
|
||||
<Flexbox
|
||||
key={index}
|
||||
onDoubleClick={(e) => {
|
||||
if (disableEditing || item.error || !e.altKey) return;
|
||||
|
||||
toggleMessageEditing(item.id, true);
|
||||
virtuosoRef?.current?.scrollIntoView({
|
||||
align: 'start',
|
||||
behavior: 'auto',
|
||||
index: messageIndex,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContentBlock index={index} {...item} />
|
||||
</Flexbox>
|
||||
) : (
|
||||
<motion.div
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
key={index}
|
||||
style={{ overflow: 'hidden' }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<ContentBlock index={index} {...item} />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default GroupChildren;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Markdown, MarkdownProps } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import BubblesLoading from '@/components/BubblesLoading';
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
|
||||
import { normalizeThinkTags, processWithArtifact } from '../../utils/markdown';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
pWithTool: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
};
|
||||
});
|
||||
interface ContentBlockProps {
|
||||
content: string;
|
||||
hasTools?: boolean;
|
||||
markdownProps?: Omit<MarkdownProps, 'className' | 'style' | 'children'>;
|
||||
}
|
||||
|
||||
const MessageContent = memo<ContentBlockProps>(({ content, hasTools, markdownProps }) => {
|
||||
const message = normalizeThinkTags(processWithArtifact(content));
|
||||
|
||||
const { styles, cx } = useStyles();
|
||||
|
||||
if (content === LOADING_FLAT) return <BubblesLoading />;
|
||||
|
||||
return (
|
||||
content && (
|
||||
<Markdown {...markdownProps} className={cx(hasTools && styles.pWithTool)} variant={'chat'}>
|
||||
{message}
|
||||
</Markdown>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export default MessageContent;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ReactNode, memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { shinyTextStylish } from '@/styles/loading';
|
||||
|
||||
export const useStyles = createStyles(({ css, token }) => ({
|
||||
apiName: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
`,
|
||||
|
||||
shinyText: shinyTextStylish(token),
|
||||
}));
|
||||
|
||||
interface BuiltinPluginTitleProps {
|
||||
apiName: string;
|
||||
hasResult?: boolean;
|
||||
icon?: ReactNode;
|
||||
identifier: string;
|
||||
index: number;
|
||||
messageId: string;
|
||||
title: string;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
const BuiltinPluginTitle = memo<BuiltinPluginTitleProps>(({ apiName, title, hasResult }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const isLoading = !hasResult;
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} className={isLoading ? styles.shinyText : ''} gap={4} horizontal>
|
||||
<div>{title}</div>
|
||||
<Icon icon={ChevronRight} />
|
||||
<span className={styles.apiName}>{apiName}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default BuiltinPluginTitle;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { Tabs } from 'antd';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PluginResult from './PluginResult';
|
||||
import PluginState from './PluginState';
|
||||
|
||||
interface DebugProps {
|
||||
apiName: string;
|
||||
identifier: string;
|
||||
requestArgs?: string;
|
||||
result?: { content: string | null; error?: any; state?: any };
|
||||
toolCallId: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const Debug = memo<DebugProps>(({ result, requestArgs, toolCallId, apiName, identifier, type }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const params = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(requestArgs || ''), null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [requestArgs]);
|
||||
|
||||
const functionCall = useMemo(() => {
|
||||
return {
|
||||
apiName,
|
||||
arguments: requestArgs,
|
||||
id: toolCallId,
|
||||
identifier,
|
||||
type,
|
||||
};
|
||||
}, [requestArgs, toolCallId, apiName, identifier, type]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
children: <Highlighter language={'json'}>{params}</Highlighter>,
|
||||
key: 'arguments',
|
||||
label: t('debug.arguments'),
|
||||
},
|
||||
{
|
||||
children: <PluginResult content={result?.content} />,
|
||||
key: 'response',
|
||||
label: t('debug.response'),
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Highlighter language={'json'}>{JSON.stringify(functionCall, null, 2)}</Highlighter>
|
||||
),
|
||||
key: 'function_call',
|
||||
label: t('debug.function_call'),
|
||||
},
|
||||
{
|
||||
children: <PluginState state={result?.state} />,
|
||||
key: 'pluginState',
|
||||
label: t('debug.pluginState'),
|
||||
},
|
||||
]}
|
||||
style={{ display: 'grid', maxWidth: 800, minWidth: 400 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Debug;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
export interface PluginResultProps {
|
||||
content?: string | null;
|
||||
variant?: 'filled' | 'outlined' | 'borderless';
|
||||
}
|
||||
|
||||
const PluginResult = memo<PluginResultProps>(({ content, variant }) => {
|
||||
const { data, language } = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(content || '');
|
||||
// Special case: if the parsed result is a string, it means the original content was a stringified string
|
||||
if (typeof parsed === 'string') {
|
||||
return { data: parsed, language: 'plaintext' }; // Return the parsed string directly, do not re-serialize
|
||||
}
|
||||
return { data: JSON.stringify(parsed, null, 2), language: 'json' };
|
||||
} catch {
|
||||
return { data: content || '', language: 'plaintext' };
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
language={language}
|
||||
style={{ maxHeight: 200, overflow: 'scroll', width: '100%' }}
|
||||
variant={variant}
|
||||
>
|
||||
{data}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
export default PluginResult;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
export interface PluginStateProps {
|
||||
state?: any;
|
||||
}
|
||||
|
||||
const PluginState = memo<PluginStateProps>(({ state }) => {
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<Highlighter language={'json'} style={{ maxHeight: 200, maxWidth: 800, overflow: 'scroll' }}>
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
export default PluginState;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { LucideSettings } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PluginDetailModal from '@/features/PluginDetailModal';
|
||||
import { pluginHelpers, useToolStore } from '@/store/tool';
|
||||
import { pluginSelectors } from '@/store/tool/selectors';
|
||||
|
||||
const Settings = memo<{ id: string }>(({ id }) => {
|
||||
const item = useToolStore(pluginSelectors.getToolManifestById(id));
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation('plugin');
|
||||
const hasSettings = pluginHelpers.isSettingSchemaNonEmpty(item?.settings);
|
||||
|
||||
return (
|
||||
hasSettings && (
|
||||
<>
|
||||
<ActionIcon
|
||||
icon={LucideSettings}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
size={'small'}
|
||||
title={t('setting')}
|
||||
/>
|
||||
<PluginDetailModal
|
||||
id={id}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
open={open}
|
||||
schema={item?.settings}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { pluginHelpers, useToolStore } from '@/store/tool';
|
||||
import { toolSelectors } from '@/store/tool/selectors';
|
||||
import { shinyTextStylish } from '@/styles/loading';
|
||||
import { LocalSystemManifest } from '@/tools/local-system';
|
||||
import { WebBrowsingManifest } from '@/tools/web-browsing';
|
||||
|
||||
import BuiltinPluginTitle from './BuiltinPluginTitle';
|
||||
|
||||
export const useStyles = createStyles(({ css, token }) => ({
|
||||
apiName: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
`,
|
||||
|
||||
shinyText: shinyTextStylish(token),
|
||||
}));
|
||||
|
||||
interface ToolTitleProps {
|
||||
apiName: string;
|
||||
hasResult?: boolean;
|
||||
identifier: string;
|
||||
index: number;
|
||||
messageId: string;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
const ToolTitle = memo<ToolTitleProps>(
|
||||
({ identifier, apiName, hasResult, index, toolCallId, messageId }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const { styles } = useStyles();
|
||||
|
||||
const isLoading = !hasResult;
|
||||
|
||||
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
{
|
||||
apiName: t(`search.apiName.${apiName}`, apiName),
|
||||
id: WebBrowsingManifest.identifier,
|
||||
title: t('search.title'),
|
||||
},
|
||||
{
|
||||
apiName: t(`localSystem.apiName.${apiName}`, apiName),
|
||||
id: LocalSystemManifest.identifier,
|
||||
title: t('localSystem.title'),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const builtinPluginTitle = plugins.find((item) => item.id === identifier);
|
||||
|
||||
if (!!builtinPluginTitle) {
|
||||
return (
|
||||
<BuiltinPluginTitle
|
||||
{...builtinPluginTitle}
|
||||
hasResult={hasResult}
|
||||
identifier={identifier}
|
||||
index={index}
|
||||
messageId={messageId}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} className={isLoading ? styles.shinyText : ''} gap={6} horizontal>
|
||||
<div>{pluginTitle}</div> <Icon icon={ChevronRight} />
|
||||
<span className={styles.apiName}>{apiName}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToolTitle;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { Check, LayoutPanelTop, LogsIcon, LucideBug, LucideBugOff, X } from 'lucide-react';
|
||||
import { CSSProperties, memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
import { shinyTextStylish } from '@/styles/loading';
|
||||
|
||||
import Debug from './Debug';
|
||||
import Settings from './Settings';
|
||||
import ToolTitle from './ToolTitle';
|
||||
|
||||
export const useStyles = createStyles(({ css, token, cx }) => ({
|
||||
actions: cx(
|
||||
'inspector-container',
|
||||
css`
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease-in-out;
|
||||
`,
|
||||
),
|
||||
apiName: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
`,
|
||||
container: css`
|
||||
:hover {
|
||||
.inspector-container {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
plugin: css`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
`,
|
||||
shinyText: shinyTextStylish(token),
|
||||
tool: css`
|
||||
cursor: pointer;
|
||||
|
||||
width: fit-content;
|
||||
padding-block: 2px;
|
||||
border-radius: 6px;
|
||||
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface InspectorProps {
|
||||
apiName: string;
|
||||
arguments?: string;
|
||||
hidePluginUI?: boolean;
|
||||
id: string;
|
||||
identifier: string;
|
||||
index: number;
|
||||
messageId: string;
|
||||
result?: { content: string | null; error?: any; state?: any };
|
||||
setShowPluginRender: (show: boolean) => void;
|
||||
setShowRender: (show: boolean) => void;
|
||||
showPluginRender: boolean;
|
||||
showPortal?: boolean;
|
||||
showRender: boolean;
|
||||
style?: CSSProperties;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const Inspectors = memo<InspectorProps>(
|
||||
({
|
||||
messageId,
|
||||
index,
|
||||
identifier,
|
||||
apiName,
|
||||
id,
|
||||
arguments: requestArgs,
|
||||
showRender,
|
||||
result,
|
||||
setShowRender,
|
||||
showPluginRender,
|
||||
setShowPluginRender,
|
||||
hidePluginUI = false,
|
||||
type,
|
||||
}) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
const hasError = !!result?.error;
|
||||
const hasSuccessResult = !!result?.content && result.content !== LOADING_FLAT;
|
||||
|
||||
const hasResult = hasSuccessResult || hasError;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={4}>
|
||||
<Flexbox align={'center'} distribution={'space-between'} gap={8} horizontal>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.tool}
|
||||
gap={8}
|
||||
horizontal
|
||||
onClick={() => {
|
||||
setShowRender(!showRender);
|
||||
}}
|
||||
paddingInline={4}
|
||||
>
|
||||
<ToolTitle
|
||||
apiName={apiName}
|
||||
hasResult={hasResult}
|
||||
identifier={identifier}
|
||||
index={index}
|
||||
messageId={messageId}
|
||||
toolCallId={id}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<Flexbox className={styles.actions} horizontal>
|
||||
{showRender && !hidePluginUI && (
|
||||
<ActionIcon
|
||||
icon={showPluginRender ? LogsIcon : LayoutPanelTop}
|
||||
onClick={() => {
|
||||
setShowPluginRender(!showPluginRender);
|
||||
}}
|
||||
size={'small'}
|
||||
title={showPluginRender ? t('inspector.args') : t('inspector.pluginRender')}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
icon={showDebug ? LucideBugOff : LucideBug}
|
||||
onClick={() => {
|
||||
setShowDebug(!showDebug);
|
||||
}}
|
||||
size={'small'}
|
||||
title={t(showDebug ? 'debug.off' : 'debug.on')}
|
||||
/>
|
||||
<Settings id={identifier} />
|
||||
</Flexbox>
|
||||
{hasResult && (
|
||||
<Flexbox align={'center'} gap={4} horizontal style={{ fontSize: 12 }}>
|
||||
{hasError ? (
|
||||
<X color={theme.colorError} size={14} />
|
||||
) : (
|
||||
<Check color={theme.colorSuccess} size={14} />
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{showDebug && (
|
||||
<Debug
|
||||
apiName={apiName}
|
||||
identifier={identifier}
|
||||
requestArgs={requestArgs}
|
||||
result={result}
|
||||
toolCallId={id}
|
||||
type={type}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Inspectors;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStylish } from '@/styles/loading';
|
||||
|
||||
import ValueCell from './ValueCell';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
arrayRow: css`
|
||||
&:not(:first-child) {
|
||||
border-block-start: 1px dotted ${token.colorBorderSecondary};
|
||||
}
|
||||
`,
|
||||
colon: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
key: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
row: css`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-block-start: 1px dotted ${token.colorBorderSecondary};
|
||||
}
|
||||
`,
|
||||
shineText: shinyTextStylish(token),
|
||||
value: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const formatValue = (value: any): string | string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : v));
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.entries(value)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
|
||||
.join(', ');
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
interface ObjectEntityProps {
|
||||
editable?: boolean;
|
||||
hasMinWidth: boolean;
|
||||
objectKey: string;
|
||||
shine?: boolean;
|
||||
value: any;
|
||||
}
|
||||
|
||||
const ObjectEntity = memo<ObjectEntityProps>(({ hasMinWidth, shine, value, objectKey }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
const isMobile = useIsMobile();
|
||||
const formatedValue = formatValue(value);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<span
|
||||
className={styles.key}
|
||||
style={{ minWidth: hasMinWidth ? (isMobile ? 60 : 140) : undefined }}
|
||||
>
|
||||
{objectKey}
|
||||
</span>
|
||||
<span className={styles.colon}>:</span>
|
||||
<div className={cx(shine ? styles.shineText : styles.value)} style={{ width: '100%' }}>
|
||||
{typeof formatedValue === 'string' ? (
|
||||
<ValueCell value={formatedValue} />
|
||||
) : (
|
||||
formatedValue.map((v, i) => <ValueCell key={i + v} value={v} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ObjectEntity;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { copyToClipboard } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
copyable: css`
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-block: 2px;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
border-radius: 6px;
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ValueCellProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ValueCell = memo<ValueCellProps>(({ value }) => {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation('common');
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.copyable}
|
||||
onClick={async () => {
|
||||
await copyToClipboard(value);
|
||||
message.success(t('copySuccess'));
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ValueCell;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { parse } from 'partial-json';
|
||||
import { ReactNode, memo, useMemo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useYamlArguments } from '@/hooks/useYamlArguments';
|
||||
|
||||
import ObjectEntity from './ObjectEntity';
|
||||
|
||||
const useStyles = createStyles(({ css, token, cx }) => ({
|
||||
button: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorText};
|
||||
}
|
||||
`,
|
||||
codeContainer: css`
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
`,
|
||||
container: css`
|
||||
position: relative;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
max-height: 200px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px 64px;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
background: ${token.colorFillQuaternary};
|
||||
|
||||
pre {
|
||||
margin: 0 !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
editButton: cx(
|
||||
'actions',
|
||||
css`
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset-block-start: 4px;
|
||||
inset-inline-end: 4px;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.2s ${token.motionEaseInOut};
|
||||
`,
|
||||
),
|
||||
}));
|
||||
|
||||
export interface ArgumentsProps {
|
||||
actions?: ReactNode;
|
||||
arguments?: string;
|
||||
shine?: boolean;
|
||||
}
|
||||
|
||||
const Arguments = memo<ArgumentsProps>(({ arguments: args = '', shine, actions }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const displayArgs = useMemo(() => {
|
||||
try {
|
||||
const obj = parse(args);
|
||||
if (Object.keys(obj).length === 0) return {};
|
||||
return obj;
|
||||
} catch {
|
||||
return args;
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const yaml = useYamlArguments(args);
|
||||
|
||||
const showActions = !!actions;
|
||||
|
||||
if (typeof displayArgs === 'string') {
|
||||
return (
|
||||
!!yaml && (
|
||||
<div className={styles.container}>
|
||||
<Highlighter language={'yaml'} showLanguage={false}>
|
||||
{yaml}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// if (args.length > 100) {
|
||||
// return (
|
||||
// <Highlighter language={'json'} showLanguage={false} variant={'filled'}>
|
||||
// {JSON.stringify(displayArgs, null, 2)}
|
||||
// </Highlighter>
|
||||
// );
|
||||
// }
|
||||
|
||||
const hasMinWidth = Object.keys(displayArgs).length > 1;
|
||||
|
||||
if (Object.keys(displayArgs).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{showActions && (
|
||||
<Flexbox className={styles.editButton} gap={4} horizontal>
|
||||
{actions}
|
||||
</Flexbox>
|
||||
)}
|
||||
{Object.entries(displayArgs).map(([key, value]) => {
|
||||
return (
|
||||
<ObjectEntity
|
||||
editable={false}
|
||||
hasMinWidth={hasMinWidth}
|
||||
key={key}
|
||||
objectKey={key}
|
||||
shine={shine}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Arguments;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ChatPluginPayload } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import PluginRender from '@/features/PluginsUI/Render';
|
||||
|
||||
import Arguments from './Arguments';
|
||||
|
||||
interface CustomRenderProps {
|
||||
content: string;
|
||||
id: string;
|
||||
plugin?: ChatPluginPayload;
|
||||
pluginState?: any;
|
||||
requestArgs?: string;
|
||||
setShowPluginRender: (value: boolean) => void;
|
||||
showPluginRender: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Render for Group Messages
|
||||
*
|
||||
* Group messages are already completed, so:
|
||||
* - No loading state needed
|
||||
* - No edit/re-run functionality
|
||||
* - Results are directly available in content prop
|
||||
*/
|
||||
const CustomRender = memo<CustomRenderProps>(
|
||||
({ id, content, pluginState, plugin, requestArgs, showPluginRender, setShowPluginRender }) => {
|
||||
// Determine if plugin UI should be shown based on plugin type
|
||||
useEffect(() => {
|
||||
if (!plugin?.type) return;
|
||||
setShowPluginRender(!['default', 'mcp'].includes(plugin.type));
|
||||
}, [plugin?.type, setShowPluginRender]);
|
||||
|
||||
// Parse and display result content
|
||||
const { data, language } = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(content || '');
|
||||
// If parsed result is a string, return it directly
|
||||
if (typeof parsed === 'string') {
|
||||
return { data: parsed, language: 'plaintext' };
|
||||
}
|
||||
return { data: JSON.stringify(parsed, null, 2), language: 'json' };
|
||||
} catch {
|
||||
return { data: content || '', language: 'plaintext' };
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Show plugin custom UI if applicable
|
||||
if (showPluginRender) {
|
||||
return (
|
||||
<Flexbox gap={12} id={id} width={'100%'}>
|
||||
<PluginRender
|
||||
arguments={plugin?.arguments}
|
||||
content={content}
|
||||
id={id}
|
||||
identifier={plugin?.identifier}
|
||||
loading={false}
|
||||
payload={plugin}
|
||||
pluginState={pluginState}
|
||||
type={plugin?.type}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
// Default render: show arguments and result
|
||||
return (
|
||||
<Flexbox gap={12} id={id} width={'100%'}>
|
||||
<Arguments arguments={requestArgs} />
|
||||
{content && (
|
||||
<Highlighter
|
||||
language={language}
|
||||
style={{ maxHeight: 200, overflow: 'scroll', width: '100%' }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{data}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CustomRender.displayName = 'GroupCustomRender';
|
||||
|
||||
export default CustomRender;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChatMessageError, ChatPluginPayload } from '@lobechat/types';
|
||||
import { Alert, Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import PluginSettings from './PluginSettings';
|
||||
|
||||
interface ErrorResponseProps extends ChatMessageError {
|
||||
id: string;
|
||||
plugin?: ChatPluginPayload;
|
||||
}
|
||||
|
||||
const ErrorResponse = memo<ErrorResponseProps>(({ id, type, body, message, plugin }) => {
|
||||
const { t } = useTranslation('error');
|
||||
if (type === 'PluginSettingsInvalid') {
|
||||
return <PluginSettings id={id} plugin={plugin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
extra={
|
||||
<Flexbox>
|
||||
<Highlighter actionIconSize={'small'} language={'json'} variant={'borderless'}>
|
||||
{JSON.stringify(body || { message, type }, null, 2)}
|
||||
</Highlighter>
|
||||
</Flexbox>
|
||||
}
|
||||
message={t(`response.${type}` as any)}
|
||||
showIcon
|
||||
type={'error'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
export default ErrorResponse;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { safeParseJSON } from '@lobechat/utils';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { BuiltinToolPlaceholders } from '@/tools/placeholders';
|
||||
|
||||
import Arguments from '../Arguments';
|
||||
|
||||
interface LoadingPlaceholderProps {
|
||||
apiName: string;
|
||||
identifier: string;
|
||||
loading?: boolean;
|
||||
requestArgs?: string;
|
||||
}
|
||||
|
||||
const LoadingPlaceholder = memo<LoadingPlaceholderProps>(
|
||||
({ identifier, requestArgs, apiName, loading }) => {
|
||||
const Render = BuiltinToolPlaceholders[identifier || ''];
|
||||
|
||||
if (identifier && Render) {
|
||||
return (
|
||||
<Render apiName={apiName} args={safeParseJSON(requestArgs) || {}} identifier={identifier} />
|
||||
);
|
||||
}
|
||||
|
||||
return <Arguments arguments={requestArgs} shine={loading} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default LoadingPlaceholder;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ChatPluginPayload } from '@lobechat/types';
|
||||
import { Avatar, Button } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { useTheme } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Center, Flexbox } from 'react-layout-kit';
|
||||
|
||||
import PluginSettingsConfig from '@/features/PluginSettings';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { pluginHelpers, useToolStore } from '@/store/tool';
|
||||
import { pluginSelectors } from '@/store/tool/selectors';
|
||||
|
||||
import { ErrorActionContainer, useStyles } from '../../../../Error/style';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
id: string;
|
||||
plugin?: ChatPluginPayload;
|
||||
}
|
||||
|
||||
const PluginSettings = memo<PluginSettingsProps>(({ id, plugin }) => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('error');
|
||||
const theme = useTheme();
|
||||
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
|
||||
const pluginIdentifier = plugin?.identifier as string;
|
||||
const pluginMeta = useToolStore(pluginSelectors.getPluginMetaById(pluginIdentifier), isEqual);
|
||||
const manifest = useToolStore(pluginSelectors.getToolManifestById(pluginIdentifier), isEqual);
|
||||
|
||||
return (
|
||||
!!manifest && (
|
||||
<ErrorActionContainer>
|
||||
<Center gap={16} style={{ maxWidth: 400 }}>
|
||||
<Avatar
|
||||
avatar={pluginHelpers.getPluginAvatar(pluginMeta) || '⚙️'}
|
||||
background={theme.colorFillContent}
|
||||
gap={12}
|
||||
size={80}
|
||||
/>
|
||||
<Flexbox style={{ fontSize: 20 }}>
|
||||
{t('pluginSettings.title', { name: pluginHelpers.getPluginTitle(pluginMeta) })}
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.desc}>{t('pluginSettings.desc')}</Flexbox>
|
||||
<Divider style={{ margin: '0 16px' }} />
|
||||
{manifest.settings && (
|
||||
<PluginSettingsConfig id={manifest.identifier} schema={manifest.settings} />
|
||||
)}
|
||||
<Button
|
||||
block
|
||||
onClick={() => {
|
||||
resend(id);
|
||||
deleteMessage(id);
|
||||
}}
|
||||
style={{ marginTop: 8 }}
|
||||
type={'primary'}
|
||||
>
|
||||
{t('unlock.confirm')}
|
||||
</Button>
|
||||
</Center>
|
||||
</ErrorActionContainer>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export default PluginSettings;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import { ChatToolResult } from '@lobechat/types';
|
||||
import { Suspense, memo } from 'react';
|
||||
|
||||
import CustomRender from './CustomRender';
|
||||
import ErrorResponse from './ErrorResponse';
|
||||
import LoadingPlaceholder from './LoadingPlaceholder';
|
||||
|
||||
interface RenderProps {
|
||||
apiName: string;
|
||||
arguments?: string;
|
||||
identifier: string;
|
||||
/**
|
||||
* ContentBlock ID (not the group message ID)
|
||||
*/
|
||||
messageId: string;
|
||||
result?: ChatToolResult;
|
||||
setShowPluginRender: (show: boolean) => void;
|
||||
showPluginRender: boolean;
|
||||
toolCallId: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Render for Group Messages
|
||||
*
|
||||
* In group messages, tool results are already embedded in the payload,
|
||||
* so we don't need to query them from the store or handle streaming.
|
||||
*/
|
||||
const Render = memo<RenderProps>(
|
||||
({
|
||||
toolCallId,
|
||||
messageId,
|
||||
arguments: requestArgs,
|
||||
showPluginRender,
|
||||
setShowPluginRender,
|
||||
identifier,
|
||||
apiName,
|
||||
result,
|
||||
type,
|
||||
}) => {
|
||||
if (!result) return null;
|
||||
|
||||
// Handle error state
|
||||
if (result.error) {
|
||||
return (
|
||||
<ErrorResponse
|
||||
{...result.error}
|
||||
id={messageId}
|
||||
plugin={
|
||||
type
|
||||
? ({
|
||||
apiName,
|
||||
arguments: requestArgs || '',
|
||||
identifier,
|
||||
type,
|
||||
} as any)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder = (
|
||||
<LoadingPlaceholder
|
||||
apiName={apiName}
|
||||
identifier={identifier}
|
||||
loading
|
||||
requestArgs={requestArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
// Standalone plugins always have LOADING_FLAT as content
|
||||
const inPlaceholder = result.content === LOADING_FLAT && type !== 'standalone';
|
||||
|
||||
if (inPlaceholder) return placeholder;
|
||||
|
||||
return (
|
||||
<Suspense fallback={placeholder}>
|
||||
<CustomRender
|
||||
content={result.content || ''}
|
||||
id={toolCallId}
|
||||
plugin={
|
||||
type
|
||||
? ({
|
||||
apiName,
|
||||
arguments: requestArgs || '',
|
||||
identifier,
|
||||
type,
|
||||
} as any)
|
||||
: undefined
|
||||
}
|
||||
pluginState={result.state}
|
||||
requestArgs={requestArgs}
|
||||
setShowPluginRender={setShowPluginRender}
|
||||
showPluginRender={showPluginRender}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Render.displayName = 'GroupToolRender';
|
||||
|
||||
export default Render;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ChatToolResult } from '@lobechat/types';
|
||||
import { CSSProperties, memo, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import AnimatedCollapsed from '@/components/AnimatedCollapsed';
|
||||
|
||||
import Inspectors from './Inspector';
|
||||
import Render from './Render';
|
||||
|
||||
export interface GroupToolProps {
|
||||
apiName: string;
|
||||
arguments?: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
index: number;
|
||||
/**
|
||||
* ContentBlock ID (not the group message ID)
|
||||
*/
|
||||
messageId: string;
|
||||
result?: ChatToolResult;
|
||||
style?: CSSProperties;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool component for Group Messages
|
||||
*
|
||||
* In group messages, all tools are completed (no streaming),
|
||||
* so we always show the results directly.
|
||||
*/
|
||||
const Tool = memo<GroupToolProps>(
|
||||
({ arguments: requestArgs, apiName, messageId, id, index, identifier, style, result, type }) => {
|
||||
// Default to false since group messages are all completed
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [showPluginRender, setShowPluginRender] = useState(false);
|
||||
|
||||
return (
|
||||
<Flexbox gap={8} style={style}>
|
||||
<Inspectors
|
||||
apiName={apiName}
|
||||
arguments={requestArgs}
|
||||
// mcp don't have ui render
|
||||
hidePluginUI={type === 'mcp'}
|
||||
id={id}
|
||||
identifier={identifier}
|
||||
index={index}
|
||||
messageId={messageId}
|
||||
result={result}
|
||||
setShowPluginRender={setShowPluginRender}
|
||||
setShowRender={setShowDetail}
|
||||
showPluginRender={showPluginRender}
|
||||
showRender={showDetail}
|
||||
type={type}
|
||||
/>
|
||||
<AnimatedCollapsed open={showDetail} width={{ collapsed: 'auto' }}>
|
||||
<Render
|
||||
apiName={apiName}
|
||||
arguments={requestArgs}
|
||||
identifier={identifier}
|
||||
messageId={messageId}
|
||||
result={result}
|
||||
setShowPluginRender={setShowPluginRender}
|
||||
showPluginRender={showPluginRender}
|
||||
toolCallId={id}
|
||||
type={type}
|
||||
/>
|
||||
</AnimatedCollapsed>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Tool.displayName = 'GroupTool';
|
||||
|
||||
export default Tool;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ChatToolPayloadWithResult } from '@lobechat/types';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import Tool from './Tool';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
toolsContainer: css`
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: 8px;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
interface ToolsRendererProps {
|
||||
messageId: string;
|
||||
tools: ChatToolPayloadWithResult[];
|
||||
}
|
||||
|
||||
export const Tools = memo<ToolsRendererProps>(({ messageId, tools }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
|
||||
if (!tools || tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={cx(styles.toolsContainer, 'tool-blocks')} gap={8}>
|
||||
{tools.map((tool, index) => (
|
||||
<Tool
|
||||
apiName={tool.apiName}
|
||||
arguments={tool.arguments}
|
||||
id={tool.id}
|
||||
identifier={tool.identifier}
|
||||
index={index}
|
||||
key={tool.id}
|
||||
messageId={messageId}
|
||||
result={tool.result}
|
||||
type={tool.type}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { UIChatMessage } from '@lobechat/types';
|
||||
import { useResponsive } from 'antd-style';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import Avatar from '@/features/ChatItem/components/Avatar';
|
||||
import BorderSpacing from '@/features/ChatItem/components/BorderSpacing';
|
||||
import Title from '@/features/ChatItem/components/Title';
|
||||
import { useStyles } from '@/features/ChatItem/style';
|
||||
import GroupChildren from '@/features/Conversation/Messages/Group/GroupChildren';
|
||||
import Usage from '@/features/Conversation/components/Extras/Usage';
|
||||
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentChatConfigSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatSelectors, messageStateSelectors } from '@/store/chat/slices/message/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import { GroupActionsBar } from './Actions';
|
||||
import EditState from './EditState';
|
||||
|
||||
const MOBILE_AVATAR_SIZE = 32;
|
||||
|
||||
interface GroupMessageProps extends UIChatMessage {
|
||||
disableEditing?: boolean;
|
||||
index: number;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
const GroupMessage = memo<GroupMessageProps>((props) => {
|
||||
const {
|
||||
showTitle,
|
||||
id,
|
||||
disableEditing,
|
||||
usage,
|
||||
index,
|
||||
createdAt,
|
||||
meta,
|
||||
children,
|
||||
performance,
|
||||
model,
|
||||
provider,
|
||||
} = props;
|
||||
const avatar = meta;
|
||||
const { mobile } = useResponsive();
|
||||
const placement = 'left';
|
||||
const type = useAgentStore(agentChatConfigSelectors.displayMode);
|
||||
const variant = type === 'chat' ? 'bubble' : 'docs';
|
||||
|
||||
const { styles } = useStyles({
|
||||
editing: false,
|
||||
placement,
|
||||
primary: false,
|
||||
showTitle,
|
||||
time: createdAt,
|
||||
title: avatar.title,
|
||||
variant,
|
||||
});
|
||||
|
||||
const [isInbox] = useSessionStore((s) => [sessionSelectors.isInboxSession(s)]);
|
||||
const [toggleSystemRole] = useGlobalStore((s) => [s.toggleSystemRole]);
|
||||
const openChatSettings = useOpenChatSettings();
|
||||
const lastAssistantMsg = useChatStore(chatSelectors.getGroupLatestMessageWithoutTools(id));
|
||||
|
||||
const contentId = lastAssistantMsg?.id;
|
||||
|
||||
const isEditing = useChatStore(messageStateSelectors.isMessageEditing(contentId || ''));
|
||||
|
||||
// ======================= Performance Optimization ======================= //
|
||||
// these useMemo/useCallback are all for the performance optimization
|
||||
// maybe we can remove it in React 19
|
||||
// ======================================================================== //
|
||||
const onAvatarClick = useCallback(() => {
|
||||
if (!isInbox) {
|
||||
toggleSystemRole(true);
|
||||
} else {
|
||||
openChatSettings();
|
||||
}
|
||||
}, [isInbox]);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={mobile ? 6 : 12}>
|
||||
<Flexbox gap={4} horizontal>
|
||||
<Avatar
|
||||
alt={avatar.title || 'avatar'}
|
||||
avatar={avatar}
|
||||
onClick={onAvatarClick}
|
||||
placement={placement}
|
||||
size={mobile ? MOBILE_AVATAR_SIZE : undefined}
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
<Title avatar={avatar} placement={placement} showTitle time={createdAt} />
|
||||
</Flexbox>
|
||||
{isEditing && contentId ? (
|
||||
<EditState content={lastAssistantMsg?.content} id={contentId} />
|
||||
) : (
|
||||
<Flexbox
|
||||
align={'flex-start'}
|
||||
className={styles.messageContent}
|
||||
data-layout={'vertical'}
|
||||
direction={'vertical'}
|
||||
gap={8}
|
||||
width={'100%'}
|
||||
>
|
||||
{children && children.length > 0 && (
|
||||
<GroupChildren
|
||||
blocks={children}
|
||||
contentId={contentId}
|
||||
disableEditing={disableEditing}
|
||||
messageIndex={index}
|
||||
/>
|
||||
)}
|
||||
|
||||
{model && (
|
||||
<Usage metadata={{ ...performance, ...usage }} model={model} provider={provider!} />
|
||||
)}
|
||||
{!disableEditing && (
|
||||
<Flexbox align={'flex-start'} className={styles.actions} role="menubar">
|
||||
<GroupActionsBar
|
||||
contentBlock={lastAssistantMsg}
|
||||
contentId={contentId}
|
||||
data={props}
|
||||
id={id}
|
||||
index={index}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{mobile && <BorderSpacing borderSpacing={MOBILE_AVATAR_SIZE} />}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default GroupMessage;
|
||||
@@ -16,6 +16,7 @@ import { chatSelectors, messageStateSelectors } from '@/store/chat/selectors';
|
||||
import History from '../components/History';
|
||||
import { InPortalThreadContext } from '../context/InPortalThreadContext';
|
||||
import AssistantMessage from './Assistant';
|
||||
import GroupMessage from './Group';
|
||||
import SupervisorMessage from './Supervisor';
|
||||
import UserMessage from './User';
|
||||
|
||||
@@ -132,6 +133,17 @@ const Item = memo<ChatListItemProps>(
|
||||
);
|
||||
}
|
||||
|
||||
case 'group': {
|
||||
return (
|
||||
<GroupMessage
|
||||
{...item}
|
||||
disableEditing={disableEditing}
|
||||
index={index}
|
||||
showTitle={item.groupId ? true : false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'supervisor': {
|
||||
return <SupervisorMessage {...item} disableEditing={disableEditing} index={index} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OFFICIAL_DOMAIN } from '@lobechat/const';
|
||||
import { UIChatMessage } from '@lobechat/types';
|
||||
import { ModelTag } from '@lobehub/icons';
|
||||
import { Avatar } from '@lobehub/ui';
|
||||
@@ -14,7 +15,6 @@ import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import pkg from '../../../../../../package.json';
|
||||
import { useContainerStyles } from '../style';
|
||||
import { useStyles } from './style';
|
||||
import { FieldType } from './type';
|
||||
@@ -75,7 +75,7 @@ const Preview = memo<PreviewProps>(
|
||||
{withFooter ? (
|
||||
<Flexbox align={'center'} className={styles.footer} gap={4}>
|
||||
<ProductLogo type={'combine'} />
|
||||
<div className={styles.url}>{pkg.homepage}</div>
|
||||
<div className={styles.url}>{OFFICIAL_DOMAIN}</div>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<div />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isDesktop, isServerMode } from '@lobechat/const';
|
||||
import {
|
||||
ContextEngine,
|
||||
GroupMessageFlattenProcessor,
|
||||
HistorySummaryProvider,
|
||||
HistoryTruncateProcessor,
|
||||
InputTemplateProcessor,
|
||||
@@ -28,7 +29,6 @@ interface ContextEngineeringContext {
|
||||
historyCount?: number;
|
||||
historySummary?: string;
|
||||
inputTemplate?: string;
|
||||
isWelcomeQuestion?: boolean;
|
||||
messages: UIChatMessage[];
|
||||
model: string;
|
||||
provider: string;
|
||||
@@ -78,14 +78,15 @@ export const contextEngineering = async ({
|
||||
// Create message processing processors
|
||||
|
||||
// 6. Input template processing
|
||||
new InputTemplateProcessor({
|
||||
inputTemplate,
|
||||
}),
|
||||
new InputTemplateProcessor({ inputTemplate }),
|
||||
|
||||
// 7. Placeholder variables processing
|
||||
new PlaceholderVariablesProcessor({ variableGenerators: VARIABLE_GENERATORS }),
|
||||
|
||||
// 8. Message content processing
|
||||
// 8. Group message flatten (convert role=group to standard assistant + tool messages)
|
||||
new GroupMessageFlattenProcessor(),
|
||||
|
||||
// 8.5 Message content processing
|
||||
new MessageContentProcessor({
|
||||
fileContext: { enabled: isServerMode, includeFileUrl: !isDesktop },
|
||||
isCanUseVideo,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ChatTranslate, UIChatMessage } from '@lobechat/types';
|
||||
|
||||
import { INBOX_SESSION_ID } from '@/const/session';
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { IMessageService } from './type';
|
||||
|
||||
@@ -22,19 +24,27 @@ export class ServerService implements IMessageService {
|
||||
};
|
||||
|
||||
getMessages: IMessageService['getMessages'] = async (sessionId, topicId, groupId) => {
|
||||
// Get user lab preference for message grouping
|
||||
const useGroup = labPreferSelectors.enableAssistantMessageGroup(useUserStore.getState());
|
||||
|
||||
const data = await lambdaClient.message.getMessages.query({
|
||||
groupId,
|
||||
sessionId: this.toDbSessionId(sessionId),
|
||||
topicId,
|
||||
useGroup,
|
||||
});
|
||||
|
||||
return data as unknown as UIChatMessage[];
|
||||
};
|
||||
|
||||
getGroupMessages: IMessageService['getGroupMessages'] = async (groupId, topicId) => {
|
||||
// Get user lab preference for message grouping
|
||||
const useGroup = labPreferSelectors.enableAssistantMessageGroup(useUserStore.getState());
|
||||
|
||||
const data = await lambdaClient.message.getMessages.query({
|
||||
groupId,
|
||||
topicId,
|
||||
useGroup,
|
||||
});
|
||||
return data as unknown as UIChatMessage[];
|
||||
};
|
||||
|
||||
@@ -541,7 +541,7 @@ describe('generateAIChatV2 actions', () => {
|
||||
result.current.cancelSendMessageInServer();
|
||||
});
|
||||
|
||||
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessageInServer operation');
|
||||
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
|
||||
expect(
|
||||
result.current.mainSendMessageOperations[
|
||||
messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
|
||||
@@ -571,7 +571,7 @@ describe('generateAIChatV2 actions', () => {
|
||||
result.current.cancelSendMessageInServer(customTopicId);
|
||||
});
|
||||
|
||||
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessageInServer operation');
|
||||
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
|
||||
});
|
||||
|
||||
it('should handle gracefully when operation does not exist', () => {
|
||||
@@ -740,4 +740,311 @@ describe('generateAIChatV2 actions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callToolFollowAssistantMessage', () => {
|
||||
const TOOL_RESULT_MSG_ID = 'tool-result-msg-id';
|
||||
const ASSISTANT_BLOCK_ID = 'assistant-block-id';
|
||||
const GROUP_MESSAGE_ID = 'group-message-id';
|
||||
const TOOL_CALL_ID = 'tool-call-id';
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.spyOn(messageService, 'createNewMessage').mockResolvedValue({
|
||||
id: 'new-assistant-block-id',
|
||||
messages: [] as any,
|
||||
});
|
||||
});
|
||||
|
||||
it('should find group message from tool result_msg_id in tools array', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const dispatchSpy = vi.fn();
|
||||
|
||||
// Create a group message structure with tool results
|
||||
const groupMessage: UIChatMessage = {
|
||||
id: GROUP_MESSAGE_ID,
|
||||
role: 'group',
|
||||
content: '',
|
||||
sessionId: TEST_IDS.SESSION_ID,
|
||||
topicId: TEST_IDS.TOPIC_ID,
|
||||
children: [
|
||||
{
|
||||
id: ASSISTANT_BLOCK_ID,
|
||||
content: 'Assistant response',
|
||||
tools: [
|
||||
{
|
||||
id: TOOL_CALL_ID,
|
||||
type: 'builtin',
|
||||
apiName: 'testTool',
|
||||
identifier: 'test-tool',
|
||||
arguments: '{}',
|
||||
result: {
|
||||
id: TOOL_RESULT_MSG_ID,
|
||||
content: 'Tool result',
|
||||
},
|
||||
result_msg_id: TOOL_RESULT_MSG_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: TEST_IDS.SESSION_ID,
|
||||
activeTopicId: TEST_IDS.TOPIC_ID,
|
||||
messagesMap: {
|
||||
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: [groupMessage],
|
||||
},
|
||||
internal_execAgentRuntime: vi.fn(),
|
||||
internal_dispatchMessage: dispatchSpy,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.callToolFollowAssistantMessage({
|
||||
parentId: TOOL_RESULT_MSG_ID,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify that addGroupBlock was called with the correct groupMessageId
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'addGroupBlock',
|
||||
groupMessageId: GROUP_MESSAGE_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that createNewMessage was called with message params
|
||||
expect(messageService.createNewMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
parentId: TOOL_RESULT_MSG_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle case when tool result is not found in any group message', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const dispatchSpy = vi.fn();
|
||||
|
||||
const groupMessage: UIChatMessage = {
|
||||
id: GROUP_MESSAGE_ID,
|
||||
role: 'group',
|
||||
content: '',
|
||||
sessionId: TEST_IDS.SESSION_ID,
|
||||
children: [
|
||||
{
|
||||
id: ASSISTANT_BLOCK_ID,
|
||||
content: 'Assistant response',
|
||||
tools: [], // No tools
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: TEST_IDS.SESSION_ID,
|
||||
activeTopicId: TEST_IDS.TOPIC_ID,
|
||||
messagesMap: {
|
||||
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: [groupMessage],
|
||||
},
|
||||
internal_execAgentRuntime: vi.fn(),
|
||||
internal_dispatchMessage: dispatchSpy,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.callToolFollowAssistantMessage({
|
||||
parentId: 'non-existent-tool-result-id',
|
||||
});
|
||||
});
|
||||
|
||||
// Should create message as regular top-level message (createMessage, not addGroupBlock)
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'createMessage',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messageService.createNewMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
parentId: 'non-existent-tool-result-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find group message from nested tool results in multiple children', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const dispatchSpy = vi.fn();
|
||||
|
||||
const groupMessage: UIChatMessage = {
|
||||
id: GROUP_MESSAGE_ID,
|
||||
role: 'group',
|
||||
content: '',
|
||||
sessionId: TEST_IDS.SESSION_ID,
|
||||
children: [
|
||||
{
|
||||
id: 'first-block',
|
||||
content: 'First assistant response',
|
||||
tools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
type: 'builtin',
|
||||
apiName: 'tool1',
|
||||
identifier: 'tool-1',
|
||||
arguments: '{}',
|
||||
result_msg_id: 'other-result-id',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'second-block',
|
||||
content: 'Second assistant response',
|
||||
tools: [
|
||||
{
|
||||
id: 'tool-2',
|
||||
type: 'builtin',
|
||||
apiName: 'tool2',
|
||||
identifier: 'tool-2',
|
||||
arguments: '{}',
|
||||
result_msg_id: TOOL_RESULT_MSG_ID, // Target tool result
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: TEST_IDS.SESSION_ID,
|
||||
messagesMap: {
|
||||
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: [groupMessage],
|
||||
},
|
||||
internal_execAgentRuntime: vi.fn(),
|
||||
internal_dispatchMessage: dispatchSpy,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.callToolFollowAssistantMessage({
|
||||
parentId: TOOL_RESULT_MSG_ID,
|
||||
});
|
||||
});
|
||||
|
||||
// Should find the correct group message even with multiple children
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'addGroupBlock',
|
||||
groupMessageId: GROUP_MESSAGE_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call internal_execAgentRuntime after creating assistant message', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const mockExecAgentRuntime = vi.fn();
|
||||
|
||||
const groupMessage: UIChatMessage = {
|
||||
id: GROUP_MESSAGE_ID,
|
||||
role: 'group',
|
||||
content: '',
|
||||
sessionId: TEST_IDS.SESSION_ID,
|
||||
children: [
|
||||
{
|
||||
id: ASSISTANT_BLOCK_ID,
|
||||
content: 'Response',
|
||||
tools: [
|
||||
{
|
||||
id: TOOL_CALL_ID,
|
||||
type: 'builtin',
|
||||
apiName: 'test',
|
||||
identifier: 'test',
|
||||
arguments: '{}',
|
||||
result_msg_id: TOOL_RESULT_MSG_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: TEST_IDS.SESSION_ID,
|
||||
messagesMap: {
|
||||
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: [groupMessage],
|
||||
},
|
||||
internal_execAgentRuntime: mockExecAgentRuntime,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.callToolFollowAssistantMessage({
|
||||
parentId: TOOL_RESULT_MSG_ID,
|
||||
traceId: 'test-trace-id',
|
||||
threadId: 'test-thread-id',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockExecAgentRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assistantMessageId: 'new-assistant-block-id',
|
||||
traceId: 'test-trace-id',
|
||||
threadId: 'test-thread-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing result_msg_id field gracefully', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const dispatchSpy = vi.fn();
|
||||
|
||||
const groupMessage: UIChatMessage = {
|
||||
id: GROUP_MESSAGE_ID,
|
||||
role: 'group',
|
||||
content: '',
|
||||
sessionId: TEST_IDS.SESSION_ID,
|
||||
children: [
|
||||
{
|
||||
id: ASSISTANT_BLOCK_ID,
|
||||
content: 'Response',
|
||||
tools: [
|
||||
{
|
||||
id: TOOL_CALL_ID,
|
||||
type: 'builtin',
|
||||
apiName: 'test',
|
||||
identifier: 'test',
|
||||
arguments: '{}',
|
||||
// Missing result_msg_id
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: TEST_IDS.SESSION_ID,
|
||||
messagesMap: {
|
||||
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: [groupMessage],
|
||||
},
|
||||
internal_execAgentRuntime: vi.fn(),
|
||||
internal_dispatchMessage: dispatchSpy,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.callToolFollowAssistantMessage({
|
||||
parentId: TOOL_RESULT_MSG_ID,
|
||||
});
|
||||
});
|
||||
|
||||
// Should create as regular message since no groupMessageId found
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'createMessage',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
||||
// Disable the auto sort key eslint rule to make the code more logic and readable
|
||||
import { LOADING_FLAT, MESSAGE_CANCEL_FLAT, isDesktop, isServerMode } from '@lobechat/const';
|
||||
import { LOADING_FLAT, MESSAGE_CANCEL_FLAT, isDesktop } from '@lobechat/const';
|
||||
import { knowledgeBaseQAPrompts } from '@lobechat/prompts';
|
||||
import {
|
||||
ChatImageItem,
|
||||
CreateMessageParams,
|
||||
MessageSemanticSearchChunk,
|
||||
SendMessageParams,
|
||||
TraceEventType,
|
||||
TraceNameMap,
|
||||
UIChatMessage,
|
||||
@@ -49,10 +48,6 @@ interface ProcessMessageParams {
|
||||
}
|
||||
|
||||
export interface AIGenerateAction {
|
||||
/**
|
||||
* Sends a new message to the AI chat system
|
||||
*/
|
||||
sendMessage: (params: SendMessageParams) => Promise<void>;
|
||||
/**
|
||||
* Regenerates a specific message in the chat
|
||||
*/
|
||||
@@ -156,21 +151,6 @@ export const generateAIChat: StateCreator<
|
||||
get().internal_traceMessage(id, { eventType: TraceEventType.RegenerateMessage });
|
||||
},
|
||||
|
||||
sendMessage: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => {
|
||||
const { activeId, sendMessageInServer } = get();
|
||||
if (!activeId) return;
|
||||
|
||||
const fileIdList = files?.map((f) => f.id);
|
||||
|
||||
const hasFile = !!fileIdList && fileIdList.length > 0;
|
||||
|
||||
// if message is empty or no files, then stop
|
||||
if (!message && !hasFile) return;
|
||||
|
||||
// router to server mode send message
|
||||
if (isServerMode)
|
||||
return sendMessageInServer({ message, files, onlyAddUserMessage, isWelcomeQuestion });
|
||||
},
|
||||
stopGenerateMessage: () => {
|
||||
const { chatLoadingIdsAbortController, internal_toggleChatLoading } = get();
|
||||
|
||||
@@ -487,7 +467,7 @@ export const generateAIChat: StateCreator<
|
||||
// if there is traceId, update it
|
||||
if (traceId) {
|
||||
msgTraceId = traceId;
|
||||
await messageService.updateMessage(messageId, {
|
||||
messageService.updateMessage(messageId, {
|
||||
traceId,
|
||||
observationId: observationId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
||||
// Disable the auto sort key eslint rule to make the code more logic and readable
|
||||
import { DEFAULT_AGENT_CHAT_CONFIG, INBOX_SESSION_ID, isDesktop } from '@lobechat/const';
|
||||
import {
|
||||
DEFAULT_AGENT_CHAT_CONFIG,
|
||||
INBOX_SESSION_ID,
|
||||
LOADING_FLAT,
|
||||
isDesktop,
|
||||
} from '@lobechat/const';
|
||||
import { knowledgeBaseQAPrompts } from '@lobechat/prompts';
|
||||
import {
|
||||
ChatImageItem,
|
||||
ChatTopic,
|
||||
ChatVideoItem,
|
||||
CreateNewMessageParams,
|
||||
MessageSemanticSearchChunk,
|
||||
SendMessageParams,
|
||||
SendMessageServerResponse,
|
||||
@@ -13,8 +19,10 @@ import {
|
||||
UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
import { TRPCClientError } from '@trpc/client';
|
||||
import debug from 'debug';
|
||||
import { t } from 'i18next';
|
||||
import { produce } from 'immer';
|
||||
import pMap from 'p-map';
|
||||
import { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { aiChatService } from '@/services/aiChat';
|
||||
@@ -30,21 +38,36 @@ import { getSessionStoreState } from '@/store/session';
|
||||
import { WebBrowsingManifest } from '@/tools/web-browsing';
|
||||
import { setNamespace } from '@/utils/storeDebug';
|
||||
|
||||
import { chatSelectors, topicSelectors } from '../../../selectors';
|
||||
import { chatSelectors, threadSelectors, topicSelectors } from '../../../selectors';
|
||||
import { messageMapKey } from '../../../utils/messageMapKey';
|
||||
|
||||
const n = setNamespace('ai');
|
||||
const log = debug('lobe-store:ai-chat-v2');
|
||||
|
||||
export interface AIGenerateV2Action {
|
||||
/**
|
||||
* Sends a new message to the AI chat system
|
||||
*/
|
||||
sendMessageInServer: (params: SendMessageParams) => Promise<void>;
|
||||
sendMessage: (params: SendMessageParams) => Promise<void>;
|
||||
/**
|
||||
* Cancels sendMessageInServer operation for a specific topic/session
|
||||
* Cancels sendMessage operation for a specific topic/session
|
||||
*/
|
||||
cancelSendMessageInServer: (topicId?: string) => void;
|
||||
clearSendMessageError: () => void;
|
||||
/**
|
||||
*/
|
||||
triggerToolsCalling: (
|
||||
id: string,
|
||||
params?: { threadId?: string; inPortalThread?: boolean; inSearchWorkflow?: boolean },
|
||||
) => Promise<void>;
|
||||
callToolFollowAssistantMessage: (params: {
|
||||
parentId: string;
|
||||
traceId?: string;
|
||||
threadId?: string;
|
||||
inPortalThread?: boolean;
|
||||
inSearchWorkflow?: boolean;
|
||||
}) => Promise<void>;
|
||||
|
||||
internal_refreshAiChat: (params: {
|
||||
topics?: ChatTopic[];
|
||||
messages: UIChatMessage[];
|
||||
@@ -57,7 +80,7 @@ export interface AIGenerateV2Action {
|
||||
*/
|
||||
internal_execAgentRuntime: (params: {
|
||||
messages: UIChatMessage[];
|
||||
userMessageId: string;
|
||||
userMessageId?: string;
|
||||
assistantMessageId: string;
|
||||
isWelcomeQuestion?: boolean;
|
||||
inSearchWorkflow?: boolean;
|
||||
@@ -70,7 +93,7 @@ export interface AIGenerateV2Action {
|
||||
traceId?: string;
|
||||
}) => Promise<void>;
|
||||
/**
|
||||
* Toggle sendMessageInServer operation state
|
||||
* Toggle sendMessage operation state
|
||||
*/
|
||||
internal_toggleSendMessageOperation: (
|
||||
key: string | { sessionId: string; topicId?: string | null },
|
||||
@@ -90,7 +113,7 @@ export const generateAIChatV2: StateCreator<
|
||||
[],
|
||||
AIGenerateV2Action
|
||||
> = (set, get) => ({
|
||||
sendMessageInServer: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => {
|
||||
sendMessage: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => {
|
||||
const { activeTopicId, activeId, activeThreadId, internal_execAgentRuntime, mainInputEditor } =
|
||||
get();
|
||||
if (!activeId) return;
|
||||
@@ -151,7 +174,7 @@ export const generateAIChatV2: StateCreator<
|
||||
|
||||
const operationKey = messageMapKey(activeId, activeTopicId);
|
||||
|
||||
// Start tracking sendMessageInServer operation with AbortController
|
||||
// Start tracking sendMessage operation with AbortController
|
||||
const abortController = get().internal_toggleSendMessageOperation(operationKey, true)!;
|
||||
|
||||
const jsonState = mainInputEditor?.getJSONState();
|
||||
@@ -205,7 +228,7 @@ export const generateAIChatV2: StateCreator<
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Stop tracking sendMessageInServer operation
|
||||
// Stop tracking sendMessage operation
|
||||
get().internal_toggleSendMessageOperation(operationKey, false);
|
||||
}
|
||||
|
||||
@@ -290,7 +313,7 @@ export const generateAIChatV2: StateCreator<
|
||||
get().internal_toggleSendMessageOperation(
|
||||
operationKey,
|
||||
false,
|
||||
'User cancelled sendMessageInServer operation',
|
||||
'User cancelled sendMessage operation',
|
||||
);
|
||||
|
||||
// Only clear creating message state if it's the active session
|
||||
@@ -325,9 +348,16 @@ export const generateAIChatV2: StateCreator<
|
||||
ragQuery,
|
||||
messages: originalMessages,
|
||||
} = params;
|
||||
|
||||
log(
|
||||
'[internal_execAgentRuntime] start, assistantId: %s, messages count: %d',
|
||||
assistantId,
|
||||
originalMessages.length,
|
||||
);
|
||||
|
||||
const {
|
||||
internal_fetchAIChatMessage,
|
||||
triggerToolCalls,
|
||||
triggerToolsCalling,
|
||||
refreshMessages,
|
||||
internal_updateMessageRAG,
|
||||
} = get();
|
||||
@@ -338,11 +368,14 @@ export const generateAIChatV2: StateCreator<
|
||||
const agentStoreState = getAgentStoreState();
|
||||
const { model, provider, chatConfig } = agentSelectors.currentAgentConfig(agentStoreState);
|
||||
|
||||
log('[internal_execAgentRuntime] Agent config: model=%s, provider=%s', model, provider);
|
||||
|
||||
let fileChunks: MessageSemanticSearchChunk[] | undefined;
|
||||
let ragQueryId;
|
||||
|
||||
// go into RAG flow if there is ragQuery flag
|
||||
if (ragQuery) {
|
||||
if (ragQuery && userMessageId) {
|
||||
log('[internal_execAgentRuntime] Entering RAG flow with query: %s', ragQuery);
|
||||
// 1. get the relative chunks from semantic search
|
||||
const { chunks, queryId, rewriteQuery } = await get().internal_retrieveChunks(
|
||||
userMessageId,
|
||||
@@ -470,7 +503,7 @@ export const generateAIChatV2: StateCreator<
|
||||
if (isToolsCalling) {
|
||||
get().internal_toggleMessageInToolsCalling(true, assistantId);
|
||||
await refreshMessages();
|
||||
await triggerToolCalls(assistantId, {
|
||||
await triggerToolsCalling(assistantId, {
|
||||
threadId: params?.threadId,
|
||||
inPortalThread: params?.inPortalThread,
|
||||
});
|
||||
@@ -481,6 +514,7 @@ export const generateAIChatV2: StateCreator<
|
||||
}
|
||||
|
||||
// 4. fetch the AI response
|
||||
log('[internal_execAgentRuntime] Fetching AI response for assistantId: %s', assistantId);
|
||||
const { isFunctionCall, content } = await internal_fetchAIChatMessage({
|
||||
messages,
|
||||
messageId: assistantId,
|
||||
@@ -491,13 +525,18 @@ export const generateAIChatV2: StateCreator<
|
||||
|
||||
// 5. if it's the function call message, trigger the function method
|
||||
if (isFunctionCall) {
|
||||
log('[internal_execAgentRuntime] AI response is function call, triggering tools calling');
|
||||
get().internal_toggleMessageInToolsCalling(true, assistantId);
|
||||
await refreshMessages();
|
||||
await triggerToolCalls(assistantId, {
|
||||
await triggerToolsCalling(assistantId, {
|
||||
threadId: params?.threadId,
|
||||
inPortalThread: params?.inPortalThread,
|
||||
});
|
||||
} else {
|
||||
log(
|
||||
'[internal_execAgentRuntime] AI response completed, content length: %d',
|
||||
content?.length || 0,
|
||||
);
|
||||
// 显示桌面通知(仅在桌面端且窗口隐藏时)
|
||||
if (isDesktop) {
|
||||
try {
|
||||
@@ -534,6 +573,225 @@ export const generateAIChatV2: StateCreator<
|
||||
await get().internal_summaryHistory(historyMessages);
|
||||
}
|
||||
},
|
||||
triggerToolsCalling: async (assistantId, { threadId, inPortalThread, inSearchWorkflow } = {}) => {
|
||||
log('[triggerToolsCalling] start, assistantId (block ID): %s', assistantId);
|
||||
|
||||
const foundMessage = chatSelectors.getMessageById(assistantId)(get());
|
||||
if (!foundMessage) {
|
||||
log('[triggerToolsCalling] Message not found, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a group message or a block
|
||||
let groupMessage: UIChatMessage;
|
||||
let latestBlock: UIChatMessage;
|
||||
|
||||
if (foundMessage.role === 'group') {
|
||||
// Case 1: assistantId matches a group message ID directly
|
||||
// Find the block within children that matches assistantId
|
||||
groupMessage = foundMessage;
|
||||
const block = foundMessage.children?.find((item) => item.id === assistantId);
|
||||
|
||||
if (!block) {
|
||||
log(
|
||||
'[triggerToolsCalling] Block with id %s not found in group message children, returning',
|
||||
assistantId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
latestBlock = block as UIChatMessage;
|
||||
} else if (foundMessage.parentId) {
|
||||
// Case 2: assistantId is a block ID, need to get parent group message
|
||||
const parentMsg = chatSelectors.getMessageById(foundMessage.parentId)(get());
|
||||
if (!parentMsg || parentMsg.role !== 'group') {
|
||||
log('[triggerToolsCalling] Parent group message not found, returning');
|
||||
return;
|
||||
}
|
||||
groupMessage = parentMsg;
|
||||
latestBlock = foundMessage;
|
||||
} else {
|
||||
log(
|
||||
'[triggerToolsCalling] Message is neither a group message nor a block with parentId, returning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log('[triggerToolsCalling] Found group message: %O', {
|
||||
id: groupMessage.id,
|
||||
groupId: groupMessage.groupId,
|
||||
childrenCount: groupMessage.children?.length,
|
||||
latestBlockId: latestBlock.id,
|
||||
});
|
||||
|
||||
if (!latestBlock.tools) {
|
||||
log('[triggerToolsCalling] Latest block has no tools, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'[triggerToolsCalling] Latest block found with %d tools: %O',
|
||||
latestBlock.tools.length,
|
||||
latestBlock.tools.map((t) => ({ id: t.id, type: t.type, identifier: t.identifier })),
|
||||
);
|
||||
|
||||
let shouldCreateMessage = false;
|
||||
let latestToolId = '';
|
||||
|
||||
await pMap(
|
||||
latestBlock.tools,
|
||||
async (payload) => {
|
||||
log(
|
||||
'[triggerToolsCalling] Processing tool: %s (type: %s)',
|
||||
payload.identifier,
|
||||
payload.type,
|
||||
);
|
||||
|
||||
// 2. 使用 createNewMessage 创建 tool 消息
|
||||
const toolMessage: CreateNewMessageParams = {
|
||||
content: '',
|
||||
parentId: assistantId,
|
||||
plugin: payload,
|
||||
role: 'tool',
|
||||
sessionId: get().activeId,
|
||||
tool_call_id: payload.id,
|
||||
threadId,
|
||||
topicId: get().activeTopicId, // if there is activeTopicId,then add it to topicId
|
||||
groupId: groupMessage.groupId, // Propagate groupId from parent message for group chat
|
||||
};
|
||||
|
||||
const result = await get().internal_createNewMessage(toolMessage);
|
||||
|
||||
if (!result) {
|
||||
log('[triggerToolsCalling] Failed to create tool message for %s', payload.identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
log('[triggerToolsCalling] Tool message created: %s', result.id);
|
||||
|
||||
// 3. 执行 tool(这时 tool 消息已经创建,且 UI 已更新)
|
||||
const data = await get().internal_invokeDifferentTypePlugin(result.id, payload);
|
||||
|
||||
if (data && !['markdown', 'standalone'].includes(payload.type)) {
|
||||
shouldCreateMessage = true;
|
||||
latestToolId = result.id;
|
||||
log(
|
||||
'[triggerToolsCalling] Tool %s requires follow-up assistant message',
|
||||
payload.identifier,
|
||||
);
|
||||
} else {
|
||||
log('[triggerToolsCalling] Tool %s completed without follow-up', payload.identifier);
|
||||
}
|
||||
},
|
||||
{ concurrency: 5 },
|
||||
);
|
||||
|
||||
await get().internal_toggleMessageInToolsCalling(false, assistantId);
|
||||
|
||||
if (!shouldCreateMessage) {
|
||||
log('[triggerToolsCalling] No follow-up message needed, completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = chatSelectors.getTraceIdByMessageId(latestToolId)(get());
|
||||
log(
|
||||
'[triggerToolsCalling] Calling follow-up assistant message with latestToolId: %s',
|
||||
latestToolId,
|
||||
);
|
||||
|
||||
await get().callToolFollowAssistantMessage({
|
||||
traceId,
|
||||
threadId,
|
||||
inPortalThread,
|
||||
inSearchWorkflow,
|
||||
parentId: latestToolId,
|
||||
});
|
||||
log('[triggerToolsCalling] completed');
|
||||
},
|
||||
|
||||
callToolFollowAssistantMessage: async ({
|
||||
parentId,
|
||||
traceId,
|
||||
threadId,
|
||||
inPortalThread,
|
||||
inSearchWorkflow,
|
||||
}) => {
|
||||
log('[callToolFollowAssistantMessage] start, parentId: %s', parentId);
|
||||
|
||||
const chats = inPortalThread
|
||||
? threadSelectors.portalAIChatsWithHistoryConfig(get())
|
||||
: chatSelectors.mainAIChatsWithHistoryConfig(get());
|
||||
|
||||
let assistantMessageId: string;
|
||||
|
||||
// 获取 agent 配置
|
||||
const agentStoreState = getAgentStoreState();
|
||||
const { model, provider } = agentSelectors.currentAgentConfig(agentStoreState);
|
||||
|
||||
// 查找包含 parentId 的 group message
|
||||
// parentId 是 tool result message 的 id,它存储在 assistant block 的 tools[].result_msg_id 中
|
||||
let groupMessageId: string | undefined;
|
||||
|
||||
// 遍历所有 group messages,找到包含该 tool result 的那个
|
||||
for (const msg of chats) {
|
||||
if (msg.role === 'group' && msg.children) {
|
||||
for (const child of msg.children) {
|
||||
// 检查 child 的 tools 中是否有 result_msg_id === parentId
|
||||
if (child.tools?.some((tool) => tool.result_msg_id === parentId)) {
|
||||
groupMessageId = msg.id;
|
||||
log('[callToolFollowAssistantMessage] Found group message: %s', groupMessageId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (groupMessageId) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的 assistant message,作为 group message 的新 block
|
||||
const assistantMessage: CreateNewMessageParams = {
|
||||
role: 'assistant',
|
||||
content: LOADING_FLAT,
|
||||
parentId,
|
||||
sessionId: get().activeId,
|
||||
topicId: get().activeTopicId,
|
||||
threadId,
|
||||
traceId,
|
||||
model,
|
||||
provider,
|
||||
};
|
||||
|
||||
log('[callToolFollowAssistantMessage] Creating new assistant message block with params: %O', {
|
||||
parentId,
|
||||
groupMessageId,
|
||||
model,
|
||||
provider,
|
||||
sessionId: get().activeId,
|
||||
topicId: get().activeTopicId,
|
||||
});
|
||||
|
||||
const result = await get().internal_createNewMessage(assistantMessage, { groupMessageId });
|
||||
|
||||
if (!result) {
|
||||
log('[callToolFollowAssistantMessage] Failed to create assistant message');
|
||||
return;
|
||||
}
|
||||
|
||||
assistantMessageId = result.id;
|
||||
log(
|
||||
'[callToolFollowAssistantMessage] Assistant message created successfully, id: %s',
|
||||
assistantMessageId,
|
||||
);
|
||||
|
||||
log('[callToolFollowAssistantMessage] Starting agent runtime with %d messages', chats.length);
|
||||
await get().internal_execAgentRuntime({
|
||||
messages: chats,
|
||||
assistantMessageId,
|
||||
traceId,
|
||||
threadId,
|
||||
inPortalThread,
|
||||
inSearchWorkflow,
|
||||
});
|
||||
log('[callToolFollowAssistantMessage] completed');
|
||||
},
|
||||
|
||||
internal_updateSendMessageOperation: (key, value, actionName) => {
|
||||
const operationKey = typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId);
|
||||
|
||||
Reference in New Issue
Block a user