Files
LibreChat/client/src/components/Chat/Messages/Content/ToolCall.tsx
Danny Avila cabc8afeac 🔧 fix: Await MCP Instructions and Filter Malformed Tool Calls (#10485)
* fix: Await MCP instructions formatting in AgentClient

* fix: don't render or aggregate malformed tool calls

* fix: implement filter for malformed tool call content parts and add tests
2025-11-13 14:17:47 -05:00

248 lines
7.7 KiB
TypeScript

import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react';
import { Button } from '@librechat/client';
import { TriangleAlert } from 'lucide-react';
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import { useLocalize, useProgress } from '~/hooks';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
import { logger, cn } from '~/utils';
export default function ToolCall({
initialProgress = 0.1,
isLast = false,
isSubmitting,
name,
args: _args = '',
output,
attachments,
auth,
}: {
initialProgress: number;
isLast?: boolean;
isSubmitting: boolean;
name: string;
args: string | Record<string, unknown>;
output?: string | null;
attachments?: TAttachment[];
auth?: string;
expires_at?: number;
}) {
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const prevShowInfoRef = useRef<boolean>(showInfo);
const { function_name, domain, isMCPToolCall } = useMemo(() => {
if (typeof name !== 'string') {
return { function_name: '', domain: null, isMCPToolCall: false };
}
if (name.includes(Constants.mcp_delimiter)) {
const [func, server] = name.split(Constants.mcp_delimiter);
return {
function_name: func || '',
domain: server && (server.replaceAll(actionDomainSeparator, '.') || null),
isMCPToolCall: true,
};
}
const [func, _domain] = name.includes(actionDelimiter)
? name.split(actionDelimiter)
: [name, ''];
return {
function_name: func || '',
domain: _domain && (_domain.replaceAll(actionDomainSeparator, '.') || null),
isMCPToolCall: false,
};
}, [name]);
const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const args = useMemo(() => {
if (typeof _args === 'string') {
return _args;
}
try {
return JSON.stringify(_args, null, 2);
} catch (e) {
logger.error(
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to stringify args',
e,
);
return '';
}
}, [_args]) as string | undefined;
const hasInfo = useMemo(
() => (args?.length ?? 0) > 0 || (output?.length ?? 0) > 0,
[args, output],
);
const authDomain = useMemo(() => {
const authURL = auth ?? '';
if (!authURL) {
return '';
}
try {
const url = new URL(authURL);
return url.hostname;
} catch (e) {
logger.error(
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL',
e,
);
return '';
}
}, [auth]);
const progress = useProgress(initialProgress);
const cancelled = (!isSubmitting && progress < 1) || error === true;
const getFinishedText = () => {
if (cancelled) {
return localize('com_ui_cancelled');
}
if (isMCPToolCall === true) {
return localize('com_assistants_completed_function', { 0: function_name });
}
if (domain != null && domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH) {
return localize('com_assistants_completed_action', { 0: domain });
}
return localize('com_assistants_completed_function', { 0: function_name });
};
useLayoutEffect(() => {
if (showInfo !== prevShowInfoRef.current) {
prevShowInfoRef.current = showInfo;
setIsAnimating(true);
if (showInfo && contentRef.current) {
requestAnimationFrame(() => {
if (contentRef.current) {
const height = contentRef.current.scrollHeight;
setContentHeight(height + 4);
}
});
} else {
setContentHeight(0);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 400);
return () => clearTimeout(timer);
}
}, [showInfo]);
useEffect(() => {
if (!contentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showInfo && !isAnimating) {
for (const entry of entries) {
if (entry.target === contentRef.current) {
setContentHeight(entry.contentRect.height + 4);
}
}
}
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showInfo, isAnimating]);
if (!isLast && (!function_name || function_name.length === 0) && !output) {
return null;
}
return (
<>
<div className="relative my-2.5 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowInfo((prev) => !prev)}
inProgressText={
function_name
? localize('com_assistants_running_var', { 0: function_name })
: localize('com_assistants_running_action')
}
authText={
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
}
finishedText={getFinishedText()}
hasInput={hasInfo}
isExpanded={showInfo}
error={cancelled}
/>
</div>
<div
className="relative"
style={{
height: showInfo ? contentHeight : 0,
overflow: 'hidden',
transition:
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: showInfo ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<div
className={cn(
'overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-md',
showInfo && 'shadow-lg',
)}
style={{
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showInfo ? 1 : 0,
transition:
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<div ref={contentRef}>
{showInfo && hasInfo && (
<ToolCallInfo
key="tool-call-info"
input={args ?? ''}
output={output}
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
attachments={attachments}
/>
)}
</div>
</div>
</div>
{auth != null && auth && progress < 1 && !cancelled && (
<div className="flex w-full flex-col gap-2.5">
<div className="mb-1 mt-2">
<Button
className="font-mediu inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm"
variant="default"
rel="noopener noreferrer"
onClick={() => window.open(auth, '_blank', 'noopener,noreferrer')}
>
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
</Button>
</div>
<p className="flex items-center text-xs text-text-warning">
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
{localize('com_assistants_allow_sites_you_trust')}
</p>
</div>
)}
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
</>
);
}