feat: display assistant message in group (#9941)

* use message group

* refactor

* fix tests

* fix tests
This commit is contained in:
Arvin Xu
2025-11-04 19:58:32 +08:00
committed by GitHub
parent 8bab7ad448
commit 59b6ac3a1c
34 changed files with 2493 additions and 53 deletions
+9 -8
View File
@@ -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 />
+6 -5
View File
@@ -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,
+10
View File
@@ -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 activeTopicIdthen 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);