* 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
248 lines
7.7 KiB
TypeScript
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} />}
|
|
</>
|
|
);
|
|
}
|