diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 1b4b9057f6..7478afbd0c 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -8,7 +8,18 @@ import { isImageVisionTool, } from 'librechat-data-provider'; import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'; -import { ImageGen, ExecuteCode, AgentUpdate, EmptyText, Reasoning, Summary, Text } from './Parts'; +import { + ImageGen, + ExecuteCode, + AgentUpdate, + EmptyText, + Reasoning, + Summary, + Text, + SkillCall, + ReadFileCall, + BashCall, +} from './Parts'; import { ErrorMessage } from './MessageContent'; import RetrievalCall from './RetrievalCall'; import { getCachedPreview } from '~/utils'; @@ -148,6 +159,36 @@ const Part = memo(function Part({ attachments={attachments} /> ); + } else if (isToolCall && toolCall.name === 'skill') { + return ( + + ); + } else if (isToolCall && toolCall.name === 'read_file') { + return ( + + ); + } else if (isToolCall && toolCall.name === 'bash_tool') { + return ( + + ); } else if (isToolCall && toolCall.name === Tools.web_search) { return ( ; + output?: string; + attachments?: TAttachment[]; +}) { + const localize = useLocalize(); + const command = useMemo(() => parseJsonField(args, 'command'), [args]); + + const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = + useToolCallState(initialProgress, isSubmitting, output, !!command); + + const highlighted = useLazyHighlight(command || undefined, 'bash'); + const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); + + const [isCopied, setIsCopied] = useState(false); + const timerRef = useRef>(); + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback(() => { + setIsCopied(true); + copy(command, { format: 'text/plain' }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setIsCopied(false), 3000); + }, [command]); + + return ( + <> +
+ + } + hasInput={!!command || hasOutput} + isExpanded={showCode} + error={cancelled} + /> +
+
+
+
+ {command && ( +
+ +
+                  
+                  {highlighted ?? command}
+                
+
+ )} + {hasOutput && ( +
+
+                  {output}
+                
+
+ )} +
+
+
+ {attachments && attachments.length > 0 && } + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 20298f5c0b..c3972c131b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -1,124 +1,20 @@ -import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useMemo } from 'react'; import { SquareTerminal } from 'lucide-react'; import type { TAttachment } from 'librechat-data-provider'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; -import { useProgress, useLocalize, useExpandCollapse } from '~/hooks'; +import useLazyHighlight from './useLazyHighlight'; +import useToolCallState from './useToolCallState'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; +import { useLocalize } from '~/hooks'; import Stdout from './Stdout'; import { cn } from '~/utils'; -import store from '~/store'; interface ParsedArgs { lang?: string; code?: string; } -interface HastText { - type: 'text'; - value: string; -} - -interface HastElement { - type: 'element'; - tagName: string; - properties?: { className?: string[] }; - children?: HastNode[]; -} - -type HastNode = HastText | HastElement; - -function hastToReact(nodes: HastNode[]): React.ReactNode[] { - return nodes.map((node, i) => { - if (node.type === 'text') { - return node.value; - } - return React.createElement( - node.tagName, - { key: i, className: node.properties?.className?.join(' ') }, - node.children ? hastToReact(node.children) : undefined, - ); - }); -} - -type LowlightModule = typeof import('lowlight'); - -/** Lazy-loaded lowlight singleton — only fetched when syntax highlighting is first needed. */ -let lowlightPromise: Promise | null = null; -let lowlightModule: LowlightModule | null = null; - -function loadLowlight(): Promise { - if (lowlightModule) { - return Promise.resolve(lowlightModule); - } - if (!lowlightPromise) { - lowlightPromise = import('lowlight').then((mod) => { - lowlightModule = mod; - return mod; - }); - } - return lowlightPromise; -} - -function highlightCode(mod: LowlightModule, code: string, lang: string): React.ReactNode[] { - try { - const tree = mod.lowlight.registered(lang) - ? mod.lowlight.highlight(lang, code) - : mod.lowlight.highlightAuto(code); - return hastToReact(tree.children as HastNode[]); - } catch { - return [code]; - } -} - -/** Hook that lazily loads lowlight and returns highlighted nodes once ready. */ -function useLazyHighlight(code: string | undefined, lang: string): React.ReactNode[] | null { - const [highlighted, setHighlighted] = useState(() => { - if (!code || !lowlightModule) { - return null; - } - return highlightCode(lowlightModule, code, lang); - }); - const prevKey = useRef(''); - - useEffect(() => { - const key = `${lang}\0${code ?? ''}`; - if (key === prevKey.current) { - return; - } - prevKey.current = key; - - if (!code) { - setHighlighted(null); - return; - } - - if (lowlightModule) { - setHighlighted(highlightCode(lowlightModule, code, lang)); - return; - } - - let cancelled = false; - loadLowlight() - .then((mod) => { - if (!cancelled) { - setHighlighted(highlightCode(mod, code, lang)); - } - }) - .catch(() => { - if (!cancelled) { - setHighlighted([code]); - } - }); - return () => { - cancelled = true; - }; - }, [code, lang]); - - return highlighted; -} - export function useParseArgs(args?: string | Record): ParsedArgs | null { return useMemo(() => { if (typeof args === 'object' && args !== null) { @@ -152,7 +48,7 @@ export function useParseArgs(args?: string | Record): ParsedArg }, [args]); } -const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m; +export const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m; export default function ExecuteCode({ isSubmitting, @@ -168,29 +64,14 @@ export default function ExecuteCode({ attachments?: TAttachment[]; }) { const localize = useLocalize(); - const hasOutput = output.length > 0; - const autoExpand = useRecoilValue(store.autoExpandTools); - const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs); - const hasContent = !!code || hasOutput; - const [showCode, setShowCode] = useState(() => autoExpand && hasContent); - const { style: expandStyle, ref: expandRef } = useExpandCollapse(showCode); - useEffect(() => { - if (autoExpand && hasContent) { - setShowCode(true); - } - }, [autoExpand, hasContent]); - const progress = useProgress(initialProgress); + const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = + useToolCallState(initialProgress, isSubmitting, output, !!code); const highlighted = useLazyHighlight(code, lang); - const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); - const toggleCode = useCallback(() => setShowCode((prev) => !prev), [setShowCode]); - - const cancelled = !isSubmitting && progress < 1; - return ( <>
@@ -201,11 +82,12 @@ export default function ExecuteCode({ finishedText={ cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished') } + errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined} icon={