| 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} />} |
| </> |
| ); |
| } |
|
|