🧩 feat: Redesign Tool Call UI with Contextual Icons, Smart Grouping, and Rich Output Rendering (#12163)

* feat: redesign tool call UI with type-specific icons, smart grouping, and rich output rendering

Replace the generic spinner/checkmark tool call UI with a modern, Cursor-inspired design:

- Add per-tool-type icons (Plug for MCP, Terminal for code, Globe for web search, etc.)
- Group 2+ consecutive tool calls into collapsible "Used N tools" sections
- Stack unique tool icons in grouped headers with overlapping circle design
- Replace raw JSON output with intelligent renderers (table, error, text)
- Restructure ToolCallInfo: output first, parameters collapsible at bottom
- Add shared useExpandCollapse hook for consistent animations
- Add CodeWindowHeader for ExecuteCode windowed view
- Remove FinishedIcon (purple checkmark) entirely

* feat: display custom MCP server icons in tool calls

Add useMCPIconMap hook to resolve MCP server names to their configured
icon paths. ToolIcon and StackedToolIcons now accept custom icon URLs,
showing actual server logos (e.g., Home Assistant, GitHub) instead of
the generic Plug icon for MCP tool calls.

* refactor: unify container styling across code blocks, mermaid, and tool output

Replace hardcoded gray colors with theme tokens throughout:
- CodeBlock: bg-gray-900/700 -> bg-surface-secondary/tertiary + border-border-light
- Mermaid dialog: bg-gray-700 -> bg-surface-secondary, text-gray-200 -> text-text-secondary
- Mermaid containers: rounded-xl -> rounded-lg, remove shadow-md for consistency
- ResultSwitcher: bg-gray-700 -> bg-surface-secondary with border separator
- RunCode: hover:bg-gray-700 -> hover:bg-surface-hover
- ErrorOutput: add border for visual consistency
- MermaidHeader/CodeWindowHeader: consistent focus outlines using border-heavy

* refactor: simplify tool output to plain text, remove custom renderers

Remove over-engineered tool output system (TableOutput, ErrorOutput,
detectOutputType) in favor of simple text extraction. Tool output now
extracts the text content from MCP content blocks and displays it as
clean readable text — no tables, no error styling, no JSON formatting.

Parameters only show key-value badges for simple objects; complex JSON
is hidden instead of dumped raw. Matches Cursor-style simplicity.

* fix: handle error messages and format JSON arrays in tool output

- Strip verbose MCP error prefixes (Error: [MCP][server][tool] tool call
  failed: Error POSTing...) and show just the meaningful error message
- Display errors in red text
- Format uniform JSON arrays as readable lists (name — path) instead
  of raw JSON dumps
- Format plain JSON objects as key: value lines

* feat: improve JSON display in tool call output and parameters

- Replace flat formatObject with recursive formatValue for proper
  indented display of nested JSON structures
- Add ComplexInput component for tool parameters with nested objects,
  arrays, or long strings (previously hidden)
- Broaden hasParams check to show parameters for all object types
- Add font-mono to output renderer for better alignment

* feat: add localization keys for tool errors, web search, and code UI

* refactor: move Mermaid components into dedicated directory module

* refactor: extract CodeBar, FloatingCodeBar, and copy utilities from CodeBlock

* feat: replace manual SVG icons with @icons-pack/react-simple-icons

Supports 50+ programming languages with tree-shaken brand icons
instead of hand-crafted SVGs for 19 languages.

* refactor: simplify code execution UI with persistent code toggle

* refactor: use useExpandCollapse hook in Thinking and Reasoning

* feat: improve tool call error states, subtitles, and group summaries

* feat: redesign web search with inline source display

* feat: improve agent handoff with keyboard accessibility

* feat: reorganize exports order in hooks and utils

* refactor: unify CopyCodeButton with animated icon transitions and iconOnly support

* feat: add run code state machine with animated success/error feedback

* refactor: improve ResultSwitcher with lucide icons and accessibility

* refactor: update CopyButton component

* refactor: replace CopyCodeButton with CopyButton component across multiple files

* test: add ImageGen test stubs

* test: add RetrievalCall test stubs

* feat: merge ImageGen with ToolIcon and localized progress text

* feat: modernize RetrievalCall with ToolIcon and collapsible output

* test: add getToolIconType action delimiter tests

* test: add ImageGen collapsible output tests

* feat: add action ToolIcon type with Zap icon

* fix: replace AgentHandoff div with semantic button

* feat: add aria-live regions to tool components

* feat: redesign execute_code tool UI with syntax highlighting and language icons

- Remove filename labels (script.py, main.rs) and line counter from CodeWindowHeader
- Replace generic FileCode icon with language-specific LangIcon
- Add syntax highlighting via highlight.js to code blocks
- Add SquareTerminal icon to ExecuteCode progress text
- Use shared CopyButton component in CodeWindowHeader
- Remove active:scale-95 press animation from CopyButton and RunCode

* feat: dynamic tool status text sizing based on markdown font-size variable

- Add tool-status-text CSS class using calc(0.9 * --markdown-font-size)
- Update progress-text-wrapper to use dynamic sizing instead of base size
- Apply tool-status-text to WebSearch, ToolCallGroup, AgentHandoff, ImageGen
- Replace hardcoded text-sm/text-xs with dynamic class across all tools
- Animate chevron rotation in ProgressText and ToolCallGroup
- Update subtitle text color from tertiary to secondary

* fix: consistent spacing and text styles across all tool components

- Standardize tool status row spacing to my-1/my-1.5 across all components
- Update ToolCallInfo text from tertiary to secondary, add vertical padding
- Animate ToolCallInfo parameters chevron rotation
- Update OutputRenderer link colors from tertiary to secondary

* feat: unify tool call grouping for all tool types

All consecutive tool calls (MCP, execute_code, web_search, image_gen,
file_search, code_interpreter) are now grouped under a single
collapsible "Used N tools" header instead of only grouping generic
tool calls.

- Remove SPECIAL_TOOL_NAMES blacklist from groupToolCalls
- Replace getToolCallData with getToolMeta to handle all tool types
- Use renderPart callback in ToolCallGroup for proper component routing
- Add file_search and code_interpreter mappings to getToolIconType

* feat: friendly tool group labels, more icons, and output copy button

- Show friendly names in group summary (Code, Web Search, Image
  Generation) instead of raw tool names
- Display MCP server names instead of individual function names
- Deduplicate labels and show up to 3 with +N overflow
- Increase stacked icons from 3 to 4
- Add icon-only copy button to tool output (OutputRenderer)

* fix: execute_code spacing and syntax-highlighted code visibility

Match ToolCall spacing by using my-1.5 on status line and moving my-2
inside overflow-hidden. Replace broken hljs.highlight() with lowlight
(same engine used by rehype-highlight for markdown code blocks) to
render syntax-highlighted code as React elements. Handle object args
in useParseArgs to support both string and Record arg formats.

* feat: replace showCode with auto-expand tools setting

Replace the execute_code-only "Always show code when using code
interpreter" global toggle with a new "Auto-expand tool details"
setting that controls all tool types. Each tool instance now uses
independent local state initialized from the setting, so expanding
one tool no longer affects others. Applies to ToolCall, ExecuteCode,
ToolCallGroup, and CodeAnalyze components.

* fix: apply auto-expand tools setting to WebSearch and RetrievalCall

* fix: only auto-expand tools when content is available

Defer auto-expansion until tool output or content arrives, preventing
empty bordered containers from showing while tools are still running.
Uses useEffect to expand when output becomes available during streaming.

* feat: redesign file_search tool output, citations, and file preview

- Redesign RetrievalCall with per-file cards using OutputRenderer
  (truncated content with show more/less, copy button) matching MCP
  tool pattern
- Route file_search tool calls from Agents API to RetrievalCall
  instead of generic ToolCall
- Add FilePreviewDialog for viewing files (PDF iframe, text content)
  with download option, opened from clickable filenames
- Redesign file citations: FileText icon in badge, relevance and
  page numbers in hovercard, click opens file preview instead of
  downloading
- Add file preview to message file attachments (Files.tsx)
- Fix hovercard animation to slide top-to-bottom and dismiss
  instantly on file click to prevent glitching over dialog
- Add localization keys for relevance, extracted content, preview
- Add top margin to ToolCallGroup

* chore: remove leftover .planning files

* fix: polish FilePreviewDialog, CodeBlock, LangIcon, and Sources

* fix: prevent keyboard focus on collapsed tool content

Add inert attribute to all expand/collapse wrapper divs so
collapsed content is removed from tab order and hidden from
assistive technology. Skip disabled ProgressText buttons from
tab order with tabIndex={-1}.

* feat: integrate file metadata into file_search UI

Pass fileType (MIME) and fileBytes from backend file records through
to the frontend. Add file-type-specific icons, file size display,
pages sorted by relevance, multi-snippet content per file, smart
preview detection by MIME type, and copy button in file preview dialog.

* fix: review fixes — inverted type check, wrong dimension, missing import, fail-open perms, timer leaks, dead code cleanup

* fix: update CodeBlock styling for improved visual consistency

* fix(chat): open composite file citations in preview

* fix(chat): restore file previews for parsed search results

* chore(git): ignore bg-shell artifacts

* fix(chat): restore readable code content in light theme

* style(chat): align code and output surfaces by theme

* chore(i18n): remove 6 unused translation keys

* fix(deps): replace private registry URL with public npm registry in lockfile

* fix: CI lint, build, and test failures

- Add missing scaleImage utility (fixes Vite build error)
- Export scaleImage from utils/index.ts
- Remove unused imports from Part.tsx (FunctionToolCall, CodeToolCall, Agents)
- Fix prettier formatting in Part.tsx (multi-line → single-line imports, conditions)
- Remove excess blank lines in Part.tsx
- Remove unused CodeEditorRef import from Artifacts.tsx
- Add useProgress mock to OpenAIImageGen.test.tsx
- Add scaleImage mock to OpenAIImageGen.test.tsx
- Update OpenAIImageGen tests to match redesigned component structure
- Remove dead collapsible output panel tests from ImageGen.test.tsx
- Add @icons-pack/react-simple-icons to Jest transformIgnorePatterns (ESM fix)

* refactor: reorganize imports order across multiple components for consistency

* fix: add scaleImage tests, delete dead ImageGen wrapper, wire up onUIAction in ToolCallInfo

- Add 7 unit tests for scaleImage utility covering null ref, scaling,
  no-upscale, height clamping, landscape, and panoramic images
- Delete unused Content/ImageGen.tsx re-export wrapper (ImageGen is
  imported from Parts/OpenAIImageGen via the Parts barrel)
- Wire up onUIAction in ToolCallInfo to use handleUIAction + ask from
  useMessagesOperations, matching UIResourceCarousel's behavior
  (was previously a silent no-op)

* refactor: optimize imports and enhance lazy loading for language icons

* fix: address review findings for tool call UI redesign

- Fix unstable array-index keys in ToolCallGroup (streaming state corruption)
- Add plain-text fallback in InputRenderer for non-JSON tool args
- Localize FRIENDLY_NAMES via translation keys instead of hardcoded English
- Guard autoCollapse against user-initiated manual expansion
- Fix CODE_INTERPRETER hasOutput to check actual outputs instead of hardcoding true
- Add logger.warn for Citations fail-closed behavior on permission errors
- Add Terminal icon to CodeAnalyze ProgressText for visual consistency
- Fix getMCPServerName to use indexOf instead of fragile split
- Use useLayoutEffect for inert attribute in useExpandCollapse (a11y)
- Memoize style object in useExpandCollapse to avoid defeating React.memo
- Memoize groupSequentialToolCalls in ContentParts to avoid recomputation
- Use source.link as stable key instead of array index in WebSearch
- Hoist rehypePlugins outside CodeMarkdown to prevent per-render recreation

* fix: revert useMemo after conditional returns in ContentParts

The useMemo placed after early returns violated React Rules of Hooks —
hook call count would change when transitioning between edit/view mode.
Reverted to the original plain forEach which is correct and equally
performant since content changes on every streaming token anyway.

* chore: remove unused com_ui_variables_info translation key

* fix: update tests and jest config for ESM compatibility after rebase

- Add ESM-only packages to transformIgnorePatterns (@dicebear, unified
  ecosystem, react-dnd, lowlight, etc.) to fix Jest parse failures
  introduced by dev rebase
- Update ToolCall.test.tsx to match new component API (CSS
  expand/collapse instead of conditional rendering, simplified props)
- Update ToolCallInfo.test.tsx to mock OutputRenderer (avoids ESM
  chain), align with current component interface (input/output/attachments)

* refactor: replace @icons-pack/react-simple-icons with inline SVGs

Inline the 51 Simple Icons SVG paths used by LangIcon directly into
langIconPaths.ts, eliminating the runtime dependency on
@icons-pack/react-simple-icons (which requires Node >= 24).

- LangIcon now renders a plain <svg> with the path data instead of
  lazy-loading React components from the package
- Removes Suspense/React.lazy overhead for code block language icons
- SVG paths sourced from Simple Icons (CC0 1.0 license)
- Package kept in package.json for now (will be removed separately)

* fix: replace Plug icon with Wrench for MCP tools, remove unused i18n keys

- MCP tools without a custom iconPath now show Wrench instead of Plug,
  matching the generic tool fallback and avoiding the "plugin" metaphor
- Remove unused translation keys: com_assistants_action_attempt,
  com_assistants_attempt_info, com_assistants_domain_info,
  com_ui_ui_resources

* fix: address second review findings

- Combine 3x getToolMeta loop into single toolMetadata pass (ToolCallGroup)
- Extract sortPagesByRelevance to shared util (was duplicated in
  FilePreviewDialog and RetrievalCall)
- Deduplicate AGENT_STYLE_TOOLS Set (export from OpenAIImageGen/index.ts)
- Localize "source/sources" in WebSearch aria-label
- Add autoExpand useEffect to CodeAnalyze for live setting changes
- Log download errors in FilePreviewDialog instead of silently swallowing
- Replace @ts-ignore with @ts-expect-error + explanation in Code.tsx
- Remove dead currentContent alias in CodeMarkdown

* chore: remove @icons-pack/react-simple-icons dependency from package.json and package-lock.json

- Deleted the @icons-pack/react-simple-icons entry from both package.json and package-lock.json, following the previous refactor to use inline SVGs for icons.

* fix: address triage audit findings

- Remove unused gIdx variable (ESLint error)
- Fix singular/plural in web search sources aria-label
- Separate inline type import in ToolCallGroup per AGENTS.md

* fix: remove invalid placeholderDimensions prop from Image component

* chore: import order

* chore: import order

* fix: resolve TypeScript errors in PR-touched files

- Remove non-existent placeholderDimensions prop from Image in Files.tsx
- Fix localize count param type (number, not string) in WebSearch.tsx
- Pass full resource object instead of partial in UIResourceCarousel.tsx
- Add 'as const' to toggleSwitchConfigs localizationKey in General.tsx
- Fix SearchResultData type in Citation.test.tsx
- Fix TAttachment and UIResource test fixture types across test files

* docs: document formatBytes difference in FilePreviewDialog

The local formatBytes returns a human-readable string with units
("1.5 MB"), while ~/utils/formatBytes returns a raw number. They
serve different purposes, so the local copy is retained with a
JSDoc comment explaining the distinction.

* fix: address remaining review items

- Replace cancelled IIFE with documented ternary in OpenAIImageGen,
  explaining the agent vs legacy path distinction
- Add .catch() fallback to loadLowlight() in useLazyHighlight — falls
  back to plain text if the chunk fails to load
- Fix import ordering in ToolCallGroup.tsx (type imports grouped before
  local value imports per AGENTS.md)

* fix: blob URL leak and useGetFiles over-fetch

- FilePreviewDialog: add cancelledRef guard to loadPreview so blob URLs
  are never created after the dialog closes (prevents orphaned object
  URLs on unmount during async PDF fetch)
- RetrievalCall: filter useGetFiles by fileIds from fileSources instead
  of fetching the entire user file corpus for display-only name matching

* chore: fix com_nav_auto_expand_tools alphabetical order in translation.json

* fix: render non-object JSON params instead of returning null in InputRenderer

* refactor: render JSON tool output as syntax-highlighted code block

Replace the custom YAML-ish formatValue/formatObjectArray rendering
with JSON.stringify + hljs language-json styling. Structured API
responses (like GitHub search results) now display as proper
syntax-highlighted JSON with indentation instead of a flat key-value
text dump.

- Remove formatValue, formatObjectArray, isUniformObjectArray helpers
- Add isJson flag to extractText return type
- JSON output rendered in <code class="hljs language-json"> block
- Text content blocks (type: "text") still extracted and rendered
  as plain text
- Error output unchanged

* fix: extract cancelled IIFE to named function in OpenAIImageGen

Replace nested ternary with a named computeCancelled() function that
documents the agent vs legacy path branching. Resolves eslint
no-nested-ternary warning.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-03-25 12:31:39 -04:00 committed by GitHub
parent 5a373825a5
commit 0c66823c26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 5572 additions and 2395 deletions

4
.gitignore vendored
View file

@ -63,6 +63,7 @@ bower_components/
.clineignore
.cursor
.aider*
.bg-shell/
# Floobits
.floo
@ -129,6 +130,7 @@ helm/**/charts/
helm/**/.values.yaml
!/client/src/@types/i18next.d.ts
!/client/src/@types/react.d.ts
# SAML Idp cert
*.cert
@ -143,7 +145,6 @@ helm/**/.values.yaml
/.codeium
*.local.md
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
@ -175,3 +176,4 @@ claude-flow
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
CLAUDE.md
.gsd

View file

@ -47,7 +47,10 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
logger.error(
`[processFileCitations] Permission check failed for FILE_CITATIONS: ${error.message}`,
);
logger.debug(`[processFileCitations] Proceeding with citations due to permission error`);
logger.warn(
'[processFileCitations] Returning null citations due to permission check error — citations will not be shown for this message',
);
return null;
}
}
@ -145,6 +148,8 @@ async function enhanceSourcesWithMetadata(sources, appConfig) {
metadata: {
...source.metadata,
storageType: configuredStorageType,
fileType: fileRecord.type || undefined,
fileBytes: fileRecord.bytes || undefined,
},
};
});

View file

@ -41,7 +41,9 @@ module.exports = {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
transformIgnorePatterns: [
'/node_modules/(?!(@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/',
],
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
};

8
client/src/@types/react.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
import 'react';
declare module 'react' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
inert?: boolean | '' | undefined;
}
}

View file

@ -1,15 +1,16 @@
import { useRef, useState, useEffect } from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { CopyCodeButton } from './Code';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -30,6 +31,7 @@ export default function Artifacts() {
const [height, setHeight] = useState(90);
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0);
const [isCopied, setIsCopied] = useState(false);
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
@ -86,6 +88,16 @@ export default function Artifacts() {
setCurrentArtifactId,
} = useArtifacts();
const handleCopyArtifact = useCallback(() => {
const content = currentArtifact?.content ?? '';
if (!content) {
return;
}
copy(content, { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
}, [currentArtifact?.content]);
const handleDragStart = (e: React.PointerEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
@ -281,7 +293,7 @@ export default function Artifacts() {
}}
/>
)}
<CopyCodeButton content={currentArtifact.content ?? ''} />
<CopyButton isCopied={isCopied} iconOnly onClick={handleCopyArtifact} />
<DownloadArtifact artifact={currentArtifact} />
<Button
size="icon"

View file

@ -1,9 +1,8 @@
import React, { memo, useState } from 'react';
import copy from 'copy-to-clipboard';
import { Button } from '@librechat/client';
import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick } from '~/utils';
import { useLocalize } from '~/hooks';
import React, { memo, useEffect, useRef, useState } from 'react';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { handleDoubleClick, langSubset } from '~/utils';
type TCodeProps = {
inline: boolean;
@ -26,29 +25,70 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
});
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const rehypePlugins = [
[rehypeKatex],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
const handleCopy = () => {
copy(content, { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
};
export const CodeMarkdown = memo(
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
return (
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={handleCopy}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
>
{isCopied ? (
<CircleCheckBig size={16} aria-hidden="true" />
) : (
<Copy size={16} aria-hidden="true" />
)}
</Button>
);
};
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled]);
return (
<div ref={scrollRef} className="max-h-full overflow-y-auto">
<ReactMarkdown
/* @ts-expect-error — rehypePlugins type mismatch between react-markdown and unified PluggableList */
rehypePlugins={rehypePlugins}
components={
{ code } as {
[key: string]: React.ElementType;
}
}
>
{content}
</ReactMarkdown>
</div>
);
},
);

View file

@ -1,24 +1,23 @@
import React, { useMemo, useState } from 'react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import { ChevronDown } from 'lucide-react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import MessageIcon from '~/components/Share/MessageIcon';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { useAgentsMapContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface AgentHandoffProps {
name: string;
args: string | Record<string, unknown>;
output?: string | null;
}
const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) => {
const localize = useLocalize();
const agentsMap = useAgentsMapContext();
const [showInfo, setShowInfo] = useState(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
/** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */
const targetAgentId = useMemo(() => {
if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) {
return null;
@ -44,19 +43,24 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
}
}, [_args]) as string;
/** Requires more than 2 characters as can be an empty object: `{}` */
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
return (
<div className="my-3">
<div
<div className="my-1">
<button
type="button"
className={cn(
'flex items-center gap-2.5 text-sm text-text-secondary',
hasInfo && 'cursor-pointer transition-colors hover:text-text-primary',
'tool-status-text flex appearance-none items-center gap-2.5 bg-transparent text-text-secondary',
hasInfo
? 'transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none',
)}
onClick={() => hasInfo && setShowInfo(!showInfo)}
disabled={!hasInfo}
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
aria-expanded={hasInfo ? showInfo : undefined}
aria-label={`${localize('com_ui_transferred_to')} ${targetAgent?.name || localize('com_ui_agent')}`}
>
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
<MessageIcon
message={
{
@ -77,15 +81,19 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
aria-hidden="true"
/>
)}
</div>
{hasInfo && showInfo && (
<div className="ml-8 mt-2 rounded-md bg-surface-secondary p-3 text-xs">
<div className="mb-1 font-medium text-text-secondary">
{localize('com_ui_handoff_instructions')}:
</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
</button>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (
<div className="ml-8 mt-2 rounded-lg border border-border-light bg-surface-secondary p-3 text-xs">
<div className="mb-1 font-medium text-text-secondary">
{localize('com_ui_handoff_instructions')}:
</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
</div>
)}
</div>
)}
</div>
</div>
);
};

View file

@ -1,8 +1,10 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { Terminal } from 'lucide-react';
import { useProgress, useLocalize } from '~/hooks';
import ProgressText from './ProgressText';
import MarkdownLite from './MarkdownLite';
import { cn } from '~/utils';
import store from '~/store';
export default function CodeAnalyze({
@ -16,8 +18,14 @@ export default function CodeAnalyze({
}) {
const localize = useLocalize();
const progress = useProgress(initialProgress);
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const autoExpand = useRecoilValue(store.autoExpandTools);
const [showCode, setShowCode] = useState(autoExpand);
useEffect(() => {
if (autoExpand) {
setShowCode(true);
}
}, [autoExpand]);
const logs = outputs.reduce((acc, output) => {
if (output['logs']) {
@ -28,7 +36,10 @@ export default function CodeAnalyze({
return (
<>
<div className="my-2.5 flex items-center gap-2.5">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{progress < 1 ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')}
</span>
<div className="my-1 flex items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowCode((prev) => !prev)}
@ -36,6 +47,12 @@ export default function CodeAnalyze({
finishedText={localize('com_ui_analyzing_finished')}
hasInput={!!code.length}
isExpanded={showCode}
icon={
<Terminal
className={cn('size-4 shrink-0 text-text-secondary', progress < 1 && 'animate-pulse')}
aria-hidden="true"
/>
}
/>
</div>
{showCode && (

View file

@ -6,12 +6,12 @@ import type {
TAttachment,
Agents,
} from 'librechat-data-provider';
import { MessageContext, SearchContext } from '~/Providers';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import { mapAttachments } from '~/utils';
import { mapAttachments, groupSequentialToolCalls } from '~/utils';
import { MessageContext, SearchContext } from '~/Providers';
import { EditTextPart, EmptyText } from './Parts';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import ToolCallGroup from './ToolCallGroup';
import Container from './Container';
import Part from './Part';
@ -160,10 +160,10 @@ const ContentParts = memo(function ContentParts({
}
const isTextPart =
part?.type === ContentTypes.TEXT ||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
typeof (part as unknown as Agents.MessageContentText)?.text === 'string';
const isThinkPart =
part?.type === ContentTypes.THINK ||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think === 'string';
if (!isTextPart && !isThinkPart) {
return null;
}
@ -216,17 +216,32 @@ const ContentParts = memo(function ContentParts({
sequentialParts.push({ part, idx });
}
});
const groupedParts = groupSequentialToolCalls(sequentialParts);
return (
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{showEmptyCursor && (
<Container>
<EmptyText />
</Container>
)}
{sequentialParts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
{groupedParts.map((group) => {
if (group.type === 'single') {
const { part, idx } = group.part;
return renderPart(part, idx, idx === lastContentIdx);
}
return (
<ToolCallGroup
key={`tool-group-${group.parts[0].idx}`}
parts={group.parts}
isSubmitting={effectiveIsSubmitting}
isLast={group.parts.some((p) => p.idx === lastContentIdx)}
renderPart={renderPart}
lastContentIdx={lastContentIdx}
/>
);
})}
</SearchContext.Provider>
);
});

View file

@ -0,0 +1,344 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import copy from 'copy-to-clipboard';
import { useRecoilValue } from 'recoil';
import { Download } from 'lucide-react';
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { logger, sortPagesByRelevance } from '~/utils';
import { useFileDownload } from '~/data-provider';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface FilePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
fileId?: string;
relevance?: number;
pages?: number[];
pageRelevance?: Record<number, number>;
fileType?: string;
fileSize?: number;
}
function getFileExtension(filename: string): string {
const dot = filename.lastIndexOf('.');
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : '';
}
function canPreviewByMime(mime?: string): 'pdf' | 'text' | false {
if (!mime) {
return false;
}
if (mime.includes('pdf')) {
return 'pdf';
}
if (
mime.startsWith('text/') ||
mime.includes('json') ||
mime.includes('xml') ||
mime.includes('javascript') ||
mime.includes('typescript') ||
mime.includes('yaml') ||
mime.includes('csv')
) {
return 'text';
}
return false;
}
function canPreviewByExt(filename: string): 'pdf' | 'text' | false {
const ext = getFileExtension(filename);
if (ext === 'pdf') {
return 'pdf';
}
const textExts = new Set([
'txt',
'md',
'csv',
'json',
'xml',
'yaml',
'yml',
'html',
'css',
'js',
'ts',
'jsx',
'tsx',
'py',
'rb',
'java',
'c',
'cpp',
'h',
'go',
'rs',
'sh',
'sql',
'log',
]);
return textExts.has(ext) ? 'text' : false;
}
/** Formats bytes with unit suffix (differs from ~/utils/formatBytes which returns a raw number). */
function formatBytes(bytes: number): string {
if (bytes >= 1048576) {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${bytes} B`;
}
function getDisplayType(fileType?: string, fileName?: string): string {
if (fileType) {
if (fileType.includes('pdf')) {
return 'PDF';
}
if (fileType.includes('word') || fileType.includes('document')) {
return 'Document';
}
if (fileType.includes('spreadsheet') || fileType.includes('excel')) {
return 'Spreadsheet';
}
if (fileType.includes('presentation') || fileType.includes('powerpoint')) {
return 'Presentation';
}
if (fileType.includes('image')) {
return 'Image';
}
if (fileType.startsWith('text/')) {
return fileType.split('/')[1]?.toUpperCase() || 'Text';
}
if (fileType.includes('json')) {
return 'JSON';
}
if (fileType.includes('xml')) {
return 'XML';
}
}
const ext = fileName ? getFileExtension(fileName) : '';
return ext ? ext.toUpperCase() : 'File';
}
export default function FilePreviewDialog({
open,
onOpenChange,
fileName,
fileId,
relevance,
pages,
pageRelevance,
fileType,
fileSize,
}: FilePreviewDialogProps) {
const localize = useLocalize();
const user = useRecoilValue(store.user);
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const loadingRef = useRef(false);
const previewKind = canPreviewByMime(fileType) || canPreviewByExt(fileName);
const cancelledRef = useRef(false);
const loadPreview = useCallback(async () => {
if (!fileId || !previewKind || loadingRef.current) {
return;
}
loadingRef.current = true;
cancelledRef.current = false;
setLoading(true);
setPreviewError(false);
try {
const result = await downloadFile();
if (cancelledRef.current || !result.data) {
if (!cancelledRef.current) {
setPreviewError(true);
}
return;
}
const resp = await fetch(result.data);
const blob = await resp.blob();
if (cancelledRef.current) {
return;
}
if (previewKind === 'text') {
setFileContent(await blob.text());
} else {
const typed = new Blob([blob], { type: 'application/pdf' });
setFileBlobUrl(URL.createObjectURL(typed));
}
} catch {
if (!cancelledRef.current) {
setPreviewError(true);
}
} finally {
loadingRef.current = false;
if (!cancelledRef.current) {
setLoading(false);
}
}
}, [fileId, previewKind, downloadFile]);
const handleDownload = useCallback(async () => {
if (!fileId) {
return;
}
try {
const result = await downloadFile();
if (!result.data) {
return;
}
const a = document.createElement('a');
a.href = result.data;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(result.data), 1000);
} catch (err) {
logger.error('[FilePreviewDialog] Download failed:', err);
}
}, [downloadFile, fileId, fileName]);
useEffect(() => {
if (open && previewKind && !fileContent && !fileBlobUrl) {
loadPreview();
}
}, [open, previewKind, fileContent, fileBlobUrl, loadPreview]);
useEffect(() => {
return () => {
if (fileBlobUrl) {
URL.revokeObjectURL(fileBlobUrl);
}
};
}, [fileBlobUrl]);
useEffect(() => {
if (!open) {
cancelledRef.current = true;
setFileContent(null);
setFileBlobUrl(null);
setPreviewError(false);
setLoading(false);
setIsCopied(false);
}
}, [open]);
const handleCopy = useCallback(() => {
if (!fileContent) {
return;
}
copy(fileContent, { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
}, [fileContent]);
const displayType = useMemo(() => getDisplayType(fileType, fileName), [fileType, fileName]);
const sortedPages = useMemo(
() => (pages && pageRelevance ? sortPagesByRelevance(pages, pageRelevance) : pages),
[pages, pageRelevance],
);
const metaParts: string[] = [displayType];
if (relevance != null && relevance > 0) {
metaParts.push(`${localize('com_ui_relevance')}: ${Math.round(relevance * 100)}%`);
}
if (fileSize != null && fileSize > 0) {
metaParts.push(formatBytes(fileSize));
}
if (sortedPages && sortedPages.length > 0) {
metaParts.push(localize('com_file_pages', { pages: sortedPages.join(', ') }));
}
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent
className="flex w-full max-w-4xl flex-col !overflow-hidden p-0"
showCloseButton={true}
>
<div className="shrink-0 px-6 pr-12 pt-6">
<OGDialogTitle className="truncate text-base">{fileName}</OGDialogTitle>
<div className="mt-0.5 flex items-center gap-3">
<OGDialogDescription className="min-w-0 truncate">
{metaParts.join(' · ')}
</OGDialogDescription>
{fileId && (
<button
type="button"
onClick={handleDownload}
className="inline-flex shrink-0 items-center gap-1 text-xs text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
aria-label={`${localize('com_ui_download')} ${fileName}`}
>
<Download className="size-3" aria-hidden="true" />
{localize('com_ui_download')}
</button>
)}
</div>
</div>
<div className="relative min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-4">
{loading && (
<div className="flex h-60 items-center justify-center rounded-lg bg-surface-secondary">
<span className="shimmer text-sm text-text-secondary">
{localize('com_ui_loading')}
</span>
</div>
)}
{previewError && (
<div className="flex h-32 items-center justify-center rounded-lg bg-surface-secondary">
<span className="text-sm text-text-secondary">
{localize('com_ui_preview_unavailable')}
</span>
</div>
)}
{fileBlobUrl && (
<iframe
src={fileBlobUrl}
title={`${localize('com_ui_preview')}: ${fileName}`}
className="h-[70vh] w-full rounded-lg border border-border-light"
/>
)}
{fileContent && (
<>
<div className="pointer-events-none sticky top-0 z-10 flex justify-end pr-1">
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
iconOnly
label={localize('com_ui_copy')}
className="pointer-events-auto rounded-lg bg-surface-secondary"
/>
</div>
<div className="-mt-8 rounded-lg bg-surface-secondary p-4">
<pre className="whitespace-pre-wrap break-words pr-8 font-mono text-sm leading-6 text-text-primary">
{fileContent}
</pre>
</div>
</>
)}
{!previewKind && !loading && (
<div className="flex h-32 items-center justify-center rounded-lg bg-surface-secondary">
<span className="text-sm text-text-secondary">
{localize('com_ui_preview_unavailable')}
</span>
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -1,7 +1,7 @@
import { useMemo, memo } from 'react';
import { useMemo, useState, useCallback, memo } from 'react';
import type { TFile, TMessage } from 'librechat-data-provider';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { getCachedPreview } from '~/utils';
import FilePreviewDialog from './FilePreviewDialog';
import Image from './Image';
const Files = ({ message }: { message?: TMessage }) => {
@ -10,26 +10,45 @@ const Files = ({ message }: { message?: TMessage }) => {
}, [message?.files]);
const otherFiles = useMemo(() => {
return message?.files?.filter((file) => !(file.type?.startsWith('image/') === true)) || [];
return message?.files?.filter((file) => !file.type?.startsWith('image/')) || [];
}, [message?.files]);
const [selectedFile, setSelectedFile] = useState<Partial<TFile> | null>(null);
const handleClose = useCallback((open: boolean) => {
if (!open) {
setSelectedFile(null);
}
}, []);
return (
<>
{otherFiles.length > 0 &&
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
otherFiles.map((file) => (
<FileContainer
key={file.file_id}
file={file as TFile}
onClick={() => setSelectedFile(file)}
/>
))}
{imageFiles.length > 0 &&
imageFiles.map((file) => {
const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
return (
<Image
key={file.file_id}
width={file.width}
height={file.height}
altText={file.filename ?? 'Uploaded Image'}
imagePath={cached ?? file.preview ?? file.filepath ?? ''}
/>
);
})}
imageFiles.map((file) => (
<Image
key={file.file_id}
imagePath={file.preview ?? file.filepath ?? ''}
height={file.height ?? 1920}
width={file.width ?? 1080}
altText={file.filename ?? 'Uploaded Image'}
/>
))}
<FilePreviewDialog
open={selectedFile !== null}
onOpenChange={handleClose}
fileName={selectedFile?.filename ?? ''}
fileId={selectedFile?.file_id}
fileType={selectedFile?.type ?? undefined}
fileSize={(selectedFile as TFile)?.bytes}
/>
</>
);
};

View file

@ -1,17 +0,0 @@
export default function FinishedIcon() {
return (
<div
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
data-projection-id="162"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.66607 0.376042C8.01072 0.605806 8.10385 1.07146 7.87408 1.4161L3.54075 7.9161C3.40573 8.11863 3.18083 8.24304 2.93752 8.24979C2.69421 8.25654 2.46275 8.1448 2.31671 7.95008L0.150044 5.06119C-0.098484 4.72982 -0.0313267 4.25972 0.300044 4.01119C0.631415 3.76266 1.10152 3.82982 1.35004 4.16119L2.88068 6.20204L6.62601 0.584055C6.85577 0.239408 7.32142 0.146278 7.66607 0.376042Z"
fill="currentColor"
/>
</svg>
</div>
);
}

View file

@ -1,84 +0,0 @@
import { useState } from 'react';
import ProgressCircle from './ProgressCircle';
import ProgressText from './ProgressText';
import { useProgress } from '~/hooks';
export default function ImageGen({
initialProgress = 0.1,
args = '',
}: {
initialProgress: number;
args: string;
}) {
const progress = useProgress(initialProgress);
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
const [showDetails, setShowDetails] = useState(false);
// const [translate, setTranslate] = useState(0);
// useEffect(() => {
// const timer = setInterval(() => {
// setTranslate((prevTranslate) => (prevTranslate + 1) % 360);
// }, 20);
// return () => clearInterval(timer);
// }, []);
// if (progress >= 1) {
// return null;
// }
return (
<div className="my-2.5 flex items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="106"
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 20 20"
width="20"
height="20"
style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }}
preserveAspectRatio="xMidYMid meet"
>
<g className="move-up">
<path
fill="rgb(177,98,253)"
fillOpacity="1"
d="M11.812616,5.914505C11.535567,5.360465,10.744857,5.360465,10.467807,5.914505C9.369731,8.110632,8.271654,10.306758,7.173676,12.502885C6.923041,13.002725,7.286122,13.590845,7.844972,13.590845C10.041112,13.590845,12.237252,13.590845,14.433392,13.590845C14.992292,13.590845,15.355792,13.002725,15.105892,12.502885C14.007888,10.306758,12.909805,8.110632,11.812616,5.914505C11.812616,5.914505,11.812616,5.914505,11.812616,5.914505C11.812616,5.914505,11.812616,5.914505,11.812616,5.914505M7.216073,8.914505C6.939024,8.360465,6.148304,8.360465,5.871264,8.914505C5.274365,10.10828,4.677499,11.302055,4.080601,12.495835C3.83003,12.99568,4.193122,13.58379,4.751975,13.58379C5.946767,13.58379,7.14156,13.58379,8.336353,13.58379C8.895213,13.58379,9.258683,12.99568,9.008763,12.495835C8.411867,11.302055,7.81497,10.10828,7.216073,8.914505C7.216073,8.914505,7.216073,8.914505,7.216073,8.914505C7.216073,8.914505,7.216073,8.914505,7.216073,8.914505"
/>
</g>
<g
style={{ display: 'block' }}
transform="matrix(-1,0,0,-1,10,10)"
opacity="1"
className="moon-rise"
>
<g opacity="1" transform="matrix(1,0,0,1,3.75,5.5)">
<path
fill="rgb(177,98,253)"
fillOpacity="1"
d=" M2.660290002822876,2.2502501010894775 C2.7567598819732666,2.2502501010894775 2.850860118865967,2.241950035095215 2.9425699710845947,2.225330114364624 C3.034290075302124,2.208709955215454 3.1081299781799316,2.1867599487304688 3.164109945297241,2.1594600677490234 C3.239150047302246,2.120300054550171 3.305850028991699,2.100709915161133 3.364219903945923,2.100709915161133 C3.405900001525879,2.100709915161133 3.438659906387329,2.113770008087158 3.462480068206787,2.1398799419403076 C3.487489938735962,2.165990114212036 3.5,2.2009999752044678 3.5,2.2449100017547607 C3.5,2.2698400020599365 3.4958300590515137,2.2983200550079346 3.487489938735962,2.3303699493408203 C3.4803500175476074,2.362410068511963 3.468440055847168,2.3968300819396973 3.4517600536346436,2.433619976043701 C3.3803000450134277,2.5950300693511963 3.287990093231201,2.7410099506378174 3.1748299598693848,2.871570110321045 C3.0628700256347656,3.002120018005371 2.9348299503326416,3.1142799854278564 2.790709972381592,3.2080399990081787 C2.646589994430542,3.3029799461364746 2.4905600547790527,3.375380039215088 2.3226099014282227,3.425230026245117 C2.15585994720459,3.4750800132751465 1.9825600385665894,3.5 1.8027100563049316,3.5 C1.5430500507354736,3.5 1.3036400079727173,3.4554901123046875 1.0844800472259521,3.3664801120758057 C0.8653200268745422,3.2786500453948975 0.6741499900817871,3.1540400981903076 0.5109699964523315,2.9926199913024902 C0.34898999333381653,2.831209897994995 0.22333000600337982,2.641319990158081 0.1340000033378601,2.4229400157928467 C0.04467000067234039,2.2045600414276123 0,1.9660099744796753 0,1.7072700262069702 C0,1.4639699459075928 0.04645000025629997,1.2325400114059448 0.1393599957227707,1.012969970703125 C0.23226000368595123,0.7922199964523315 0.3626900017261505,0.5975800156593323 0.5306299924850464,0.4290440082550049 C0.6997600197792053,0.2593249976634979 0.8968899846076965,0.12877200543880463 1.121999979019165,0.03738600015640259 C1.1541600227355957,0.024329999461770058 1.1833399534225464,0.01483600027859211 1.2095500230789185,0.008901000022888184 C1.2369400262832642,0.0029670000076293945 1.2631399631500244,2.220446049250313e-16 1.288159966468811,2.220446049250313e-16 C1.335800051689148,2.220446049250313e-16 1.3733199834823608,0.014241999946534634 1.4007099866867065,0.042725998908281326 C1.4292999505996704,0.07121100276708603 1.4435900449752808,0.10681600123643875 1.4435900449752808,0.14954200387001038 C1.4435900449752808,0.1780260056257248 1.438230037689209,0.2076980024576187 1.4275100231170654,0.23855499923229218 C1.41798996925354,0.2682270109653473 1.404289960861206,0.2996779978275299 1.3864200115203857,0.3329089879989624 C1.3625999689102173,0.3768230080604553 1.3423500061035156,0.4302310049533844 1.3256800174713135,0.493133008480072 C1.309000015258789,0.5548499822616577 1.296489953994751,0.6225000023841858 1.288159966468811,0.6960800290107727 C1.2798199653625488,0.7684800028800964 1.2756500244140625,0.8414700031280518 1.2756500244140625,0.9150599837303162 C1.2756500244140625,1.1215699911117554 1.3072099685668945,1.3073099851608276 1.3703399896621704,1.4722800254821777 C1.4346599578857422,1.6372499465942383 1.5269700288772583,1.7778899669647217 1.6472699642181396,1.8941999673843384 C1.7675700187683105,2.0093300342559814 1.9128799438476562,2.097749948501587 2.083209991455078,2.1594600677490234 C2.2547199726104736,2.2199900150299072 2.44707989692688,2.2502501010894775 2.660290002822876,2.2502501010894775 C2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 C2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775"
/>
</g>
</g>
</svg>
</div>
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
</div>
</div>
<ProgressText
progress={progress}
onClick={() => setShowDetails((prev) => !prev)}
inProgressText="Creating Image"
finishedText="Finished."
hasInput={false}
/>
</div>
);
}

View file

@ -2,9 +2,8 @@ import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary';
import Mermaid, { MermaidErrorBoundary } from '~/components/Messages/Content/Mermaid';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import Mermaid from '~/components/Messages/Content/Mermaid';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useFileDownload } from '~/data-provider';
import { useCodeBlockContext } from '~/Providers';

View file

@ -1,3 +1,4 @@
import { memo } from 'react';
import {
Tools,
Constants,
@ -6,17 +7,8 @@ import {
imageGenTools,
isImageVisionTool,
} from 'librechat-data-provider';
import { memo } from 'react';
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
import {
OpenAIImageGen,
ExecuteCode,
AgentUpdate,
EmptyText,
Reasoning,
Summary,
Text,
} from './Parts';
import { ImageGen, ExecuteCode, AgentUpdate, EmptyText, Reasoning, Summary, Text } from './Parts';
import { ErrorMessage } from './MessageContent';
import RetrievalCall from './RetrievalCall';
import { getCachedPreview } from '~/utils';
@ -25,7 +17,6 @@ import CodeAnalyze from './CodeAnalyze';
import Container from './Container';
import WebSearch from './WebSearch';
import ToolCall from './ToolCall';
import ImageGen from './ImageGen';
import Image from './Image';
type PartProps = {
@ -138,7 +129,7 @@ const Part = memo(function Part({
isSubmitting={isSubmitting}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
args={toolCall.args}
/>
);
} else if (
@ -148,7 +139,7 @@ const Part = memo(function Part({
toolCall.name === 'gemini_image_gen')
) {
return (
<OpenAIImageGen
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
toolName={toolCall.name}
@ -167,14 +158,17 @@ const Part = memo(function Part({
isLast={isLast}
/>
);
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
} else if (isToolCall && (toolCall.name === 'file_search' || toolCall.name === 'retrieval')) {
return (
<AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
<RetrievalCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
output={toolCall.output ?? undefined}
attachments={attachments}
/>
);
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
return <AgentHandoff args={toolCall.args ?? ''} name={toolCall.name || ''} />;
} else if (isToolCall) {
return (
<ToolCall
@ -185,7 +179,6 @@ const Part = memo(function Part({
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
@ -203,7 +196,12 @@ const Part = memo(function Part({
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
<RetrievalCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
output={(toolCall as { output?: string }).output}
attachments={attachments}
/>
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
@ -214,6 +212,9 @@ const Part = memo(function Part({
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
isSubmitting={isSubmitting}
toolName={toolCall.function.name}
output={toolCall.function.output ?? ''}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {

View file

@ -0,0 +1,35 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import CopyButton from '~/components/Messages/Content/CopyButton';
import LangIcon from '~/components/Messages/Content/LangIcon';
import { useLocalize } from '~/hooks';
interface CodeWindowHeaderProps {
language: string;
code: string;
}
export default function CodeWindowHeader({ language, code }: CodeWindowHeaderProps) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback(() => {
setIsCopied(true);
copy(code.trim(), { format: 'text/plain' });
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsCopied(false), 3000);
}, [code]);
return (
<div className="flex items-center justify-between bg-surface-primary-alt px-1.5 py-1.5 font-sans text-xs text-text-secondary dark:bg-transparent">
<span className="flex items-center gap-1.5 text-xs font-medium">
<LangIcon lang={language} className="size-3.5 shrink-0" />
{language}
</span>
<CopyButton isCopied={isCopied} onClick={handleCopy} label={localize('com_ui_copy_code')} />
</div>
);
}

View file

@ -1,9 +1,10 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { SquareTerminal } from 'lucide-react';
import type { TAttachment } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import { useProgress, useLocalize } from '~/hooks';
import { useProgress, useLocalize, useExpandCollapse } from '~/hooks';
import CodeWindowHeader from './CodeWindowHeader';
import { AttachmentGroup } from './Attachment';
import Stdout from './Stdout';
import { cn } from '~/utils';
@ -14,8 +15,115 @@ interface ParsedArgs {
code?: string;
}
export function useParseArgs(args?: string): ParsedArgs | null {
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<LowlightModule> | null = null;
let lowlightModule: LowlightModule | null = null;
function loadLowlight(): Promise<LowlightModule> {
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<React.ReactNode[] | null>(() => {
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<string, unknown>): ParsedArgs | null {
return useMemo(() => {
if (typeof args === 'object' && args !== null) {
return { lang: String(args.lang ?? ''), code: String(args.code ?? '') };
}
let parsedArgs: ParsedArgs | string | undefined | null = args;
try {
parsedArgs = JSON.parse(args || '');
@ -44,6 +152,8 @@ export function useParseArgs(args?: string): ParsedArgs | null {
}, [args]);
}
const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m;
export default function ExecuteCode({
isSubmitting,
initialProgress = 0.1,
@ -53,170 +163,88 @@ export default function ExecuteCode({
}: {
initialProgress: number;
isSubmitting: boolean;
args?: string;
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const hasOutput = output.length > 0;
const outputRef = useRef<string>(output);
const codeContentRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const autoExpand = useRecoilValue(store.autoExpandTools);
const prevShowCodeRef = useRef<boolean>(showCode);
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);
useEffect(() => {
if (output !== outputRef.current) {
outputRef.current = output;
const highlighted = useLazyHighlight(code, lang);
if (showCode && codeContentRef.current) {
setTimeout(() => {
if (codeContentRef.current) {
const newHeight = codeContentRef.current.scrollHeight;
setContentHeight(newHeight);
}
}, 10);
}
}
}, [output, showCode]);
const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]);
useEffect(() => {
if (showCode !== prevShowCodeRef.current) {
prevShowCodeRef.current = showCode;
if (showCode && codeContentRef.current) {
setIsAnimating(true);
requestAnimationFrame(() => {
if (codeContentRef.current) {
const height = codeContentRef.current.scrollHeight;
setContentHeight(height);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 500);
return () => clearTimeout(timer);
});
} else if (!showCode) {
setIsAnimating(true);
setContentHeight(0);
const timer = setTimeout(() => {
setIsAnimating(false);
}, 500);
return () => clearTimeout(timer);
}
}
}, [showCode]);
useEffect(() => {
if (!codeContentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showCode && !isAnimating) {
for (const entry of entries) {
if (entry.target === codeContentRef.current) {
setContentHeight(entry.contentRect.height);
}
}
}
});
resizeObserver.observe(codeContentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showCode, isAnimating]);
const toggleCode = useCallback(() => setShowCode((prev) => !prev), [setShowCode]);
const cancelled = !isSubmitting && progress < 1;
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<div className="relative my-1.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowCode((prev) => !prev)}
onClick={toggleCode}
inProgressText={localize('com_ui_analyzing')}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
}
icon={
<SquareTerminal
className={cn(
'size-4 shrink-0 text-text-secondary',
progress < 1 && !cancelled && 'animate-pulse',
)}
aria-hidden="true"
/>
}
hasInput={!!code?.length}
isExpanded={showCode}
error={cancelled}
/>
</div>
<div
className="relative mb-2"
style={{
height: showCode ? 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: showCode ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<div
className={cn(
'code-analyze-block mt-0.5 overflow-hidden rounded-xl bg-surface-primary',
showCode && 'shadow-lg',
)}
ref={codeContentRef}
style={{
transform: showCode ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showCode ? 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)',
}}
>
{showCode && (
<div
style={{
transform: showCode ? 'translateY(0)' : 'translateY(-4px)',
opacity: showCode ? 1 : 0,
transition:
'transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<MarkdownLite
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
codeExecution={false}
/>
</div>
)}
{hasOutput && (
<div
className={cn(
'bg-surface-tertiary p-4 text-xs',
showCode ? 'border-t border-surface-primary-contrast' : '',
)}
style={{
transform: showCode ? 'translateY(0)' : 'translateY(-6px)',
opacity: showCode ? 1 : 0,
transition:
'transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s, opacity 0.45s cubic-bezier(0.19, 1, 0.22, 1) 0.05s',
boxShadow: showCode ? '0 -1px 0 rgba(0,0,0,0.05)' : 'none',
}}
>
<div className="prose flex flex-col-reverse">
<Stdout output={output} />
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
{code && <CodeWindowHeader language={lang} code={code} />}
{code && (
<pre className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt">
<code className={`hljs language-${lang} !whitespace-pre`}>{highlighted}</code>
</pre>
)}
{hasOutput && (
<div
className={cn(
'bg-surface-primary-alt p-4 text-xs dark:bg-transparent',
code && 'border-t border-border-light',
)}
>
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_output')}
</div>
<div
className={cn(
'max-h-[200px] overflow-auto',
outputHasError ? 'text-red-600 dark:text-red-400' : 'text-text-primary',
)}
>
<Stdout output={output} />
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}

View file

@ -1,12 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { PixelCard } from '@librechat/client';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import { ToolIcon, isError } from '~/components/Chat/Messages/Content/ToolOutput';
import Image from '~/components/Chat/Messages/Content/Image';
import { useProgress, useLocalize } from '~/hooks';
import ProgressText from './ProgressText';
import { cn } from '~/utils';
import { AGENT_STYLE_TOOLS } from '.';
import { scaleImage } from '~/utils';
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
const IMAGE_FULL_H = 'h-[45vh]' as const;
function computeCancelled(
isSubmitting: boolean | undefined,
initialProgress: number,
hasError: boolean,
): boolean {
if (isSubmitting !== undefined) {
return (!isSubmitting && initialProgress < 1) || hasError;
}
// Legacy path: in-progress (0 < progress < 1) is never cancelled
// because legacy image gen lacks a submitting signal.
if (initialProgress < 1 && initialProgress > 0) {
return false;
}
return hasError;
}
export default function OpenAIImageGen({
initialProgress = 0.1,
@ -17,49 +33,102 @@ export default function OpenAIImageGen({
attachments,
}: {
initialProgress: number;
isSubmitting: boolean;
toolName: string;
isSubmitting?: boolean;
toolName?: string;
args: string | Record<string, unknown>;
output?: string | null;
attachments?: TAttachment[];
}) {
const [progress, setProgress] = useState(initialProgress);
const localize = useLocalize();
const isAgentStyle = toolName != null && AGENT_STYLE_TOOLS.has(toolName);
const [agentProgress, setAgentProgress] = useState(initialProgress);
const legacyProgress = useProgress(isAgentStyle ? 1 : initialProgress);
const progress = isAgentStyle ? agentProgress : legacyProgress;
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const hasError = typeof output === 'string' && isError(output);
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
/**
* Determines if the image generation was cancelled.
* - Agent path (isSubmitting defined): cancelled if not submitting + incomplete, or on error.
* - Legacy path (isSubmitting undefined): in-progress (0 < progress < 1) is never cancelled
* because legacy image gen lacks a submitting signal only errors cancel.
*/
const cancelled = computeCancelled(isSubmitting, initialProgress, hasError);
let width: number | undefined;
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high';
// Parse args if it's a string
let parsedArgs;
let parsedArgs: Record<string, unknown> = {};
try {
parsedArgs = typeof _args === 'string' ? JSON.parse(_args) : _args;
} catch (error) {
console.error('Error parsing args:', error);
} catch {
parsedArgs = {};
}
if (parsedArgs && typeof parsedArgs.quality === 'string') {
const q = parsedArgs.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
try {
const argsObj = parsedArgs;
if (argsObj && typeof argsObj.size === 'string') {
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
if (!isNaN(w) && !isNaN(h)) {
width = w;
height = h;
}
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
width = undefined;
height = undefined;
}
if (argsObj && typeof argsObj.quality === 'string') {
const q = argsObj.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
}
} catch {
width = undefined;
height = undefined;
}
const attachment = attachments?.[0];
const {
width: imageWidth,
height: imageHeight,
filepath = null,
filename = '',
width: imgWidth,
height: imgHeight,
} = (attachment as TFile & TAttachmentMetadata) || {};
let origWidth = width ?? imageWidth;
let origHeight = height ?? imageHeight;
if (origWidth === undefined || origHeight === undefined) {
origWidth = 1024;
origHeight = 1024;
}
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
const containerRef = useRef<HTMLDivElement>(null);
const updateDimensions = useCallback(() => {
if (origWidth && origHeight && containerRef.current) {
const scaled = scaleImage({
originalWidth: origWidth,
originalHeight: origHeight,
containerRef,
});
setDimensions(scaled);
}
}, [origWidth, origHeight]);
useEffect(() => {
if (!isAgentStyle) {
return;
}
if (isSubmitting) {
setProgress(initialProgress);
setAgentProgress(initialProgress);
if (intervalRef.current) {
clearInterval(intervalRef.current);
@ -71,7 +140,6 @@ export default function OpenAIImageGen({
} else if (quality === 'high') {
baseDuration = 50000;
}
// adding some jitter (±30% of base)
const jitter = Math.floor(baseDuration * 0.3);
const totalDuration = Math.floor(Math.random() * jitter) + baseDuration;
const updateInterval = 200;
@ -83,7 +151,7 @@ export default function OpenAIImageGen({
if (currentStep >= totalSteps) {
clearInterval(intervalRef.current as NodeJS.Timeout);
setProgress(0.9);
setAgentProgress(0.9);
} else {
const progressRatio = currentStep / totalSteps;
let mapRatio: number;
@ -95,7 +163,7 @@ export default function OpenAIImageGen({
}
const scaledProgress = 0.1 + mapRatio * 0.8;
setProgress(scaledProgress);
setAgentProgress(scaledProgress);
}
}, updateInterval);
}
@ -105,35 +173,81 @@ export default function OpenAIImageGen({
clearInterval(intervalRef.current);
}
};
}, [isSubmitting, initialProgress, quality]);
}, [isSubmitting, initialProgress, quality, isAgentStyle]);
useEffect(() => {
if (!isAgentStyle) {
return;
}
if (initialProgress >= 1 || cancelled) {
setProgress(initialProgress);
setAgentProgress(initialProgress);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
}, [initialProgress, cancelled]);
}, [initialProgress, cancelled, isAgentStyle]);
useEffect(() => {
updateDimensions();
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [updateDimensions]);
const isInProgress = progress < 1 && !cancelled;
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{(() => {
if (progress < 1 && !cancelled) {
return '';
}
if (cancelled && hasError) {
return localize('com_ui_image_gen_failed');
}
if (cancelled) {
return localize('com_ui_cancelled');
}
return localize('com_ui_image_created');
})()}
</span>
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2">
<ToolIcon type="image_gen" isAnimating={isInProgress} />
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div>
<div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
<Image
width={imgWidth}
args={parsedArgs}
height={imgHeight}
altText={filename}
imagePath={filepath ?? ''}
className={progress < 1 ? 'invisible absolute' : ''}
/>
{isAgentStyle && (
<div className="relative mb-2 flex w-full justify-start">
<div ref={containerRef} className="w-full max-w-lg">
{dimensions.width !== 'auto' && progress < 1 && (
<PixelCard
variant="default"
progress={progress}
randomness={0.6}
width={dimensions.width}
height={dimensions.height}
/>
)}
<Image
altText={filename}
imagePath={filepath ?? ''}
width={Number(dimensions.width?.split('px')[0])}
height={Number(dimensions.height?.split('px')[0])}
args={parsedArgs}
/>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -1,20 +1,21 @@
import { AGENT_STYLE_TOOLS } from '.';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ProgressText({
progress,
error,
toolName = 'image_gen_oai',
toolName = '',
}: {
progress: number;
error?: boolean;
toolName: string;
toolName?: string;
}) {
const localize = useLocalize();
const getText = () => {
if (error) {
return localize('com_ui_error');
return localize('com_ui_image_gen_failed');
}
if (toolName === 'image_edit_oai') {
@ -33,7 +34,6 @@ export default function ProgressText({
return localize('com_ui_getting_started');
}
// Gemini image generation
if (toolName === 'gemini_image_gen') {
if (progress >= 1) {
return localize('com_ui_image_created');
@ -50,30 +50,38 @@ export default function ProgressText({
return localize('com_ui_getting_started');
}
if (AGENT_STYLE_TOOLS.has(toolName)) {
if (progress >= 1) {
return localize('com_ui_image_created');
}
if (progress >= 0.7) {
return localize('com_ui_final_touch');
}
if (progress >= 0.5) {
return localize('com_ui_adding_details');
}
if (progress >= 0.3) {
return localize('com_ui_creating_image');
}
return localize('com_ui_getting_started');
}
if (progress >= 1) {
return localize('com_ui_image_created');
}
if (progress >= 0.7) {
return localize('com_ui_final_touch');
}
if (progress >= 0.5) {
return localize('com_ui_adding_details');
}
if (progress >= 0.3) {
return localize('com_ui_creating_image');
}
return localize('com_ui_getting_started');
return localize('com_ui_generating_image');
};
const text = getText();
return (
<div
<span
className={cn(
'progress-text-content pointer-events-none absolute left-0 top-0 inline-flex w-full items-center gap-2 overflow-visible whitespace-nowrap',
'progress-text-content tool-status-text whitespace-nowrap font-medium',
progress < 1 && 'shimmer',
)}
>
<span className={`font-medium ${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
</div>
{text}
</span>
);
}

View file

@ -1 +1,4 @@
export { default as ImageGen } from './OpenAIImageGen';
export { default as OpenAIImageGen } from './OpenAIImageGen';
export const AGENT_STYLE_TOOLS = new Set(['image_gen_oai', 'image_edit_oai', 'gemini_image_gen']);

View file

@ -1,11 +1,11 @@
import { memo, useMemo, useState, useCallback, useRef, useId } from 'react';
import { useAtom } from 'jotai';
import type { MouseEvent, FocusEvent } from 'react';
import { useAtomValue } from 'jotai';
import { ContentTypes } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { useMessageContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type ReasoningProps = {
@ -38,10 +38,11 @@ type ReasoningProps = {
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const contentId = useId();
const localize = useLocalize();
const [showThinking] = useAtom(showThinkingAtom);
const showThinking = useAtomValue(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
// Strip <think> tags from the reasoning content (modern format)
@ -113,15 +114,10 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
role="group"
aria-label={label}
aria-hidden={!isExpanded || undefined}
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
className={cn(nextType !== ContentTypes.THINK && isExpanded && 'mb-4')}
style={expandStyle}
>
<div className="relative overflow-hidden">
<div className="relative overflow-hidden" ref={expandRef}>
<ThinkingContent>{reasoningText}</ThinkingContent>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}

View file

@ -1,26 +1,25 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
interface StdoutProps {
output?: string;
}
const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
export default function Stdout({ output = '' }: StdoutProps) {
const processedContent = useMemo(() => {
if (!output) {
return '';
}
const parts = output.split('Generated files:');
return parts[0].trim();
}, [output]);
return (
processedContent && (
<pre className="shrink-0">
<div className="text-text-primary">{processedContent}</div>
</pre>
)
);
};
if (!processedContent) {
return null;
}
export default Stdout;
return (
<pre className="shrink-0 whitespace-pre-wrap break-words font-mono text-text-primary">
{processedContent}
</pre>
);
}

View file

@ -1,11 +1,11 @@
import { useState, useMemo, memo, useCallback, useRef, useId, type MouseEvent } from 'react';
import { useAtomValue } from 'jotai';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Lightbulb, ChevronDown, ChevronUp } from 'lucide-react';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import type { FocusEvent, FC } from 'react';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { fontSizeAtom } from '~/store/fontSize';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/**
@ -18,7 +18,7 @@ export const ThinkingContent: FC<{
const fontSize = useAtomValue(fontSizeAtom);
return (
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 pb-10 text-text-secondary">
<div className="relative rounded-lg border border-border-light bg-surface-secondary p-3 pb-8 text-text-secondary">
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
</div>
);
@ -184,7 +184,7 @@ export const FloatingThinkingBar = memo(
aria-expanded={isExpanded}
aria-controls={contentId}
className={cn(
'flex items-center justify-center rounded-lg bg-surface-secondary p-1.5 text-text-secondary-alt shadow-sm',
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
@ -207,7 +207,7 @@ export const FloatingThinkingBar = memo(
onClick={handleCopy}
aria-label={copyTooltip}
className={cn(
'flex items-center justify-center rounded-lg bg-surface-secondary p-1.5 text-text-secondary-alt shadow-sm',
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
@ -248,6 +248,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentId = useId();
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
@ -311,12 +312,10 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
role="group"
aria-label={label}
aria-hidden={!isExpanded || undefined}
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
className={cn(isExpanded && 'mb-8')}
style={expandStyle}
>
<div className="relative overflow-hidden">
<div className="relative overflow-hidden" ref={expandRef}>
<ThinkingContent>{children}</ThinkingContent>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}

View file

@ -1,8 +1,6 @@
import { ChevronDown } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { Spinner } from '@librechat/client';
import { ChevronDown, ChevronUp } from 'lucide-react';
import CancelledIcon from './CancelledIcon';
import FinishedIcon from './FinishedIcon';
import { cn } from '~/utils';
const wrapperClass =
@ -16,7 +14,6 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
<div
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>
{children}
</div>
@ -30,7 +27,6 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
<div
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="78"
>
{children}
</div>
@ -44,6 +40,9 @@ export default function ProgressText({
inProgressText,
finishedText,
authText,
icon: iconProp,
subtitle,
errorSuffix,
hasInput = true,
popover = false,
isExpanded = false,
@ -54,6 +53,9 @@ export default function ProgressText({
inProgressText: string;
finishedText: string;
authText?: string;
icon?: React.ReactNode;
subtitle?: string;
errorSuffix?: string;
hasInput?: boolean;
popover?: boolean;
isExpanded?: boolean;
@ -70,13 +72,10 @@ export default function ProgressText({
};
const getIcon = () => {
if (error) {
if (error && !errorSuffix) {
return <CancelledIcon />;
}
if (progress < 1) {
return <Spinner />;
}
return <FinishedIcon />;
return iconProp ?? null;
};
const text = getText();
@ -89,20 +88,30 @@ export default function ProgressText({
type="button"
className={cn(
'inline-flex w-full items-center gap-2',
hasInput ? '' : 'pointer-events-none',
hasInput
? 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none',
)}
disabled={!hasInput}
tabIndex={hasInput ? 0 : -1}
onClick={hasInput ? onClick : undefined}
aria-expanded={hasInput ? isExpanded : undefined}
>
{icon}
<span className={showShimmer ? 'shimmer' : ''}>{text}</span>
{hasInput &&
(isExpanded ? (
<ChevronUp className="size-4 shrink-0 translate-y-[1px]" aria-hidden="true" />
) : (
<ChevronDown className="size-4 shrink-0 translate-y-[1px]" aria-hidden="true" />
))}
<span className={cn(showShimmer ? 'shimmer' : '', 'font-medium')}>{text}</span>
{subtitle && <span className="font-normal text-text-secondary">{subtitle}</span>}
{errorSuffix && (
<span className="font-normal text-red-600 dark:text-red-400"> {errorSuffix}</span>
)}
{hasInput && (
<ChevronDown
className={cn(
'size-4 shrink-0 translate-y-[1px] transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
)}
</button>
</Wrapper>
);

View file

@ -1,52 +1,475 @@
import ProgressCircle from './ProgressCircle';
import InProgressCall from './InProgressCall';
import RetrievalIcon from './RetrievalIcon';
import CancelledIcon from './CancelledIcon';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { TooltipAnchor } from '@librechat/client';
import { FileText, FileSpreadsheet, FileCode, FileImage, File } from 'lucide-react';
import type { TAttachment, TFile } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, OutputRenderer, isError } from './ToolOutput';
import FilePreviewDialog from './FilePreviewDialog';
import { sortPagesByRelevance, cn } from '~/utils';
import { useGetFiles } from '~/data-provider';
import ProgressText from './ProgressText';
import FinishedIcon from './FinishedIcon';
import { useProgress } from '~/hooks';
import store from '~/store';
interface FileSource {
fileId: string;
fileName: string;
relevance: number;
content: string;
pages: number[];
pageRelevance: Record<number, number>;
fileType?: string;
fileBytes?: number;
metadata?: Record<string, unknown>;
}
function extractFileSources(attachments?: TAttachment[]): FileSource[] {
if (!attachments) {
return [];
}
const deduped = new Map<string, FileSource>();
for (const att of attachments) {
if (att.type !== Tools.file_search || !att[Tools.file_search]) {
continue;
}
const raw = att[Tools.file_search] as { sources?: FileSource[] };
if (!raw.sources) {
continue;
}
for (const source of raw.sources) {
const key = source.fileId;
const existing = deduped.get(key);
const meta = source.metadata as Record<string, unknown> | undefined;
if (existing) {
const mergedPages = [...new Set([...existing.pages, ...(source.pages || [])])];
existing.pages = mergedPages;
existing.relevance = Math.max(existing.relevance, source.relevance || 0);
existing.pageRelevance = { ...existing.pageRelevance, ...source.pageRelevance };
if (source.content && existing.content) {
existing.content += '\n\n' + source.content;
} else if (source.content) {
existing.content = source.content;
}
} else {
deduped.set(key, {
fileId: source.fileId,
fileName: source.fileName,
relevance: source.relevance || 0,
content: source.content || '',
pages: source.pages || [],
pageRelevance: source.pageRelevance || {},
fileType: (meta?.fileType as string) || undefined,
fileBytes: (meta?.fileBytes as number) || undefined,
metadata: meta,
});
}
}
}
return Array.from(deduped.values()).sort((a, b) => b.relevance - a.relevance);
}
interface ParsedResult {
filename: string;
relevance: number;
content: string;
}
interface DisplayResult {
fileId?: string;
fileName: string;
relevance: number;
content: string;
pages?: number[];
pageRelevance?: Record<number, number>;
fileType?: string;
fileBytes?: number;
}
interface FileMatch {
fileId: string;
fileName: string;
fileType?: string;
fileBytes?: number;
}
function normalizeFilename(filename: string): string {
return filename.toLowerCase().replace(/[^a-z0-9.]/g, '');
}
function addFileMatch(
lookup: Map<string, FileMatch | null>,
fileName: string | undefined,
match: FileMatch,
): void {
if (!fileName) {
return;
}
const key = normalizeFilename(fileName);
if (key.length === 0) {
return;
}
const existing = lookup.get(key);
if (!existing) {
lookup.set(key, match);
return;
}
if (existing.fileId !== match.fileId) {
lookup.set(key, null);
}
}
function buildFileLookup(
fileSources: FileSource[],
files?: TFile[],
): Map<string, FileMatch | null> {
const lookup = new Map<string, FileMatch | null>();
for (const source of fileSources) {
addFileMatch(lookup, source.fileName, {
fileId: source.fileId,
fileName: source.fileName,
fileType: source.fileType,
fileBytes: source.fileBytes,
});
}
for (const file of files ?? []) {
if (!file.file_id || !file.filename) {
continue;
}
const key = normalizeFilename(file.filename);
if (lookup.has(key)) {
continue;
}
addFileMatch(lookup, file.filename, {
fileId: file.file_id,
fileName: file.filename,
fileType: file.type ?? undefined,
fileBytes: file.bytes,
});
}
return lookup;
}
function mergeRetrievalResults(
fileSources: FileSource[],
parsedResults: ParsedResult[],
files?: TFile[],
): DisplayResult[] {
if (parsedResults.length === 0) {
return fileSources.map((source) => ({
fileId: source.fileId,
fileName: source.fileName,
relevance: source.relevance,
content: source.content,
pages: source.pages,
pageRelevance: source.pageRelevance,
fileType: source.fileType,
fileBytes: source.fileBytes,
}));
}
const fileLookup = buildFileLookup(fileSources, files);
return parsedResults.map((result) => {
const key = normalizeFilename(result.filename);
const match = fileLookup.get(key) ?? undefined;
return {
fileId: match?.fileId,
fileName: match?.fileName ?? result.filename,
relevance: result.relevance,
content: result.content,
fileType: match?.fileType,
fileBytes: match?.fileBytes,
};
});
}
function parseRetrievalOutput(raw: string): ParsedResult[] {
const sections = raw.split('\n---\n');
const results: ParsedResult[] = [];
for (const section of sections) {
const trimmed = section.trim();
if (!trimmed) {
continue;
}
let filename = '';
let relevance = 0;
const contentLines: string[] = [];
let inContent = false;
for (const line of trimmed.split('\n')) {
if (inContent) {
contentLines.push(line);
continue;
}
if (line.startsWith('File: ')) {
filename = line.slice(6).trim();
} else if (line.startsWith('Relevance: ')) {
relevance = parseFloat(line.slice(11).trim()) || 0;
} else if (line.startsWith('Content: ')) {
inContent = true;
contentLines.push(line.slice(9));
}
}
if (filename) {
results.push({ filename, relevance, content: contentLines.join('\n').trim() });
}
}
return results;
}
function getFileIcon(mimeType?: string): React.ComponentType<{ className?: string }> {
if (!mimeType) {
return FileText;
}
if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) {
return FileSpreadsheet;
}
if (mimeType.includes('image')) {
return FileImage;
}
if (
mimeType.includes('javascript') ||
mimeType.includes('typescript') ||
mimeType.includes('json') ||
mimeType.includes('xml') ||
mimeType.includes('html')
) {
return FileCode;
}
if (mimeType.includes('pdf') || mimeType.includes('text') || mimeType.includes('word')) {
return FileText;
}
return File;
}
function FileHeader({
fileName,
relevance,
pages,
pageRelevance,
fileType,
onOpenPreview,
}: {
fileName: string;
relevance: number;
pages?: number[];
pageRelevance?: Record<number, number>;
fileType?: string;
onOpenPreview?: () => void;
}) {
const localize = useLocalize();
const IconComponent = getFileIcon(fileType);
const sortedPages = pages && pageRelevance ? sortPagesByRelevance(pages, pageRelevance) : pages;
return (
<div className="flex items-center gap-2 px-3 py-2">
<IconComponent className="size-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
{onOpenPreview ? (
<button
type="button"
onClick={onOpenPreview}
className="min-w-0 truncate text-left text-xs font-medium text-text-primary underline decoration-border-medium underline-offset-2 transition-colors hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy focus-visible:ring-offset-1"
aria-label={`${localize('com_ui_preview')}: ${fileName}`}
>
{fileName}
</button>
) : (
<span className="min-w-0 truncate text-xs font-medium text-text-primary">{fileName}</span>
)}
{relevance > 0 && (
<TooltipAnchor
description={localize('com_ui_relevance')}
side="top"
className="flex items-center"
>
<span
className="shrink-0 rounded bg-surface-tertiary px-1.5 py-0.5 text-[11px] tabular-nums leading-none text-text-secondary"
aria-label={`${localize('com_ui_relevance')}: ${Math.round(relevance * 100)}%`}
>
{Math.round(relevance * 100)}%
</span>
</TooltipAnchor>
)}
<span className="flex-1" />
{sortedPages && sortedPages.length > 0 && (
<span className="shrink-0 text-[11px] text-text-secondary">
{localize('com_file_pages', { pages: sortedPages.join(', ') })}
</span>
)}
</div>
);
}
export default function RetrievalCall({
initialProgress = 0.1,
isSubmitting,
output,
attachments,
}: {
initialProgress: number;
isSubmitting: boolean;
output?: string;
attachments?: TAttachment[];
}) {
const progress = useProgress(initialProgress);
const radius = 56.08695652173913;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
const error = progress >= 2;
const localize = useLocalize();
const errorState = typeof output === 'string' && isError(output);
const cancelled = !isSubmitting && initialProgress < 1 && !errorState;
const hasOutput = !!output && !isError(output);
const autoExpand = useRecoilValue(store.autoExpandTools);
const [showOutput, setShowOutput] = useState(() => autoExpand && hasOutput);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showOutput);
const fileSources = useMemo(() => extractFileSources(attachments), [attachments]);
const parsedResults = useMemo(
() => (hasOutput && output ? parseRetrievalOutput(output) : []),
[hasOutput, output],
);
const fileIds = useMemo(
() => new Set(fileSources.map((s) => s.fileId).filter(Boolean)),
[fileSources],
);
const { data: availableFiles = [] } = useGetFiles<TFile[]>({
enabled: hasOutput && parsedResults.length > 0 && fileIds.size > 0,
select: (files) => files.filter((f) => fileIds.has(f.file_id)),
});
const displayResults = useMemo(
() => mergeRetrievalResults(fileSources, parsedResults, availableFiles),
[availableFiles, fileSources, parsedResults],
);
const hasResults = displayResults.length > 0;
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const openPreview = useCallback((index: number) => {
setPreviewIndex(index);
}, []);
const closePreview = useCallback((open: boolean) => {
if (!open) {
setPreviewIndex(null);
}
}, []);
const previewData = useMemo(() => {
if (previewIndex === null) {
return null;
}
const result = displayResults[previewIndex];
if (!result?.fileId) {
return null;
}
return {
fileName: result.fileName,
fileId: result.fileId,
relevance: result.relevance,
pages: result.pages,
pageRelevance: result.pageRelevance,
fileType: result.fileType,
};
}, [displayResults, previewIndex]);
useEffect(() => {
if (autoExpand && hasOutput) {
setShowOutput(true);
}
}, [autoExpand, hasOutput]);
return (
<div className="my-2.5 flex items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">
{progress < 1 ? (
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
style={{ opacity: 1, transform: 'none' }}
>
<div>
<RetrievalIcon />
</div>
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
</div>
</InProgressCall>
) : error ? (
<CancelledIcon />
) : (
<FinishedIcon />
)}
<div className="my-1">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{(() => {
if (progress < 1 && !cancelled) {
return localize('com_ui_searching_files');
}
if (cancelled) {
return localize('com_ui_cancelled');
}
return localize('com_ui_retrieved_files');
})()}
</span>
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={hasOutput ? () => setShowOutput((prev) => !prev) : undefined}
inProgressText={localize('com_ui_searching_files')}
finishedText={localize('com_ui_retrieved_files')}
errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<ToolIcon type="file_search" isAnimating={progress < 1 && !cancelled && !errorState} />
}
hasInput={hasOutput}
isExpanded={showOutput}
error={cancelled}
/>
</div>
<ProgressText
progress={progress}
onClick={() => ({})}
inProgressText={'Searching my knowledge'}
finishedText={'Used Retrieval'}
hasInput={false}
popover={false}
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasOutput && hasResults && (
<div className="my-2 flex flex-col gap-2">
{displayResults.map((item, i) => {
return (
<div
key={`${item.fileId ?? item.fileName}-${i}`}
className={cn(
'overflow-hidden rounded-lg border border-border-light bg-surface-secondary',
)}
>
<FileHeader
fileName={item.fileName}
relevance={item.relevance}
pages={item.pages}
pageRelevance={item.pageRelevance}
fileType={item.fileType}
onOpenPreview={item.fileId ? () => openPreview(i) : undefined}
/>
{item.content && (
<div className="border-t border-border-light px-3 py-3">
<OutputRenderer text={item.content} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<FilePreviewDialog
open={previewData !== null}
onOpenChange={closePreview}
fileName={previewData?.fileName ?? ''}
fileId={previewData?.fileId}
relevance={previewData?.relevance}
pages={previewData?.pages}
pageRelevance={previewData?.pageRelevance}
fileType={previewData?.fileType}
/>
</div>
);

View file

@ -10,7 +10,6 @@ import type {
TMessageContentParts,
} from 'librechat-data-provider';
import { UnfinishedMessage } from './MessageContent';
import Sources from '~/components/Web/Sources';
import { cn, mapAttachments } from '~/utils';
import { SearchContext } from '~/Providers';
import MarkdownLite from './MarkdownLite';
@ -34,7 +33,6 @@ const SearchContent = ({
if (Array.isArray(message.content) && message.content.length > 0) {
return (
<SearchContext.Provider value={{ searchResults }}>
<Sources />
{message.content
.filter((part: TMessageContentParts | undefined) => part)
.map((part: TMessageContentParts | undefined, idx: number) => {
@ -44,14 +42,14 @@ const SearchContent = ({
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const attachments = attachmentMap[toolCallId];
const partAttachments = attachmentMap[toolCallId];
return (
<Part
key={`display-${messageId}-${idx}`}
showCursor={false}
isSubmitting={false}
isCreatedByUser={message.isCreatedByUser}
attachments={attachments}
attachments={partAttachments}
part={part}
/>
);

View file

@ -1,4 +1,5 @@
import { useMemo, useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Button } from '@librechat/client';
import { TriangleAlert } from 'lucide-react';
import {
@ -8,11 +9,14 @@ import {
actionDomainSeparator,
} from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import { useLocalize, useProgress } from '~/hooks';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap } from '~/hooks/MCP';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
import { logger, cn } from '~/utils';
import { logger } from '~/utils';
import store from '~/store';
export default function ToolCall({
initialProgress = 0.1,
@ -32,14 +36,18 @@ export default function ToolCall({
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 autoExpand = useRecoilValue(store.autoExpandTools);
const hasOutput = (output?.length ?? 0) > 0;
const [showInfo, setShowInfo] = useState(() => autoExpand && hasOutput);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
useEffect(() => {
if (autoExpand && hasOutput) {
setShowInfo(true);
}
}, [autoExpand, hasOutput]);
const { function_name, domain, isMCPToolCall, mcpServerName } = useMemo(() => {
if (typeof name !== 'string') {
@ -65,6 +73,10 @@ export default function ToolCall({
};
}, [name]);
const toolIconType = useMemo(() => getToolIconType(name), [name]);
const mcpIconMap = useMCPIconMap();
const mcpIconUrl = isMCPToolCall ? mcpIconMap.get(mcpServerName) : undefined;
const actionId = useMemo(() => {
if (isMCPToolCall || !auth) {
return '';
@ -95,8 +107,9 @@ export default function ToolCall({
window.open(auth, '_blank', 'noopener,noreferrer');
}, [auth, isMCPToolCall, mcpServerName, actionId]);
const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const hasError = typeof output === 'string' && isError(output);
const cancelled = !isSubmitting && initialProgress < 1 && !hasError;
const errorState = hasError;
const args = useMemo(() => {
if (typeof _args === 'string') {
@ -136,7 +149,17 @@ export default function ToolCall({
}, [auth]);
const progress = useProgress(initialProgress);
const cancelled = (!isSubmitting && progress < 1) || error === true;
const showCancelled = cancelled || (errorState && !output);
const subtitle = useMemo(() => {
if (isMCPToolCall && mcpServerName) {
return localize('com_ui_via_server', { 0: mcpServerName });
}
if (domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH) {
return localize('com_ui_via_server', { 0: domain });
}
return undefined;
}, [isMCPToolCall, mcpServerName, domain, localize]);
const getFinishedText = () => {
if (cancelled) {
@ -151,56 +174,23 @@ export default function ToolCall({
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">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{(() => {
if (progress < 1 && !showCancelled) {
return function_name
? localize('com_assistants_running_var', { 0: function_name })
: localize('com_assistants_running_action');
}
return getFinishedText();
})()}
</span>
<div className="relative my-1.5 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowInfo((prev) => !prev)}
@ -210,61 +200,37 @@ export default function ToolCall({
: localize('com_assistants_running_action')
}
authText={
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
!showCancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
}
finishedText={getFinishedText()}
subtitle={subtitle}
errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<ToolIcon
type={toolIconType}
iconUrl={mcpIconUrl}
isAnimating={progress < 1 && !showCancelled && !errorState}
/>
}
hasInput={hasInfo}
isExpanded={showInfo}
error={cancelled}
error={showCancelled}
/>
</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',
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
<ToolCallInfo input={args ?? ''} output={output} attachments={attachments} />
</div>
)}
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 && (
{auth != null && auth && progress < 1 && !showCancelled && (
<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"
className="inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium"
variant="default"
rel="noopener noreferrer"
onClick={handleOAuthClick}

View file

@ -0,0 +1,178 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDown } from 'lucide-react';
import { ContentTypes, ToolCallTypes } from 'librechat-data-provider';
import type { TMessageContentParts, Agents, FunctionToolCall } from 'librechat-data-provider';
import type { PartWithIndex } from './ParallelContent';
import type { TranslationKeys } from '~/hooks';
import { StackedToolIcons, getMCPServerName } from './ToolOutput';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { useMCPIconMap } from '~/hooks/MCP';
import { cn } from '~/utils';
import store from '~/store';
/** Maps tool names to translation keys — resolved via localize() at render time. */
const FRIENDLY_NAME_KEYS: Record<string, TranslationKeys> = {
execute_code: 'com_ui_tool_name_code',
run_tools_with_code: 'com_ui_tool_name_code',
web_search: 'com_ui_tool_name_web_search',
image_gen_oai: 'com_ui_tool_name_image_gen',
image_edit_oai: 'com_ui_tool_name_image_edit',
gemini_image_gen: 'com_ui_tool_name_image_gen',
file_search: 'com_ui_tool_name_file_search',
code_interpreter: 'com_ui_tool_name_code_analysis',
retrieval: 'com_ui_tool_name_file_search',
};
interface ToolMeta {
name: string;
hasOutput: boolean;
}
function getToolMeta(part: TMessageContentParts): ToolMeta | null {
if (part.type !== ContentTypes.TOOL_CALL) {
return null;
}
const toolCall = part[ContentTypes.TOOL_CALL];
if (!toolCall) {
return null;
}
const isStandard =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isStandard) {
const tc = toolCall as Agents.ToolCall;
return { name: tc.name ?? '', hasOutput: !!tc.output };
}
if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const ci = (toolCall as { code_interpreter?: { outputs?: unknown[] } }).code_interpreter;
return { name: 'code_interpreter', hasOutput: (ci?.outputs?.length ?? 0) > 0 };
}
if (toolCall.type === ToolCallTypes.RETRIEVAL || toolCall.type === ToolCallTypes.FILE_SEARCH) {
return { name: 'file_search', hasOutput: !!(toolCall as { output?: string }).output };
}
if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
const fn = (toolCall as FunctionToolCall).function;
return { name: fn.name, hasOutput: !!fn.output };
}
return null;
}
interface ToolCallGroupProps {
parts: PartWithIndex[];
isSubmitting: boolean;
isLast: boolean;
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
lastContentIdx: number;
}
export default function ToolCallGroup({
parts,
isSubmitting,
isLast,
renderPart,
lastContentIdx,
}: ToolCallGroupProps) {
const localize = useLocalize();
const mcpIconMap = useMCPIconMap();
const count = parts.length;
const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]);
const allCompleted = useMemo(
() => toolMetadata.every((m) => m?.hasOutput === true),
[toolMetadata],
);
const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]);
const toolNameSummary = useMemo(() => {
const seen = new Set<string>();
const labels: string[] = [];
for (const rawName of toolNames) {
if (!rawName) {
continue;
}
const serverName = getMCPServerName(rawName);
const nameKey = FRIENDLY_NAME_KEYS[rawName];
const label = serverName || (nameKey ? localize(nameKey) : rawName);
if (!seen.has(label)) {
seen.add(label);
labels.push(label);
}
}
if (labels.length <= 3) {
return labels.join(', ');
}
return `${labels.slice(0, 3).join(', ')}, +${labels.length - 3}`;
}, [toolNames, localize]);
const autoExpand = useRecoilValue(store.autoExpandTools);
const autoCollapse = !autoExpand && count >= 2 && allCompleted;
const [isExpanded, setIsExpanded] = useState(autoExpand || !autoCollapse);
const [userOverride, setUserOverride] = useState(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
useEffect(() => {
if (autoCollapse && !userOverride) {
setIsExpanded(false);
}
}, [autoCollapse, userOverride]);
const handleToggle = useCallback(() => {
setUserOverride(true);
setIsExpanded((prev) => !prev);
}, []);
const hasActiveToolCall = useMemo(
() => isSubmitting && toolMetadata.some((m) => m && !m.hasOutput),
[toolMetadata, isSubmitting],
);
useEffect(() => {
if (hasActiveToolCall) {
setIsExpanded(true);
}
}, [hasActiveToolCall]);
return (
<div className="mb-2 mt-1">
<button
type="button"
className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-label={localize('com_ui_used_n_tools', { 0: String(count) })}
>
<StackedToolIcons
toolNames={toolNames}
mcpIconMap={mcpIconMap}
maxIcons={4}
isAnimating={!allCompleted && isSubmitting}
/>
<span className="tool-status-text font-medium">
{localize('com_ui_used_n_tools', { 0: String(count) })}
</span>
{toolNameSummary && (
<span className="text-xs font-normal text-text-secondary"> {toolNameSummary}</span>
)}
<ChevronDown
className={cn(
'size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
<div className="py-0.5 pl-4">
{parts.map(({ part, idx }) => renderPart(part, idx, isLast && idx === lastContentIdx))}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,61 +1,125 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { useState, useMemo } from 'react';
import { ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useLocalize, useExpandCollapse } from '~/hooks';
import UIResourceCarousel from './UIResourceCarousel';
import { useMessagesOperations } from '~/Providers';
import { OutputRenderer } from './ToolOutput';
import { handleUIAction, cn } from '~/utils';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return false;
}
const entries = Object.entries(obj);
if (entries.length === 0 || entries.length > 8) {
return false;
}
return entries.every(
([, v]) =>
v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean',
);
}
function KeyValueInput({ data }: { data: Record<string, string | number | boolean | null> }) {
return (
<div
className="rounded-lg bg-surface-tertiary p-2 text-xs text-text-primary"
style={{
position: 'relative',
maxHeight,
overflow: 'auto',
}}
>
<pre className="m-0 whitespace-pre-wrap break-words" style={{ overflowWrap: 'break-word' }}>
<code>{text}</code>
</pre>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
{Object.entries(data).map(([key, value]) => (
<div key={key} className="flex items-baseline gap-1.5">
<span className="font-medium text-text-secondary">{key}</span>
<span className="rounded bg-surface-tertiary px-1.5 py-0.5 text-text-primary">
{String(value ?? 'null')}
</span>
</div>
))}
</div>
);
}
function formatParamValue(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value.length > 200 ? value.slice(0, 200) + '...' : value;
}
if (typeof value !== 'object') {
return String(value);
}
const str = JSON.stringify(value);
return str.length > 200 ? str.slice(0, 200) + '...' : str;
}
function ComplexInput({ data }: { data: Record<string, unknown> }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
{Object.entries(data).map(([key, value]) => (
<div key={key} className="flex items-baseline gap-1.5">
<span className="font-medium text-text-secondary">{key}</span>
<span className="max-w-[300px] overflow-hidden truncate rounded bg-surface-tertiary px-1.5 py-0.5 font-mono text-text-primary">
{formatParamValue(value)}
</span>
</div>
))}
</div>
);
}
function InputRenderer({ input }: { input: string }) {
if (!input || input.trim().length === 0) {
return null;
}
try {
const parsed = JSON.parse(input);
if (isSimpleObject(parsed)) {
return <KeyValueInput data={parsed} />;
}
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return <ComplexInput data={parsed as Record<string, unknown>} />;
}
// Valid JSON but not a plain object (array, string, number, boolean) — render formatted
return (
<pre className="whitespace-pre-wrap text-xs text-text-primary">
{typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2)}
</pre>
);
} catch {
// Not JSON — render as plain text
return <pre className="whitespace-pre-wrap text-xs text-text-primary">{input}</pre>;
}
}
export default function ToolCallInfo({
input,
output,
domain,
function_name,
pendingAuth,
attachments,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const formatText = (text: string) => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
};
const { ask } = useMessagesOperations();
const [showParams, setShowParams] = useState(false);
const { style: paramsExpandStyle, ref: paramsExpandRef } = useExpandCollapse(showParams);
let title =
domain != null && domain
? localize('com_assistants_domain_info', { 0: domain })
: localize('com_assistants_function_use', { 0: function_name });
if (pendingAuth === true) {
title =
domain != null && domain
? localize('com_assistants_action_attempt', { 0: domain })
: localize('com_assistants_attempt_info');
}
const hasParams = useMemo(() => {
if (!input || input.trim().length === 0) {
return false;
}
try {
const parsed = JSON.parse(input);
if (typeof parsed === 'object' && parsed !== null) {
return Object.keys(parsed).length > 0;
}
} catch {
// Not JSON
}
return input.trim().length > 0;
}, [input]);
const uiResources: UIResource[] =
attachments
@ -65,43 +129,51 @@ export default function ToolCallInfo({
}) ?? [];
return (
<div className="w-full p-2">
<div style={{ opacity: 1 }}>
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
<div>
<OptimizedCodeBlock text={formatText(input)} maxHeight={250} />
</div>
{output && (
<>
<div className="my-2 text-sm font-medium text-text-primary">
{localize('com_ui_result')}
</div>
<div>
<OptimizedCodeBlock text={formatText(output)} maxHeight={250} />
</div>
{uiResources.length > 0 && (
<div className="my-2 text-sm font-medium text-text-primary">
{localize('com_ui_ui_resources')}
</div>
<div className="w-full px-3 py-3.5">
{output && <OutputRenderer text={output} />}
{output && hasParams && <div className="my-2 border-t border-border-light" />}
{hasParams && (
<>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 text-xs text-text-secondary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
<div>
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}
{uiResources.length === 1 && (
<UIResourceRenderer
resource={uiResources[0]}
onUIAction={async (result) => {
console.log('Action:', result);
}}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}
/>
onClick={() => setShowParams((prev) => !prev)}
aria-expanded={showParams}
>
<span>{localize('com_ui_parameters')}</span>
<ChevronDown
className={cn(
'size-3 shrink-0 transition-transform duration-200 ease-out',
showParams && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
<div style={paramsExpandStyle}>
<div className="overflow-hidden pt-1" ref={paramsExpandRef}>
<InputRenderer input={input} />
</div>
</>
)}
</div>
</div>
</>
)}
{uiResources.length > 0 && (
<>
{(hasParams || output) && <div className="my-2 border-t border-border-light" />}
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}
{uiResources.length === 1 && (
<UIResourceRenderer
resource={uiResources[0]}
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}
/>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,168 @@
import { useState, useMemo, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ContentBlock {
type?: string;
text?: string;
}
const ERROR_PREFIX = /^Error:\s*(\[.*?\]\s*)*tool call failed:\s*/i;
const ERROR_INNER = /^Error\s+\w+ing to endpoint\s*\(HTTP \d+\):\s*/i;
function cleanError(text: string): string {
let cleaned = text.replace(ERROR_PREFIX, '').trim();
cleaned = cleaned.replace(ERROR_INNER, '').trim();
if (cleaned.endsWith('Please fix your mistakes.')) {
cleaned = cleaned.slice(0, -'Please fix your mistakes.'.length).trim();
}
return cleaned;
}
export function isError(text: string): boolean {
return ERROR_PREFIX.test(text) || text.startsWith('Error processing tool');
}
function isStructuredText(text: string): boolean {
return text.includes('\n') || text.includes('{') || text.includes(':');
}
interface ExtractedText {
text: string;
rawError: string;
error: boolean;
/** When true, `text` contains raw JSON that should be rendered as a highlighted code block. */
isJson: boolean;
}
function extractText(raw: string): ExtractedText {
const trimmed = raw.trim();
if (!trimmed) {
return { text: '', rawError: '', error: false, isJson: false };
}
if (isError(trimmed)) {
return { text: cleanError(trimmed), rawError: trimmed, error: true, isJson: false };
}
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
const textBlocks = parsed.filter(
(b: ContentBlock) => typeof b === 'object' && b !== null && typeof b.text === 'string',
);
if (textBlocks.length > 0) {
const joined = (textBlocks as ContentBlock[])
.map((b) => b.text)
.join('\n')
.trim();
if (isError(joined)) {
return { text: cleanError(joined), rawError: joined, error: true, isJson: false };
}
return { text: joined, rawError: '', error: false, isJson: false };
}
}
// Render structured JSON as a highlighted code block
return {
text: JSON.stringify(parsed, null, 2),
rawError: '',
error: false,
isJson: true,
};
} catch {
// Not JSON
}
}
return { text: trimmed, rawError: '', error: false, isJson: false };
}
const TRUNCATE_LINES = 20;
const VISIBLE_LINES = 15;
interface OutputRendererProps {
text: string;
}
export default function OutputRenderer({ text }: OutputRendererProps) {
const localize = useLocalize();
const { text: displayText, rawError, error, isJson } = useMemo(() => extractText(text), [text]);
const [isExpanded, setIsExpanded] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const handleCopy = useCallback(() => {
setIsCopied(true);
copy(displayText, { format: 'text/plain' });
setTimeout(() => setIsCopied(false), 3000);
}, [displayText]);
if (!displayText) {
return null;
}
const lines = displayText.split('\n');
const needsTruncation = lines.length > TRUNCATE_LINES;
const visibleText =
needsTruncation && !isExpanded ? lines.slice(0, VISIBLE_LINES).join('\n') : displayText;
const structured = !isJson && isStructuredText(displayText);
return (
<div className="relative">
{isJson ? (
<pre className="max-h-[300px] overflow-auto rounded text-xs">
<code className="hljs language-json !whitespace-pre-wrap !break-words">
{visibleText}
</code>
</pre>
) : (
<pre
className={cn(
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words text-xs',
error && 'font-mono text-red-600 dark:text-red-400',
!error && structured && 'font-mono text-text-secondary',
!error && !structured && 'font-sans text-sm text-text-primary',
)}
>
{visibleText}
</pre>
)}
<div className="absolute bottom-0 right-0">
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
iconOnly
label={localize('com_ui_copy')}
/>
</div>
{needsTruncation && (
<button
type="button"
className="mt-1 text-xs text-text-secondary underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? localize('com_ui_show_less') : localize('com_ui_show_more')}
</button>
)}
{error && rawError && rawError !== displayText && (
<button
type="button"
className="mt-1 block text-xs text-text-secondary underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
onClick={() => setShowErrorDetails((prev) => !prev)}
>
{localize('com_ui_details')}
</button>
)}
{showErrorDetails && rawError && (
<pre className="mt-2 max-h-[200px] overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-red-600 dark:text-red-400">
{rawError}
</pre>
)}
</div>
);
}

View file

@ -0,0 +1,84 @@
import { useMemo } from 'react';
import ToolIcon, { getToolIconType, getMCPServerName } from './ToolIcon';
import type { ToolIconType } from './ToolIcon';
import { cn } from '~/utils';
interface ResolvedIcon {
key: string;
type: ToolIconType;
iconUrl?: string;
}
interface StackedToolIconsProps {
toolNames: string[];
mcpIconMap?: Map<string, string>;
maxIcons?: number;
isAnimating?: boolean;
}
export default function StackedToolIcons({
toolNames,
mcpIconMap,
maxIcons = 3,
isAnimating = false,
}: StackedToolIconsProps) {
const uniqueIcons = useMemo(() => {
const seen = new Set<string>();
const result: ResolvedIcon[] = [];
for (const name of toolNames) {
const type = getToolIconType(name);
const serverName = getMCPServerName(name);
const iconUrl = serverName ? mcpIconMap?.get(serverName) : undefined;
const key = iconUrl ? `mcp-${serverName}` : type;
if (!seen.has(key)) {
seen.add(key);
result.push({ key, type, iconUrl });
}
}
return result;
}, [toolNames, mcpIconMap]);
const visibleIcons = uniqueIcons.slice(0, maxIcons);
const overflowCount = uniqueIcons.length - visibleIcons.length;
if (visibleIcons.length <= 1) {
const icon = visibleIcons[0];
return (
<ToolIcon type={icon?.type ?? 'generic'} iconUrl={icon?.iconUrl} isAnimating={isAnimating} />
);
}
return (
<div className="flex items-center" aria-hidden="true">
{visibleIcons.map((icon, index) => (
<div
key={icon.key}
className={cn(
'relative flex items-center justify-center rounded-full border border-border-medium bg-surface-secondary',
'h-[22px] w-[22px]',
index > 0 && '-ml-2.5',
)}
style={{ zIndex: visibleIcons.length - index }}
>
<ToolIcon
type={icon.type}
iconUrl={icon.iconUrl}
isAnimating={isAnimating}
className="size-3"
/>
</div>
))}
{overflowCount > 0 && (
<div
className={cn(
'relative flex items-center justify-center rounded-full border border-border-medium bg-surface-tertiary',
'-ml-2.5 h-[22px] w-[22px] text-xs font-medium text-text-secondary',
)}
style={{ zIndex: 0 }}
>
+{overflowCount}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
import { Constants, actionDelimiter } from 'librechat-data-provider';
import { Terminal, Globe, ImageIcon, ArrowRightLeft, FileSearch, Zap, Wrench } from 'lucide-react';
import { cn } from '~/utils';
export type ToolIconType =
| 'mcp'
| 'execute_code'
| 'web_search'
| 'image_gen'
| 'agent_handoff'
| 'file_search'
| 'action'
| 'generic';
const ICON_MAP: Record<ToolIconType, React.ComponentType<{ className?: string }>> = {
mcp: Wrench,
execute_code: Terminal,
web_search: Globe,
image_gen: ImageIcon,
agent_handoff: ArrowRightLeft,
file_search: FileSearch,
action: Zap,
generic: Wrench,
};
export function getToolIconType(name: string): ToolIconType {
if (!name) {
return 'generic';
}
if (name.includes(Constants.mcp_delimiter)) {
return 'mcp';
}
if (name === 'execute_code' || name === Constants.PROGRAMMATIC_TOOL_CALLING) {
return 'execute_code';
}
if (name === 'web_search') {
return 'web_search';
}
if (name === 'image_gen_oai' || name === 'image_edit_oai' || name === 'gemini_image_gen') {
return 'image_gen';
}
if (name === 'file_search' || name === 'retrieval') {
return 'file_search';
}
if (name === 'code_interpreter') {
return 'execute_code';
}
if (name.startsWith(Constants.LC_TRANSFER_TO_)) {
return 'agent_handoff';
}
if (name.includes(actionDelimiter)) {
return 'action';
}
return 'generic';
}
/** Extracts the MCP server name from a tool name with format `tool<delimiter>server`. */
export function getMCPServerName(toolName: string): string {
const idx = toolName.indexOf(Constants.mcp_delimiter);
if (idx < 0) {
return '';
}
const afterDelimiter = toolName.slice(idx + Constants.mcp_delimiter.length);
return afterDelimiter || '';
}
interface ToolIconProps {
type: ToolIconType;
iconUrl?: string;
isAnimating?: boolean;
className?: string;
}
export default function ToolIcon({ type, iconUrl, isAnimating = false, className }: ToolIconProps) {
if (iconUrl) {
return (
<img
src={iconUrl}
alt=""
className={cn(
'size-4 shrink-0 rounded-full object-cover',
isAnimating && 'animate-pulse',
className,
)}
aria-hidden="true"
/>
);
}
const IconComponent = ICON_MAP[type];
return (
<IconComponent
className={cn(
'size-4 shrink-0 text-text-secondary',
isAnimating && 'animate-pulse',
className,
)}
aria-hidden="true"
/>
);
}

View file

@ -0,0 +1,4 @@
export type { ToolIconType } from './ToolIcon';
export { default as StackedToolIcons } from './StackedToolIcons';
export { default as OutputRenderer, isError } from './OutputRenderer';
export { default as ToolIcon, getToolIconType, getMCPServerName } from './ToolIcon';

View file

@ -109,11 +109,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
>
<div className="flex h-full flex-col">
<UIResourceRenderer
resource={{
uri: uiResource.uri,
mimeType: uiResource.mimeType,
text: uiResource.text,
}}
resource={uiResource}
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },

View file

@ -1,9 +1,14 @@
import { useMemo } from 'react';
import type { TAttachment } from 'librechat-data-provider';
import { useMemo, useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { Globe, ChevronDown } from 'lucide-react';
import type { TAttachment, ValidSource, SearchResultData } from 'librechat-data-provider';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import { StackedFavicons } from '~/components/Web/Sources';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { useSearchContext } from '~/Providers';
import ProgressText from './ProgressText';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
import store from '~/store';
type ProgressKeys =
| 'com_ui_web_searching'
@ -11,11 +16,72 @@ type ProgressKeys =
| 'com_ui_web_search_processing'
| 'com_ui_web_search_reading';
const MAX_VISIBLE_FAVICONS = 3;
function collectSources(results: Record<string, SearchResultData>): ValidSource[] {
const sourceMap = new Map<string, ValidSource>();
for (const result of Object.values(results)) {
if (!result) {
continue;
}
result.organic?.forEach((s) => {
if (s.link) {
sourceMap.set(s.link, s);
}
});
result.topStories?.forEach((s) => {
if (s.link) {
sourceMap.set(s.link, s);
}
});
}
return Array.from(sourceMap.values());
}
function getUniqueDomainSources(sources: ValidSource[], max: number): ValidSource[] {
const seen = new Set<string>();
const result: ValidSource[] = [];
for (const source of sources) {
const domain = getCleanDomain(source.link);
if (seen.has(domain)) {
continue;
}
seen.add(domain);
result.push(source);
if (result.length >= max) {
break;
}
}
return result;
}
function SourceFaviconStack({ sources }: { sources: ValidSource[] }) {
const visible = getUniqueDomainSources(sources, MAX_VISIBLE_FAVICONS);
return (
<div className="flex items-center" aria-hidden="true">
{visible.map((source, i) => (
<div
key={source.link}
className={cn(
'relative flex items-center justify-center rounded-full border border-border-medium bg-surface-secondary',
'h-[22px] w-[22px]',
i > 0 && '-ml-2.5',
)}
style={{ zIndex: MAX_VISIBLE_FAVICONS - i }}
>
<FaviconImage domain={getCleanDomain(source.link)} className="size-3 rounded-full" />
</div>
))}
</div>
);
}
export default function WebSearch({
initialProgress: progress = 0.1,
isSubmitting,
isLast,
output,
attachments,
}: {
isLast?: boolean;
isSubmitting: boolean;
@ -30,14 +96,39 @@ export default function WebSearch({
const complete = !isLast && progress === 1;
const finalizing = isSubmitting && isLast && progress === 1;
const allSources = useMemo((): ValidSource[] => {
if (searchResults && Object.keys(searchResults).length > 0) {
return collectSources(searchResults);
}
if (attachments) {
const turnMap: Record<string, SearchResultData> = {};
for (const att of attachments) {
if (att.type === Tools.web_search && att[Tools.web_search]) {
const data = att[Tools.web_search];
const key = typeof data.turn === 'number' ? String(data.turn) : '0';
turnMap[key] = data;
}
}
if (Object.keys(turnMap).length > 0) {
return collectSources(turnMap);
}
}
return [];
}, [searchResults, attachments]);
const processedSources = useMemo(() => {
if (complete && !finalizing) {
return [];
}
if (!searchResults) return [];
if (!searchResults) {
return [];
}
const values = Object.values(searchResults);
const result = values[values.length - 1];
if (!result) return [];
if (!result) {
return [];
}
if (finalizing) {
return [...(result.organic || []), ...(result.topStories || [])];
}
@ -45,18 +136,23 @@ export default function WebSearch({
(source) => source.processed === true,
);
}, [searchResults, complete, finalizing]);
const turns = useMemo(() => {
if (!searchResults) return 0;
return Object.values(searchResults).length;
}, [searchResults]);
const clampedProgress = useMemo(() => {
return Math.min(progress, 0.99);
}, [progress]);
const ownTurn = useMemo(() => {
if (!attachments) {
return 0;
}
for (const att of attachments) {
if (att.type === Tools.web_search && att[Tools.web_search]) {
const turn = att[Tools.web_search].turn;
return typeof turn === 'number' ? turn : 0;
}
}
return 0;
}, [attachments]);
const showSources = processedSources.length > 0;
const progressText = useMemo(() => {
let text: ProgressKeys = turns > 1 ? 'com_ui_web_searching_again' : 'com_ui_web_searching';
let text: ProgressKeys = ownTurn > 0 ? 'com_ui_web_searching_again' : 'com_ui_web_searching';
if (showSources) {
text = 'com_ui_web_search_processing';
}
@ -64,28 +160,108 @@ export default function WebSearch({
text = 'com_ui_web_search_reading';
}
return localize(text);
}, [turns, localize, showSources, finalizing]);
}, [ownTurn, localize, showSources, finalizing]);
if (complete || cancelled) {
const autoExpand = useRecoilValue(store.autoExpandTools);
const sourceCount = allSources.length;
const [showSourceList, setShowSourceList] = useState(() => autoExpand && sourceCount > 0);
const { style: sourceExpandStyle, ref: sourceExpandRef } = useExpandCollapse(showSourceList);
useEffect(() => {
if (autoExpand && sourceCount > 0) {
setShowSourceList(true);
}
}, [autoExpand, sourceCount]);
if (cancelled) {
return null;
}
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
{showSources && (
<div className="mr-2">
<StackedFavicons sources={processedSources} start={-5} />
if (complete) {
const hasSourceData = sourceCount > 0;
const completedText = localize('com_ui_web_searched');
return (
<div className="mb-2">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{completedText}
</span>
<button
type="button"
className={cn(
'tool-status-text group flex items-center gap-2 rounded-full py-1 transition-colors',
hasSourceData
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none text-text-secondary',
)}
disabled={!hasSourceData}
onClick={hasSourceData ? () => setShowSourceList((prev) => !prev) : undefined}
aria-expanded={hasSourceData ? showSourceList : undefined}
aria-label={
hasSourceData
? `${completedText} - ${localize(sourceCount === 1 ? 'com_ui_web_search_source' : 'com_ui_web_search_sources', { count: sourceCount })}`
: completedText
}
>
{hasSourceData ? (
<SourceFaviconStack sources={allSources} />
) : (
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<span className="font-medium">{completedText}</span>
{hasSourceData && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-text-secondary transition-transform duration-200',
showSourceList && 'rotate-180',
)}
aria-hidden="true"
/>
)}
</button>
{hasSourceData && (
<div style={sourceExpandStyle}>
<div className="overflow-hidden" ref={sourceExpandRef}>
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
i > 0 && 'border-t border-border-light',
)}
>
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
</a>
);
})}
</div>
</div>
</div>
)}
<ProgressText
finishedText=""
hasInput={false}
error={cancelled}
isExpanded={false}
progress={clampedProgress}
inProgressText={progressText}
/>
</div>
</>
);
}
return (
<div className="my-1 flex items-center gap-2.5">
<span className="sr-only" aria-live="polite" aria-atomic="true">
{progressText}
</span>
{showSources && <StackedFavicons sources={processedSources} start={-5} />}
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="tool-status-text shimmer font-medium text-text-secondary">
{progressText}
</span>
</div>
);
}

View file

@ -0,0 +1,97 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import AgentHandoff from '../AgentHandoff';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_ui_transferred_to: 'Transferred to',
com_ui_agent: 'Agent',
com_ui_handoff_instructions: 'Handoff instructions',
};
return translations[key] || key;
},
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
},
ref: { current: null },
}),
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: () => ({
'agent-123': { name: 'Test Agent' },
}),
}));
jest.mock('~/components/Share/MessageIcon', () => ({
__esModule: true,
default: () => <div data-testid="message-icon" />,
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down" />,
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(' '),
}));
const renderAgentHandoff = (props: {
name: string;
args: string | Record<string, unknown>;
output?: string | null;
}) =>
render(
<RecoilRoot>
<AgentHandoff {...props} />
</RecoilRoot>,
);
describe('AgentHandoff - A11Y accessibility stubs', () => {
it('A11Y-01: renders a semantic button element when hasInfo is true', () => {
renderAgentHandoff({
name: 'lc_transfer_to_agent-123',
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
expect(button.tagName).toBe('BUTTON');
});
it('A11Y-02: button has aria-label describing the handoff target', () => {
renderAgentHandoff({
name: 'lc_transfer_to_agent-123',
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toContain('Transferred to');
expect(button.getAttribute('aria-label')).toContain('Test Agent');
});
it('A11Y-03: button has focus-visible ring classes', () => {
renderAgentHandoff({
name: 'lc_transfer_to_agent-123',
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
expect(button.className).toContain('focus-visible:ring-2');
});
it('A11Y-03: disabled button is rendered when hasInfo is false', () => {
renderAgentHandoff({
name: 'lc_transfer_to_agent-123',
args: '',
});
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});

View file

@ -0,0 +1,215 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import ImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_ui_generating_image: 'Generating image...',
com_ui_image_created: 'Image created',
com_ui_image_gen_failed: 'Image generation failed',
com_ui_getting_started: 'Getting started',
com_ui_creating_image: 'Creating image',
com_ui_adding_details: 'Adding details',
com_ui_final_touch: 'Final touch',
com_ui_error: 'Error',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
const getProgressLabel = (progress: number) =>
progress >= 1 ? 'Image created' : 'Generating image...';
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
__esModule: true,
default: ({
toolName,
progress,
error,
onClick,
hasInput,
isExpanded,
}: {
toolName: string;
progress: number;
error?: boolean;
onClick?: () => void;
hasInput?: boolean;
isExpanded?: boolean;
}) => (
<button
data-testid="progress-text"
data-tool-name={toolName}
data-progress={progress}
data-error={error}
data-has-input={hasInput}
data-is-expanded={isExpanded}
onClick={onClick}
type="button"
>
{error ? 'Error' : getProgressLabel(progress)}
</button>
),
}));
jest.mock('@librechat/client', () => ({
PixelCard: (props: Record<string, unknown>) => (
<div data-testid="pixel-card" data-progress={props.progress} />
),
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: () => <div data-testid="image" />,
}));
jest.mock('../ToolOutput', () => ({
ToolIcon: ({ type, isAnimating }: { type: string; isAnimating?: boolean }) => (
<span data-testid="tool-icon" data-type={type} data-animating={isAnimating} />
),
isError: (output: string) => typeof output === 'string' && output.toLowerCase().includes('error'),
}));
jest.mock('~/utils', () => ({
scaleImage: () => ({ width: '512px', height: '512px' }),
logger: { error: jest.fn(), debug: jest.fn() },
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(' '),
}));
const defaultProps = {
initialProgress: 1,
isSubmitting: false,
toolName: 'image_gen_oai',
args: '{}',
output: '',
};
const renderImageGen = (props: Partial<typeof defaultProps> = {}) =>
render(
<RecoilRoot>
<ImageGen {...defaultProps} {...props} />
</RecoilRoot>,
);
describe('ImageGen - LGCY-01: Modern visual patterns', () => {
it('renders ToolIcon with type="image_gen" for agent-style tools', () => {
renderImageGen({
toolName: 'image_gen_oai',
isSubmitting: false,
initialProgress: 1,
args: '{}',
output: '',
});
const icon = screen.queryByTestId('tool-icon');
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute('data-type', 'image_gen');
});
it('renders ToolIcon with type="image_gen" for legacy tools', () => {
renderImageGen({
initialProgress: 1,
args: '{"prompt":"test"}',
});
const icon = screen.queryByTestId('tool-icon');
expect(icon).toBeInTheDocument();
});
it('does not render ProgressCircle', () => {
renderImageGen();
expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument();
expect(document.querySelector('.progress-circle')).toBeNull();
});
it('renders PixelCard during agent-style image generation', () => {
renderImageGen({
isSubmitting: true,
initialProgress: 0.5,
toolName: 'image_gen_oai',
args: '{"size":"1024x1024"}',
});
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
});
});
describe('ImageGen - LGCY-01: Legacy and agent-style unification', () => {
it('accepts legacy props (initialProgress + args only)', () => {
const { container } = render(
<RecoilRoot>
<ImageGen
initialProgress={1}
isSubmitting={false}
toolName=""
args='{"prompt":"a cat"}'
output=""
/>
</RecoilRoot>,
);
expect(container).toBeTruthy();
});
it('accepts agent-style props (full set)', () => {
const { container } = renderImageGen({
initialProgress: 1,
isSubmitting: false,
toolName: 'image_gen_oai',
args: '{"prompt":"a cat","size":"1024x1024"}',
output: 'https://example.com/image.png',
});
expect(container).toBeTruthy();
});
it('handles history reload state (initialProgress=1, no isSubmitting)', () => {
renderImageGen({
initialProgress: 1,
isSubmitting: false,
});
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveTextContent('Image created');
});
});
describe('ImageGen - LGCY-03: Localization', () => {
it('does not contain hardcoded "Creating Image" text', () => {
const { container } = renderImageGen({
isSubmitting: true,
initialProgress: 0.5,
args: '{}',
});
expect(container.textContent).not.toContain('Creating Image');
});
it('does not contain hardcoded "Finished." text', () => {
const { container } = renderImageGen({
initialProgress: 1,
isSubmitting: false,
});
expect(container.textContent).not.toContain('Finished.');
});
});
describe('ImageGen - A11Y-04: screen reader status announcements', () => {
it('includes sr-only aria-live region for status announcements', () => {
renderImageGen({
initialProgress: 1,
isSubmitting: false,
args: '{"prompt":"test"}',
output: '',
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).not.toBeNull();
expect(liveRegion!.className).toContain('sr-only');
});
});

View file

@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
jest.mock('~/utils', () => ({
scaleImage: () => ({ width: '512px', height: '512px' }),
cn: (...classes: (string | boolean | undefined | null)[]) =>
classes
.flat(Infinity)
@ -12,25 +13,13 @@ jest.mock('~/utils', () => ({
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: ({
altText,
imagePath,
className,
}: {
altText: string;
imagePath: string;
className?: string;
}) => (
<div
data-testid="image-component"
data-alt={altText}
data-src={imagePath}
className={className}
/>
default: ({ altText, imagePath }: { altText: string; imagePath: string }) => (
<div data-testid="image-component" data-alt={altText} data-src={imagePath} />
),
}));
@ -40,6 +29,13 @@ jest.mock('@librechat/client', () => ({
),
}));
jest.mock('../ToolOutput', () => ({
ToolIcon: ({ type, isAnimating }: { type: string; isAnimating?: boolean }) => (
<span data-testid="tool-icon" data-type={type} data-animating={isAnimating} />
),
isError: (output: string) => typeof output === 'string' && output.toLowerCase().includes('error'),
}));
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
__esModule: true,
default: ({ progress, error }: { progress: number; error: boolean }) => (
@ -72,14 +68,7 @@ describe('OpenAIImageGen', () => {
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
it('hides Image with invisible absolute while progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const image = screen.getByTestId('image-component');
expect(image.className).toContain('invisible');
expect(image.className).toContain('absolute');
});
it('shows Image without hiding classes when progress >= 1', () => {
it('shows Image when progress >= 1', () => {
render(
<OpenAIImageGen
{...defaultProps}
@ -94,9 +83,7 @@ describe('OpenAIImageGen', () => {
]}
/>,
);
const image = screen.getByTestId('image-component');
expect(image.className).not.toContain('invisible');
expect(image.className).not.toContain('absolute');
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
});
@ -112,26 +99,23 @@ describe('OpenAIImageGen', () => {
});
});
describe('layout classes', () => {
it('applies max-h-[45vh] to the outer container', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} />);
const outerDiv = container.querySelector('[class*="max-h-"]');
expect(outerDiv?.className).toContain('max-h-[45vh]');
describe('ToolIcon', () => {
it('renders ToolIcon with type="image_gen"', () => {
render(<OpenAIImageGen {...defaultProps} />);
const icon = screen.getByTestId('tool-icon');
expect(icon).toHaveAttribute('data-type', 'image_gen');
});
it('applies h-[45vh] w-full to inner container during loading', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
expect(innerDiv).not.toBeNull();
expect(innerDiv?.className).toContain('w-full');
it('sets isAnimating when in progress', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const icon = screen.getByTestId('tool-icon');
expect(icon).toHaveAttribute('data-animating', 'true');
});
it('applies w-auto to inner container when complete', () => {
const { container } = render(
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
);
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
expect(overflowDiv?.className).toContain('w-auto');
it('sets isAnimating=false when complete', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
const icon = screen.getByTestId('tool-icon');
expect(icon).toHaveAttribute('data-animating', 'false');
});
});
@ -142,10 +126,8 @@ describe('OpenAIImageGen', () => {
});
it('handles invalid JSON args gracefully', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('handles object args', () => {

View file

@ -0,0 +1,276 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen, fireEvent } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import RetrievalCall from '../RetrievalCall';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_ui_searching_files: 'Searching files...',
com_ui_retrieved_files: 'Retrieved files',
com_ui_retrieval_failed: 'failed',
com_ui_tool_failed: 'failed',
com_ui_preview: 'Preview',
com_ui_relevance: 'Relevance',
com_file_pages: 'Pages',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
useExpandCollapse: (isExpanded: boolean) => ({
style: isExpanded
? { display: 'grid', gridTemplateRows: '1fr', opacity: 1 }
: { display: 'grid', gridTemplateRows: '0fr', opacity: 0 },
ref: { current: null },
}),
}));
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({
onClick,
inProgressText,
finishedText,
icon,
hasInput,
isExpanded,
error,
errorSuffix,
}: {
onClick?: () => void;
inProgressText: string;
finishedText: string;
icon?: React.ReactNode;
hasInput?: boolean;
isExpanded?: boolean;
error?: boolean;
errorSuffix?: string;
}) => (
<div
onClick={onClick}
data-testid="progress-text"
data-in-progress={inProgressText}
data-finished={finishedText}
data-has-input={hasInput}
data-expanded={isExpanded}
data-error={error}
data-error-suffix={errorSuffix}
>
{icon}
{finishedText || inProgressText}
</div>
),
}));
jest.mock('../ToolOutput', () => ({
ToolIcon: ({ type, isAnimating }: { type: string; isAnimating?: boolean }) => (
<span data-testid="tool-icon" data-type={type} data-animating={isAnimating} />
),
OutputRenderer: ({ text }: { text: string }) => <pre data-testid="output-renderer">{text}</pre>,
isError: (output: string) => typeof output === 'string' && output.toLowerCase().includes('error'),
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(' '),
logger: { error: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/data-provider', () => ({
useGetFiles: jest.fn(() => ({ data: [] })),
}));
jest.mock('../FilePreviewDialog', () => ({
__esModule: true,
default: ({ open, fileId, fileName }: { open: boolean; fileId?: string; fileName: string }) =>
open ? (
<div data-testid="file-preview-dialog" data-file-id={fileId}>
{fileName}
</div>
) : null,
}));
const defaultProps = {
initialProgress: 1,
isSubmitting: false,
};
const renderRetrievalCall = (
props: Partial<typeof defaultProps & { output?: string; attachments?: TAttachment[] }> = {},
) =>
render(
<RecoilRoot>
<RetrievalCall {...defaultProps} {...props} />
</RecoilRoot>,
);
const mockedUseGetFiles = jest.requireMock('~/data-provider').useGetFiles as jest.Mock;
beforeEach(() => {
mockedUseGetFiles.mockReturnValue({ data: [] });
});
describe('RetrievalCall - LGCY-02: Modern visual patterns', () => {
it('renders ToolIcon with type="file_search"', () => {
renderRetrievalCall({ initialProgress: 1, isSubmitting: false });
const icon = screen.getByTestId('tool-icon');
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute('data-type', 'file_search');
});
it('does not render ProgressCircle', () => {
renderRetrievalCall();
expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument();
});
it('does not render InProgressCall', () => {
renderRetrievalCall();
expect(screen.queryByTestId('in-progress-call')).not.toBeInTheDocument();
});
it('does not render RetrievalIcon', () => {
renderRetrievalCall();
expect(screen.queryByTestId('retrieval-icon')).not.toBeInTheDocument();
});
});
describe('RetrievalCall - LGCY-02: Collapsible output panel', () => {
it('shows collapsible panel when output exists and is clicked', () => {
renderRetrievalCall({
output: 'File: notes.txt\nRelevance: 0.8\nContent: file results here',
initialProgress: 1,
isSubmitting: false,
});
const progressText = screen.getByTestId('progress-text');
fireEvent.click(progressText);
expect(screen.getByText('file results here')).toBeInTheDocument();
});
it('does not show panel when output is undefined', () => {
renderRetrievalCall({ initialProgress: 1, isSubmitting: false });
expect(screen.queryByText('file results here')).not.toBeInTheDocument();
});
it('does not show panel when output contains error', () => {
renderRetrievalCall({
output: 'error processing tool',
initialProgress: 1,
isSubmitting: false,
});
expect(screen.queryByText('error processing tool')).not.toBeInTheDocument();
});
});
describe('RetrievalCall - LGCY-03: Localization', () => {
it('uses localized in-progress text', () => {
renderRetrievalCall({ initialProgress: 0.5, isSubmitting: true });
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-in-progress', 'Searching files...');
});
it('uses localized finished text', () => {
renderRetrievalCall({ initialProgress: 1, isSubmitting: false });
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-finished', 'Retrieved files');
});
it('does not contain hardcoded "Searching my knowledge" text', () => {
const { container } = renderRetrievalCall({ initialProgress: 0.5, isSubmitting: true });
expect(container.textContent).not.toContain('Searching my knowledge');
});
it('does not contain hardcoded "Used Retrieval" text', () => {
const { container } = renderRetrievalCall({ initialProgress: 1, isSubmitting: false });
expect(container.textContent).not.toContain('Used Retrieval');
});
});
describe('RetrievalCall - A11Y-04: screen reader status announcements', () => {
it('includes sr-only aria-live region for status announcements', () => {
renderRetrievalCall({
initialProgress: 1,
isSubmitting: false,
output: 'files found',
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).not.toBeNull();
expect(liveRegion!.className).toContain('sr-only');
});
});
describe('RetrievalCall - file preview resolution', () => {
it('resolves parsed filenames against known files and opens preview', () => {
mockedUseGetFiles.mockReturnValue({
data: [
{
file_id: 'file-123',
filename: 'Tutorial Imazing.pdf',
bytes: 2048,
type: 'application/pdf',
},
],
});
renderRetrievalCall({
initialProgress: 1,
isSubmitting: false,
output:
'File: Tutorial_Imazing.pdf\nRelevance: 0.4442\nContent: Example content from parsed output',
});
fireEvent.click(screen.getByTestId('progress-text'));
fireEvent.click(screen.getByRole('button', { name: 'Preview: Tutorial Imazing.pdf' }));
expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-id', 'file-123');
});
it('keeps multiple parsed results clickable when only one attachment source is available', () => {
renderRetrievalCall({
initialProgress: 1,
isSubmitting: false,
output:
'File: Tutorial_Imazing.pdf\nRelevance: 0.4843\nContent: First result\n---\nFile: Tutorial_Imazing.pdf\nRelevance: 0.3751\nContent: Second result',
attachments: [
{
type: 'file_search',
toolCallId: 'call-1',
file_search: {
sources: [
{
fileId: 'file-123',
fileName: 'Tutorial Imazing.pdf',
relevance: 0.4843,
content: 'First result',
pages: [1],
pageRelevance: { 1: 0.4843 },
metadata: {
fileType: 'application/pdf',
fileBytes: 2048,
},
},
],
},
},
] as any,
});
fireEvent.click(screen.getByTestId('progress-text'));
expect(screen.getAllByRole('button', { name: 'Preview: Tutorial Imazing.pdf' })).toHaveLength(
2,
);
});
});

View file

@ -1,7 +1,7 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { render, screen, fireEvent } from '@testing-library/react';
import ToolCall from '../ToolCall';
// Mock dependencies
@ -17,10 +17,24 @@ jest.mock('~/hooks', () => ({
com_ui_cancelled: 'Cancelled',
com_ui_requires_auth: 'Requires authentication',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
com_ui_via_server: `via ${values?.[0]}`,
com_ui_tool_failed: 'failed',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
},
ref: { current: null },
}),
}));
jest.mock('~/hooks/MCP', () => ({
useMCPIconMap: () => new Map(),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
@ -40,7 +54,9 @@ jest.mock('../ToolCallInfo', () => ({
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
<div onClick={onClick}>{finishedText || inProgressText}</div>
<div data-testid="progress-text" onClick={onClick}>
{finishedText || inProgressText}
</div>
),
}));
@ -50,7 +66,7 @@ jest.mock('../Parts', () => ({
),
}));
jest.mock('~/components/ui', () => ({
jest.mock('@librechat/client', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
@ -102,9 +118,9 @@ describe('ToolCall', () => {
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments as any} />);
fireEvent.click(screen.getByText('Completed testFunction'));
fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
@ -116,7 +132,7 @@ describe('ToolCall', () => {
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
@ -145,9 +161,9 @@ describe('ToolCall', () => {
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments as any} />);
fireEvent.click(screen.getByText('Completed testFunction'));
fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
@ -169,7 +185,7 @@ describe('ToolCall', () => {
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments as any} />);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(attachmentGroup).toBeInTheDocument();
@ -190,54 +206,30 @@ describe('ToolCall', () => {
});
describe('tool call info visibility', () => {
it('should toggle tool call info when clicking header', () => {
it('should toggle tool call info expand/collapse when clicking header', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
// Initially closed
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
// ToolCallInfo is always in the DOM (CSS expand/collapse), but initially collapsed
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText('Completed testFunction'));
// The expand wrapper starts collapsed (showInfo=false, autoExpand=false)
const expandWrapper = toolCallInfo.closest('[style]')?.parentElement;
expect(expandWrapper).toBeDefined();
// Click to expand
fireEvent.click(screen.getByTestId('progress-text'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
// Click to close
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
});
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
];
// Use a name with domain separator (_action_) and domain separator (---)
const propsWithDomain = {
...mockProps,
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
attachments,
};
renderWithRecoil(<ToolCall {...propsWithDomain} />);
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
it('should pass input and output props to ToolCallInfo', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('{"test": "input"}');
expect(props.output).toBe('Test output');
expect(props.function_name).toBe('testFunction');
// Domain is extracted from name and --- are replaced with dots
expect(props.domain).toBe('test.domain.com');
expect(props.pendingAuth).toBe(false);
});
});
@ -268,35 +260,17 @@ describe('ToolCall', () => {
window.open = originalOpen;
});
it('should pass pendingAuth as true when auth is pending', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com" // Need auth URL to extract domain
initialProgress={0.5} // Less than 1
isSubmitting={true} // Still submitting
/>,
);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.pendingAuth).toBe(true);
});
it('should not show auth section when cancelled', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
initialProgress={0.5}
isSubmitting={false} // Not submitting + progress < 1 = cancelled
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
expect(screen.queryByText('Sign in to auth.example.com')).not.toBeInTheDocument();
});
it('should not show auth section when progress is complete', () => {
@ -304,21 +278,18 @@ describe('ToolCall', () => {
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
initialProgress={1}
isSubmitting={false}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
expect(screen.queryByText('Sign in to auth.example.com')).not.toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
renderWithRecoil(<ToolCall {...mockProps} args={undefined as any} />);
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
@ -328,21 +299,16 @@ describe('ToolCall', () => {
it('should handle null output', () => {
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.output).toBeNull();
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
it('should handle simple function name without domain', () => {
renderWithRecoil(<ToolCall {...mockProps} name="simpleName" />);
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.domain).toBe('');
expect(toolCallInfo).toBeInTheDocument();
});
it('should handle complex nested attachments', () => {
@ -367,9 +333,9 @@ describe('ToolCall', () => {
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments as any} />);
fireEvent.click(screen.getByText('Completed testFunction'));
fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
@ -379,4 +345,22 @@ describe('ToolCall', () => {
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
});
});
describe('A11Y-04: screen reader status announcements', () => {
it('includes sr-only aria-live region for status announcements', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
initialProgress={1}
isSubmitting={false}
name="test_func"
output="result"
/>,
);
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).not.toBeNull();
expect(liveRegion!.className).toContain('sr-only');
});
});
});

View file

@ -1,24 +1,33 @@
import React from 'react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
// Mock the dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_assistants_domain_info: `Used ${values?.[0]}`,
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_action_attempt: `Attempted to use ${values?.[0]}`,
com_assistants_attempt_info: 'Attempted to use function',
com_ui_result: 'Result',
com_ui_ui_resources: 'UI Resources',
com_ui_parameters: 'Parameters',
};
return translations[key] || key;
},
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
},
ref: { current: null },
}),
}));
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
ask: jest.fn(),
}),
}));
jest.mock('@mcp-ui/client', () => ({
@ -30,18 +39,22 @@ jest.mock('../UIResourceCarousel', () => ({
default: jest.fn(() => null),
}));
// Add TextEncoder/TextDecoder polyfill for Jest environment
import { TextEncoder, TextDecoder } from 'util';
jest.mock('../ToolOutput', () => ({
OutputRenderer: ({ text }: { text: string }) => <div data-testid="output-renderer">{text}</div>,
}));
if (typeof global.TextEncoder === 'undefined') {
global.TextEncoder = TextEncoder as any;
global.TextDecoder = TextDecoder as any;
}
jest.mock('~/utils', () => ({
handleUIAction: jest.fn(),
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span>{'ChevronDown'}</span>,
}));
describe('ToolCallInfo', () => {
const mockProps = {
input: '{"test": "input"}',
function_name: 'testFunction',
};
beforeEach(() => {
@ -61,11 +74,10 @@ describe('ToolCallInfo', () => {
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
[Tools.ui_resources]: [uiResource] as any,
},
];
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render UIResourceRenderer for single resource
@ -85,7 +97,6 @@ describe('ToolCallInfo', () => {
});
it('should render carousel for multiple ui_resources from attachments', () => {
// To test multiple resources, we can use a single attachment with multiple resources
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
@ -96,11 +107,10 @@ describe('ToolCallInfo', () => {
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
] as any,
},
];
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render carousel for multiple resources
@ -119,50 +129,15 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should handle attachments with normal output', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
},
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
]);
const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
// Check that the output is displayed normally
const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
});
it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
render(<ToolCallInfo {...mockProps} output="Some output" />);
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle empty attachments array', () => {
const attachments: TAttachment[] = [];
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
render(<ToolCallInfo {...mockProps} attachments={[]} />);
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
@ -183,101 +158,65 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should not render UI resources components for non-ui_resources attachments
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist in attachments', () => {
it('should render output when provided', () => {
render(<ToolCallInfo {...mockProps} output="Some output" />);
expect(screen.getByTestId('output-renderer')).toBeInTheDocument();
expect(screen.getByTestId('output-renderer').textContent).toBe('Some output');
});
it('should render parameters toggle when input has JSON content', () => {
render(<ToolCallInfo {...mockProps} output="Some output" />);
expect(screen.getByText('Parameters')).toBeInTheDocument();
});
it('should not render parameters toggle when input is empty', () => {
render(<ToolCallInfo input="" output="Some output" />);
expect(screen.queryByText('Parameters')).not.toBeInTheDocument();
});
it('should toggle parameters visibility when clicking', () => {
render(<ToolCallInfo {...mockProps} output="Some output" />);
const paramsButton = screen.getByText('Parameters');
expect(paramsButton.closest('button')).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(paramsButton.closest('button')!);
expect(paramsButton.closest('button')).toHaveAttribute('aria-expanded', 'true');
});
it('should render ui_resources section when attachments have ui_resources', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }] as any,
},
];
// Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
it('should not render UI Resources heading when no ui_resources in attachments', () => {
render(<ToolCallInfo {...mockProps} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
it('should pass correct props to UIResourceRenderer', () => {
const uiResource = {
type: 'form',
data: { fields: [{ name: 'test', type: 'text' }] },
};
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
];
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
resource: { type: 'text', data: 'Test' },
}),
expect.any(Object),
);
});
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
];
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
>;
const onUIAction = mockUIResourceRenderer.mock.calls[0]?.[0]?.onUIAction;
const testResult = { action: 'submit', data: { test: 'value' } };
if (onUIAction) {
await onUIAction(testResult as any);
}
expect(consoleSpy).toHaveBeenCalledWith('Action:', testResult);
consoleSpy.mockRestore();
});
});
describe('backward compatibility', () => {
it('should handle output with ui_resources for backward compatibility', () => {
it('should handle output with ui_resources metadata (ignored — uses attachments)', () => {
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
@ -302,7 +241,7 @@ describe('ToolCallInfo', () => {
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }] as any,
},
];

View file

@ -0,0 +1,28 @@
import { Constants, actionDelimiter } from 'librechat-data-provider';
import { getToolIconType } from '../ToolOutput/ToolIcon';
describe('getToolIconType - ACTN-01: Action delimiter detection', () => {
it('returns "action" for tool name containing actionDelimiter', () => {
const toolName = `get_weather${actionDelimiter}weather---api---com`;
expect(getToolIconType(toolName)).toBe('action');
});
it('returns "mcp" when name has both mcp_delimiter and actionDelimiter', () => {
const toolName = `tool${Constants.mcp_delimiter}server`;
expect(getToolIconType(toolName)).toBe('mcp');
});
it('returns "generic" for plain tool name without delimiters', () => {
expect(getToolIconType('some_plain_tool')).toBe('generic');
});
it('returns correct types for existing tool names', () => {
expect(getToolIconType('execute_code')).toBe('execute_code');
expect(getToolIconType(Constants.PROGRAMMATIC_TOOL_CALLING)).toBe('execute_code');
expect(getToolIconType('web_search')).toBe('web_search');
expect(getToolIconType('image_gen_oai')).toBe('image_gen');
expect(getToolIconType('image_edit_oai')).toBe('image_gen');
expect(getToolIconType('gemini_image_gen')).toBe('image_gen');
expect(getToolIconType(`${Constants.LC_TRANSFER_TO_}agent1`)).toBe('agent_handoff');
});
});

View file

@ -0,0 +1,42 @@
import React from 'react';
import { InfoIcon } from 'lucide-react';
import type { CodeBarProps } from '~/common';
import useCopyCode from '~/components/Messages/Content/useCopyCode';
import CopyButton from '~/components/Messages/Content/CopyButton';
import LangIcon from '~/components/Messages/Content/LangIcon';
import RunCode from '~/components/Messages/Content/RunCode';
import { useLocalize } from '~/hooks';
const CodeBar: React.FC<CodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
const { isCopied, handleCopy } = useCopyCode(codeRef);
return (
<div className="flex items-center justify-between bg-surface-primary-alt px-1.5 py-1.5 font-sans text-xs text-text-secondary dark:bg-transparent">
<span className="flex items-center gap-1.5 text-xs font-medium">
<LangIcon lang={lang} className="size-3.5" />
{lang}
</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-text-secondary" />
) : (
<div className="flex items-center justify-center gap-2">
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
{error !== true && (
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
label={localize('com_ui_copy_code')}
/>
)}
</div>
)}
</div>
);
},
);
export default CodeBar;

View file

@ -1,13 +1,11 @@
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import { InfoIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import type { CodeBarProps } from '~/common';
import FloatingCodeBar from '~/components/Messages/Content/FloatingCodeBar';
import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher';
import { useToolCallsMapContext, useMessageContext } from '~/Providers';
import { LogContent } from '~/components/Chat/Messages/Content/Parts';
import RunCode from '~/components/Messages/Content/RunCode';
import CodeBar from '~/components/Messages/Content/CodeBar';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
@ -19,143 +17,6 @@ type CodeBlockProps = Pick<
classProp?: string;
};
interface FloatingCodeBarProps extends CodeBarProps {
isVisible: boolean;
}
const CodeBar: React.FC<CodeBarProps> = React.memo(function CodeBar({
lang,
error,
codeRef,
blockIndex,
plugin = null,
allowExecution = true,
}) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : (
<div className="flex items-center justify-center gap-4">
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
type="button"
className={cn(
'ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
setTimeout(() => {
setIsCopied(false);
}, 3000);
}
}}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</div>
)}
</div>
);
});
CodeBar.displayName = 'CodeBar';
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(function FloatingCodeBar({
lang,
error,
codeRef,
blockIndex,
plugin = null,
allowExecution = true,
isVisible,
}) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const handleCopy = useCallback(() => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
const wasFocused = document.activeElement === copyButtonRef.current;
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
if (wasFocused) {
requestAnimationFrame(() => {
copyButtonRef.current?.focus();
});
}
setTimeout(() => {
const focusedElement = document.activeElement as HTMLElement | null;
setIsCopied(false);
requestAnimationFrame(() => {
focusedElement?.focus();
});
}, 3000);
}
}, [codeRef]);
return (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} iconOnly />
)}
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
render={
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
className={cn(
'flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard aria-hidden="true" />
)}
</button>
}
/>
</>
)}
</div>
);
});
FloatingCodeBar.displayName = 'FloatingCodeBar';
const CodeBlock: React.FC<CodeBlockProps> = ({
lang,
blockIndex,
@ -165,9 +26,13 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
plugin = null,
error,
}) => {
const localize = useLocalize();
const codeRef = useRef<HTMLElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isBarVisible, setIsBarVisible] = useState(false);
const codeBarRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isCodeBarVisible, setIsCodeBarVisible] = useState(true);
const toolCallsMap = useToolCallsMapContext();
const { messageId, partIndex } = useMessageContext();
const key = allowExecution
@ -185,67 +50,76 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
}
}, [fetchedToolCalls]);
// Handle focus within the container (for keyboard navigation)
const handleFocus = useCallback(() => {
setIsBarVisible(true);
useEffect(() => {
const el = codeBarRef.current;
if (!el) {
return;
}
const observer = new IntersectionObserver(
([entry]) => setIsCodeBarVisible(entry.isIntersecting),
{ root: null, threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleFocus = useCallback(() => setIsHovered(true), []);
const handleBlur = useCallback((e: React.FocusEvent) => {
// Check if focus is moving to another element within the container
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
setIsBarVisible(false);
setIsHovered(false);
}
}, []);
const handleMouseEnter = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => {
// Only hide if no element inside has focus
if (!containerRef.current?.contains(document.activeElement)) {
setIsBarVisible(false);
setIsHovered(false);
}
}, []);
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
const next = () => {
if (!toolCalls) {
return;
}
if (currentIndex < toolCalls.length - 1) {
const next = useCallback(() => {
if (toolCalls && currentIndex < toolCalls.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
}, [toolCalls, currentIndex]);
const previous = () => {
const previous = useCallback(() => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
}, [currentIndex]);
const isNonCode = !!(plugin === true || error === true);
const language = isNonCode ? 'json' : lang;
const showFloating = isHovered && !isCodeBarVisible;
return (
<div
ref={containerRef}
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
className="relative w-full overflow-hidden rounded-xl border border-border-light text-xs"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<CodeBar
lang={lang}
error={error}
codeRef={codeRef}
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
/>
<div className={cn(classProp, 'overflow-y-auto p-4')}>
<div ref={codeBarRef}>
<CodeBar
lang={lang}
error={error}
codeRef={codeRef}
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
/>
</div>
<div
className={cn(classProp, 'overflow-y-auto bg-surface-chat p-4 dark:bg-surface-primary-alt')}
>
<code
ref={codeRef}
className={cn(
@ -262,17 +136,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
isVisible={isBarVisible}
isVisible={showFloating}
/>
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
<div className="bg-gray-700 p-4 text-xs">
<div
className="prose flex flex-col-reverse text-white"
style={{
color: 'white',
}}
>
<div className="border-t border-border-light bg-surface-primary-alt p-4 text-xs dark:bg-transparent">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_output')}
</div>
<div className="flex flex-col-reverse text-text-primary">
<pre className="shrink-0">
<LogContent
output={(currentToolCall?.result as string | undefined) ?? ''}

View file

@ -0,0 +1,89 @@
import React from 'react';
import { Copy, Check } from 'lucide-react';
import { TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface CopyButtonProps {
isCopied: boolean;
iconOnly?: boolean;
onClick: () => void;
tabIndex?: number;
className?: string;
label?: string;
copiedLabel?: string;
}
const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
({ isCopied, iconOnly = false, onClick, tabIndex, className, label, copiedLabel }, ref) => {
const localize = useLocalize();
const defaultLabel = label ?? localize('com_ui_copy');
const defaultCopiedLabel = copiedLabel ?? localize('com_ui_copied');
const currentLabel = isCopied ? defaultCopiedLabel : defaultLabel;
const button = (
<button
ref={ref}
type="button"
onClick={onClick}
tabIndex={tabIndex}
aria-label={currentLabel}
className={cn(
'inline-flex select-none items-center justify-center text-text-secondary transition-all duration-200 ease-out',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-heavy',
iconOnly ? 'rounded-lg p-1.5' : 'ml-auto gap-2 rounded-md px-2 py-1',
isCopied && 'text-text-secondary hover:text-text-primary',
className,
)}
>
<span className="relative flex size-[18px] items-center justify-center" aria-hidden="true">
<Copy
size={18}
className={cn(
'absolute transition-all duration-300 ease-out',
isCopied ? 'rotate-[-90deg] scale-0 opacity-0' : 'rotate-0 scale-100 opacity-100',
)}
/>
<Check
size={18}
className={cn(
'transition-all duration-300 ease-out',
isCopied ? 'rotate-0 scale-100 opacity-100' : 'rotate-90 scale-0 opacity-0',
)}
/>
</span>
{!iconOnly && (
<span className="relative overflow-hidden">
<span
className={cn(
'block transition-all duration-300 ease-out',
isCopied ? '-translate-y-full opacity-0' : 'translate-y-0 opacity-100',
)}
>
{defaultLabel}
</span>
<span
className={cn(
'absolute inset-0 transition-all duration-300 ease-out',
isCopied ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0',
)}
>
{defaultCopiedLabel}
</span>
</span>
)}
</button>
);
if (iconOnly) {
return <TooltipAnchor description={currentLabel} render={button} />;
}
return button;
},
);
CopyButton.displayName = 'CopyButton';
export default CopyButton;

View file

@ -0,0 +1,45 @@
import React from 'react';
import { InfoIcon } from 'lucide-react';
import type { CodeBarProps } from '~/common';
import useCopyCode from '~/components/Messages/Content/useCopyCode';
import CopyButton from '~/components/Messages/Content/CopyButton';
import RunCode from '~/components/Messages/Content/RunCode';
import cn from '~/utils/cn';
interface FloatingCodeBarProps extends CodeBarProps {
isVisible: boolean;
}
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
({ lang, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
const { isCopied, buttonRef, handleCopy } = useCopyCode(codeRef);
return (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-text-secondary transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-text-secondary" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} iconOnly />
)}
<CopyButton
ref={buttonRef}
isCopied={isCopied}
iconOnly
tabIndex={isVisible ? 0 : -1}
onClick={handleCopy}
/>
</>
)}
</div>
);
},
);
export default FloatingCodeBar;

View file

@ -0,0 +1,104 @@
import React from 'react';
import { FileText } from 'lucide-react';
import LANG_ICON_PATHS from './langIconPaths';
interface LangIconProps {
lang: string;
className?: string;
}
const LANG_ALIASES: Record<string, string> = {
py: 'python',
js: 'javascript',
ts: 'typescript',
jsx: 'javascript',
tsx: 'typescript',
rs: 'rust',
golang: 'go',
rb: 'ruby',
sh: 'bash',
zsh: 'bash',
shell: 'bash',
powershell: 'bash',
cmd: 'bash',
'c++': 'cpp',
'c#': 'csharp',
cs: 'csharp',
dotnet: 'csharp',
htm: 'html',
html5: 'html',
css3: 'css',
scss: 'sass',
jsonc: 'json',
json5: 'json',
yml: 'yaml',
mysql: 'json',
postgresql: 'json',
sqlite: 'json',
sql: 'json',
kt: 'kotlin',
kts: 'kotlin',
ex: 'elixir',
exs: 'elixir',
erl: 'erlang',
hs: 'haskell',
pl: 'perl',
clj: 'clojure',
cljs: 'clojure',
cljc: 'clojure',
jl: 'julia',
ml: 'ocaml',
mli: 'ocaml',
fs: 'fsharp',
fsx: 'fsharp',
cr: 'crystal',
coffee: 'coffeescript',
sol: 'solidity',
wasm: 'webassembly',
wat: 'webassembly',
gql: 'graphql',
tex: 'latex',
md: 'markdown',
mdx: 'markdown',
pas: 'delphi',
pascal: 'delphi',
rkt: 'racket',
purs: 'purescript',
};
/** Map from alias used in LANG_ICON_PATHS to the name used in ICON_IMPORTS on the old code. */
const NAME_MAP: Record<string, string> = {
java: 'openjdk',
c: 'c',
cpp: 'cplusplus',
csharp: 'dotnet',
html: 'html5',
bash: 'gnubash',
groovy: 'apachegroovy',
};
const LangIcon = React.memo(function LangIcon({ lang, className }: LangIconProps) {
const key = lang.toLowerCase();
if (key === 'txt' || key === 'text') {
return <FileText className={className} aria-hidden="true" />;
}
const resolved = LANG_ALIASES[key] ?? key;
const pathKey = NAME_MAP[resolved] ?? resolved;
const path = LANG_ICON_PATHS[pathKey];
if (!path) {
return null;
}
return (
<svg
role="img"
viewBox="0 0 24 24"
className={className}
fill="currentColor"
aria-hidden="true"
>
<path d={path} />
</svg>
);
});
export default LangIcon;

View file

@ -1,850 +0,0 @@
import React, { useEffect, useMemo, useState, useRef, useCallback, memo } from 'react';
import copy from 'copy-to-clipboard';
import { X, ZoomIn, ZoomOut, ChevronUp, RefreshCw, RotateCcw, ChevronDown } from 'lucide-react';
import {
Button,
Spinner,
OGDialog,
Clipboard,
CheckMark,
OGDialogClose,
OGDialogTitle,
OGDialogContent,
} from '@librechat/client';
import { useLocalize, useDebouncedMermaid } from '~/hooks';
import { fixSubgraphTitleContrast } from '~/utils/mermaid';
import MermaidHeader from './MermaidHeader';
import cn from '~/utils/cn';
interface MermaidProps {
/** Mermaid diagram content */
children: string;
/** Unique identifier */
id?: string;
/** Custom theme */
theme?: string;
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;
const MIN_CONTAINER_HEIGHT = 200;
const MAX_CONTAINER_HEIGHT = 500;
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const localize = useLocalize();
const [blobUrl, setBlobUrl] = useState<string>('');
const [showCode, setShowCode] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Separate showCode state for dialog to avoid re-renders
const [dialogShowCode, setDialogShowCode] = useState(false);
const lastValidSvgRef = useRef<string | null>(null);
const expandButtonRef = useRef<HTMLButtonElement>(null);
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
const [svgDimensions, setSvgDimensions] = useState<{ width: number; height: number } | null>(
null,
);
const [containerWidth, setContainerWidth] = useState(700);
const [isHovered, setIsHovered] = useState(false);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const [showMobileControls, setShowMobileControls] = useState(false);
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
const [zoom, setZoom] = useState(1);
// Dialog zoom and pan state (separate from inline view)
const [dialogZoom, setDialogZoom] = useState(1);
const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 });
const [isDialogPanning, setIsDialogPanning] = useState(false);
const dialogPanStartRef = useRef({ x: 0, y: 0 });
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const streamingCodeRef = useRef<HTMLPreElement>(null);
// Get SVG from debounced hook (handles streaming gracefully)
const { svg, isLoading, error } = useDebouncedMermaid({
content: children,
id,
theme,
key: retryCount,
});
// Auto-scroll streaming code to bottom
useEffect(() => {
if (isLoading && streamingCodeRef.current) {
streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight;
}
}, [children, isLoading]);
// Store last valid SVG for showing during updates
useEffect(() => {
if (svg) {
lastValidSvgRef.current = svg;
}
}, [svg]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Process SVG and extract dimensions
const { processedSvg, parsedDimensions } = useMemo(() => {
if (!svg) {
return { processedSvg: null, parsedDimensions: null };
}
// Regex-based fallback for malformed or unparseable SVG
const applyFallbackFixes = (svgString: string): string => {
let finalSvg = svgString;
// Firefox fix: Ensure viewBox is set correctly
if (
!svgString.includes('viewBox') &&
svgString.includes('height=') &&
svgString.includes('width=')
) {
const widthMatch = svgString.match(/width="(\d+)"/);
const heightMatch = svgString.match(/height="(\d+)"/);
if (widthMatch && heightMatch) {
const width = widthMatch[1];
const height = heightMatch[1];
finalSvg = finalSvg.replace('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
}
}
// Ensure SVG has proper XML namespace
if (!finalSvg.includes('xmlns')) {
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
return finalSvg;
};
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}
const svgElement = doc.querySelector('svg');
if (svgElement) {
let width = parseFloat(svgElement.getAttribute('width') || '0');
let height = parseFloat(svgElement.getAttribute('height') || '0');
if (!width || !height) {
const viewBox = svgElement.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.split(/[\s,]+/).map(Number);
if (parts.length === 4) {
width = parts[2];
height = parts[3];
}
}
}
let dimensions: { width: number; height: number } | null = null;
if (width > 0 && height > 0) {
dimensions = { width, height };
if (!svgElement.getAttribute('viewBox')) {
svgElement.setAttribute('viewBox', `0 0 ${width} ${height}`);
}
svgElement.removeAttribute('width');
svgElement.removeAttribute('height');
svgElement.removeAttribute('style');
}
if (!svgElement.getAttribute('xmlns')) {
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
fixSubgraphTitleContrast(svgElement);
return {
processedSvg: new XMLSerializer().serializeToString(doc),
parsedDimensions: dimensions,
};
}
// Fallback: if svgElement is null
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}, [svg]);
// The svg dimension update needs to be in useEffect instead of useMemo to avoid re-render problems
useEffect(() => {
if (parsedDimensions) {
setSvgDimensions(parsedDimensions);
}
}, [parsedDimensions]);
useEffect(() => {
if (!processedSvg) {
return;
}
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
}, [processedSvg]);
const { initialScale, calculatedHeight } = useMemo(() => {
if (!svgDimensions) {
return { initialScale: 1, calculatedHeight: MAX_CONTAINER_HEIGHT };
}
const padding = 32;
const availableWidth = containerWidth - padding;
const scaleX = availableWidth / svgDimensions.width;
const scaleY = MAX_CONTAINER_HEIGHT / svgDimensions.height;
const scale = Math.min(scaleX, scaleY, 1); // Cap at 1 to prevent small diagrams from being scaled up
const height = Math.max(
MIN_CONTAINER_HEIGHT,
Math.min(MAX_CONTAINER_HEIGHT, svgDimensions.height * scale + padding),
);
return { initialScale: scale, calculatedHeight: height };
}, [svgDimensions, containerWidth]);
const [isDialogCopied, setIsDialogCopied] = useState(false);
const handleDialogCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
setIsDialogCopied(true);
requestAnimationFrame(() => {
dialogCopyButtonRef.current?.focus();
});
setTimeout(() => {
setIsDialogCopied(false);
requestAnimationFrame(() => {
dialogCopyButtonRef.current?.focus();
});
}, 3000);
}, [children]);
// Zoom controls copy with focus restoration
const [isZoomCopied, setIsZoomCopied] = useState(false);
const handleZoomCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
setIsZoomCopied(true);
requestAnimationFrame(() => {
zoomCopyButtonRef.current?.focus();
});
setTimeout(() => {
setIsZoomCopied(false);
requestAnimationFrame(() => {
zoomCopyButtonRef.current?.focus();
});
}, 3000);
}, [children]);
// Dialog zoom controls copy
const handleDialogZoomCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
requestAnimationFrame(() => {
dialogZoomCopyButtonRef.current?.focus();
});
}, [children]);
const handleRetry = () => {
setRetryCount((prev) => prev + 1);
};
const handleToggleCode = useCallback(() => {
setShowCode((prev) => !prev);
}, []);
// Toggle dialog code with focus restoration
const handleToggleDialogCode = useCallback(() => {
setDialogShowCode((prev) => !prev);
requestAnimationFrame(() => {
dialogShowCodeButtonRef.current?.focus();
});
}, []);
// Zoom handlers
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleResetZoom = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
// Dialog zoom handlers
const handleDialogZoomIn = useCallback(() => {
setDialogZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleDialogZoomOut = useCallback(() => {
setDialogZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleDialogResetZoom = useCallback(() => {
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
}, []);
const handleDialogWheel = useCallback((e: React.WheelEvent) => {
// In the expanded dialog, allow zooming without holding modifier key
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setDialogZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}, []);
const handleDialogMouseDown = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const isButton = target.tagName === 'BUTTON' || target.closest('button');
if (e.button === 0 && !isButton) {
setIsDialogPanning(true);
dialogPanStartRef.current = { x: e.clientX - dialogPan.x, y: e.clientY - dialogPan.y };
}
},
[dialogPan],
);
const handleDialogMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDialogPanning) {
setDialogPan({
x: e.clientX - dialogPanStartRef.current.x,
y: e.clientY - dialogPanStartRef.current.y,
});
}
},
[isDialogPanning],
);
const handleDialogMouseUp = useCallback(() => {
setIsDialogPanning(false);
}, []);
const handleDialogMouseLeave = useCallback(() => {
setIsDialogPanning(false);
}, []);
// Mouse wheel zoom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheelNative = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}
};
// use native event listener with passive: false to prevent scroll
container.addEventListener('wheel', handleWheelNative, { passive: false });
return () => container.removeEventListener('wheel', handleWheelNative);
}, [blobUrl]); // blobUrl dep (unused in callback) ensures listener re-attaches when container mounts
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only start panning on left click and not on buttons/icons inside buttons
const target = e.target as HTMLElement;
const isButton = target.tagName === 'BUTTON' || target.closest('button');
if (e.button === 0 && !isButton) {
setIsPanning(true);
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
}
},
[pan],
);
// Attach document-level listeners when panning starts
useEffect(() => {
if (!isPanning) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
};
const handleDocumentMouseUp = () => {
setIsPanning(false);
};
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}, [isPanning]);
const showControls = isTouchDevice
? showMobileControls || showCode
: isHovered || isFocusWithin || showCode;
const handleContainerClick = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!isTouchDevice) return;
const target = e.target as HTMLElement;
const isInteractive = target.closest('button, a, [role="button"]');
if (!isInteractive) {
setShowMobileControls((prev) => !prev);
}
},
[isTouchDevice],
);
const handleExpand = useCallback(() => {
setDialogShowCode(false);
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
setIsDialogOpen(true);
}, []);
const zoomControls = (
<div
className={cn(
'absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomOut();
}}
disabled={zoom <= MIN_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomIn();
}}
disabled={zoom >= MAX_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleResetZoom();
}}
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={zoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
// Dialog zoom controls
const dialogZoomControls = (
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomOut();
}}
disabled={dialogZoom <= MIN_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
{Math.round(dialogZoom * 100)}%
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomIn();
}}
disabled={dialogZoom >= MAX_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogResetZoom();
}}
disabled={dialogZoom === 1 && dialogPan.x === 0 && dialogPan.y === 0}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={dialogZoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
<Clipboard className="h-4 w-4" />
</button>
</div>
);
// Full-screen dialog - rendered inline, not as function component to avoid recreation
const expandedDialog = (
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} triggerRef={expandButtonRef}>
<OGDialogContent
showCloseButton={false}
className="h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] gap-0 overflow-hidden border-border-light bg-surface-primary-alt p-0"
>
<OGDialogTitle className="flex h-10 items-center justify-between bg-gray-700 px-4 font-sans text-xs text-gray-200">
<span>{localize('com_ui_mermaid')}</span>
<div className="flex gap-2">
<Button
ref={dialogShowCodeButtonRef}
variant="ghost"
size="sm"
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleToggleDialogCode}
>
{dialogShowCode ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{dialogShowCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={dialogCopyButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleDialogCopy}
>
{isDialogCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
<OGDialogClose className="rounded-sm p-1 text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white">
<X className="h-4 w-4" />
<span className="sr-only">{localize('com_ui_close')}</span>
</OGDialogClose>
</div>
</OGDialogTitle>
{dialogShowCode && (
<div className="border-b border-border-medium bg-surface-secondary p-4">
<pre className="max-h-[150px] overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
<div
className={cn(
'relative flex-1 overflow-hidden p-4',
'bg-surface-primary-alt',
isDialogPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: dialogShowCode ? 'calc(85vh - 200px)' : 'calc(85vh - 50px)' }}
onWheel={handleDialogWheel}
onMouseDown={handleDialogMouseDown}
onMouseMove={handleDialogMouseMove}
onMouseUp={handleDialogMouseUp}
onMouseLeave={handleDialogMouseLeave}
>
<div
className="flex h-full w-full items-center justify-center"
style={{
transform: `translate(${dialogPan.x}px, ${dialogPan.y}px)`,
transition: isDialogPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-h-full max-w-full select-none object-contain"
style={{
transform: `scale(${dialogZoom})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
</div>
{dialogZoomControls}
</div>
</OGDialogContent>
</OGDialog>
);
// Loading state - show last valid diagram with loading indicator, or spinner
if (isLoading) {
// If we have a previous valid render, show it with a subtle loading indicator
if (lastValidSvgRef.current && blobUrl) {
return (
<div
className={cn(
'relative w-full overflow-hidden rounded-md border transition-all duration-200',
showControls ? 'border-border-light' : 'border-transparent',
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocusWithin(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
}}
onClick={handleContainerClick}
>
<MermaidHeader
className={cn(
'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
onToggleCode={handleToggleCode}
/>
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4 transition-colors duration-200',
'rounded-md',
showControls ? 'bg-surface-primary-alt dark:bg-white/[0.03]' : 'bg-transparent',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
>
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
<Spinner className="h-3 w-3" />
</div>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="select-none opacity-70"
style={{
width: svgDimensions ? `${svgDimensions.width * initialScale}px` : 'auto',
height: svgDimensions ? `${svgDimensions.height * initialScale}px` : 'auto',
}}
draggable={false}
/>
</div>
{zoomControls}
</div>
</div>
);
}
// No previous render, show streaming code
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<div className="flex items-center gap-2 rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
<Spinner className="h-3 w-3 text-gray-200" />
<span>{localize('com_ui_mermaid')}</span>
</div>
<pre
ref={streamingCodeRef}
className="max-h-[350px] min-h-[150px] overflow-auto whitespace-pre-wrap rounded-b-md bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary"
>
{children}
</pre>
</div>
);
}
// Error state
if (error) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<MermaidHeader codeContent={children} showCode={showCode} onToggleCode={handleToggleCode} />
<div className="rounded-b-md border-t border-red-500/30 bg-red-500/10 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-red-500 dark:text-red-400">
{localize('com_ui_mermaid_failed')}
</span>
<button
type="button"
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
>
<RefreshCw className="h-3 w-3" />
{localize('com_ui_retry')}
</button>
</div>
<pre className="overflow-auto text-xs text-red-600 dark:text-red-300">
{error.message}
</pre>
{showCode && (
<div className="mt-4 border-t border-border-medium pt-4">
<div className="mb-2 text-xs text-text-secondary">
{localize('com_ui_mermaid_source')}
</div>
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
</div>
</div>
);
}
// Success state
if (!blobUrl) {
return null;
}
return (
<>
{expandedDialog}
<div
className={cn(
'relative w-full overflow-hidden rounded-md border transition-all duration-200',
showControls ? 'border-border-light' : 'border-transparent',
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocusWithin(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
}}
onClick={handleContainerClick}
>
<MermaidHeader
className={cn(
'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
showExpandButton
expandButtonRef={expandButtonRef}
onExpand={handleExpand}
onToggleCode={handleToggleCode}
/>
{showCode && (
<div
className={cn(
'border-b border-border-medium bg-surface-secondary p-4 pt-12 transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4 transition-colors duration-200',
'rounded-md',
showControls ? 'bg-surface-primary-alt dark:bg-white/[0.03]' : 'bg-transparent',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="select-none"
style={{
width: svgDimensions ? `${svgDimensions.width * initialScale}px` : 'auto',
height: svgDimensions ? `${svgDimensions.height * initialScale}px` : 'auto',
}}
draggable={false}
/>
</div>
{zoomControls}
</div>
</div>
</>
);
});
Mermaid.displayName = 'Mermaid';
export default Mermaid;

View file

@ -0,0 +1,288 @@
import React, { useEffect, useState, useRef, useCallback, memo } from 'react';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@librechat/client';
import useSvgProcessing from './useSvgProcessing';
import useMermaidZoom from './useMermaidZoom';
import MermaidDialog from './MermaidDialog';
import MermaidHeader from './MermaidHeader';
import ZoomControls from './ZoomControls';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidProps {
children: string;
id?: string;
theme?: string;
}
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const localize = useLocalize();
const [showCode, setShowCode] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const [showMobileControls, setShowMobileControls] = useState(false);
const expandButtonRef = useRef<HTMLButtonElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const streamingCodeRef = useRef<HTMLPreElement>(null);
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
const {
blobUrl,
svgDimensions,
isLoading,
error,
lastValidSvgRef,
initialScale,
calculatedHeight,
} = useSvgProcessing({ content: children, id, theme, retryCount, containerRef });
const { zoom, pan, isPanning, handleZoomIn, handleZoomOut, handleResetZoom, handleMouseDown } =
useMermaidZoom({ containerRef, wheelDep: blobUrl });
useEffect(() => {
if (isLoading && streamingCodeRef.current) {
streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight;
}
}, [children, isLoading]);
const handleToggleCode = useCallback(() => setShowCode((prev) => !prev), []);
const handleRetry = useCallback(() => setRetryCount((prev) => prev + 1), []);
const handleExpand = useCallback(() => setIsDialogOpen(true), []);
const handleContainerClick = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!isTouchDevice) {
return;
}
const target = e.target as HTMLElement;
if (!target.closest('button, a, [role="button"]')) {
setShowMobileControls((prev) => !prev);
}
},
[isTouchDevice],
);
const showControls = isTouchDevice
? showMobileControls || showCode
: isHovered || isFocusWithin || showCode;
const hoverHandlers = {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
onFocus: () => setIsFocusWithin(true),
onBlur: (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
},
};
const diagramStyle = {
width: svgDimensions ? `${svgDimensions.width * initialScale}px` : 'auto',
height: svgDimensions ? `${svgDimensions.height * initialScale}px` : 'auto',
};
if (isLoading) {
if (lastValidSvgRef.current && blobUrl) {
return (
<div
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-all duration-200',
showControls ? 'border-border-light' : 'border-transparent',
)}
{...hoverHandlers}
onClick={handleContainerClick}
>
<MermaidHeader
className={cn(
'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
onToggleCode={handleToggleCode}
/>
<div
ref={containerRef}
className={cn(
'relative overflow-hidden rounded-md p-4 transition-colors duration-200',
'bg-surface-primary-alt dark:bg-white/[0.03]',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
>
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
<Spinner className="h-3 w-3" />
</div>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="select-none opacity-70"
style={diagramStyle}
draggable={false}
/>
</div>
<ZoomControls
zoom={zoom}
pan={pan}
codeContent={children}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleResetZoom}
className={cn(
'absolute bottom-2 right-2 z-10 transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
/>
</div>
</div>
);
}
return (
<div className="w-full overflow-hidden rounded-lg border border-border-light">
<div className="flex items-center gap-2 border-b border-border-light bg-surface-secondary px-4 py-2 font-sans text-xs text-text-secondary">
<Spinner className="h-3 w-3" />
<span className="font-medium">{localize('com_ui_mermaid')}</span>
</div>
<pre
ref={streamingCodeRef}
className="max-h-[350px] min-h-[150px] overflow-auto whitespace-pre-wrap bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary"
>
{children}
</pre>
</div>
);
}
if (error) {
return (
<div className="w-full overflow-hidden rounded-lg border border-border-light">
<MermaidHeader codeContent={children} showCode={showCode} onToggleCode={handleToggleCode} />
<div className="border-t border-border-light bg-surface-tertiary p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-red-600 dark:text-red-400">
{localize('com_ui_mermaid_failed')}
</span>
<button
type="button"
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
>
<RefreshCw className="h-3 w-3" />
{localize('com_ui_retry')}
</button>
</div>
<pre className="overflow-auto text-xs text-red-600 dark:text-red-300">
{error.message}
</pre>
{showCode && (
<div className="mt-4 border-t border-border-light pt-4">
<div className="mb-2 text-xs text-text-secondary">
{localize('com_ui_mermaid_source')}
</div>
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
</div>
</div>
);
}
if (!blobUrl) {
return null;
}
return (
<>
<MermaidDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
triggerRef={expandButtonRef}
blobUrl={blobUrl}
codeContent={children}
/>
<div
className="relative w-full overflow-hidden rounded-lg border border-border-light transition-all duration-200"
{...hoverHandlers}
onClick={handleContainerClick}
>
<MermaidHeader
className="border-b border-border-light bg-surface-secondary"
actionsClassName="transition-opacity duration-200"
codeContent={children}
showCode={showCode}
showExpandButton
expandButtonRef={expandButtonRef}
onExpand={handleExpand}
onToggleCode={handleToggleCode}
/>
{showCode && (
<div className="border-b border-border-light bg-surface-secondary p-4">
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4 transition-colors duration-200',
'bg-surface-primary-alt dark:bg-white/[0.03]',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="select-none"
style={diagramStyle}
draggable={false}
/>
</div>
<ZoomControls
zoom={zoom}
pan={pan}
codeContent={children}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleResetZoom}
className={cn(
'absolute bottom-2 right-2 z-10 transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
/>
</div>
</div>
</>
);
});
Mermaid.displayName = 'Mermaid';
export default Mermaid;

View file

@ -0,0 +1,156 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { X, ChevronUp, ChevronDown } from 'lucide-react';
import {
Button,
OGDialog,
Clipboard,
CheckMark,
OGDialogClose,
OGDialogTitle,
OGDialogContent,
} from '@librechat/client';
import useMermaidZoom from './useMermaidZoom';
import ZoomControls from './ZoomControls';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement>;
blobUrl: string;
codeContent: string;
}
const MermaidDialog: React.FC<MermaidDialogProps> = memo(
({ open, onOpenChange, triggerRef, blobUrl, codeContent }) => {
const localize = useLocalize();
const [showCode, setShowCode] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const {
zoom,
pan,
isPanning,
handleZoomIn,
handleZoomOut,
handleResetZoom,
handleWheel,
handleMouseDown,
} = useMermaidZoom();
useEffect(() => {
if (open) {
setShowCode(false);
handleResetZoom();
}
}, [open, handleResetZoom]);
const handleToggleCode = useCallback(() => {
setShowCode((prev) => !prev);
requestAnimationFrame(() => showCodeButtonRef.current?.focus());
}, []);
const handleCopy = useCallback(() => {
copy(codeContent.trim(), { format: 'text/plain' });
setIsCopied(true);
requestAnimationFrame(() => copyButtonRef.current?.focus());
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setIsCopied(false);
requestAnimationFrame(() => copyButtonRef.current?.focus());
}, 3000);
}, [codeContent]);
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogContent
showCloseButton={false}
className="h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] gap-0 overflow-hidden border-border-light bg-surface-primary-alt p-0"
>
<OGDialogTitle className="flex h-10 items-center justify-between border-b border-border-light bg-surface-secondary px-4 font-sans text-xs text-text-secondary">
<span>{localize('com_ui_mermaid')}</span>
<div className="flex gap-2">
<Button
ref={showCodeButtonRef}
variant="ghost"
size="sm"
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0"
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={copyButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0"
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
<OGDialogClose className="rounded-sm p-1 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy">
<X className="h-4 w-4" />
<span className="sr-only">{localize('com_ui_close')}</span>
</OGDialogClose>
</div>
</OGDialogTitle>
{showCode && (
<div className="border-b border-border-light bg-surface-secondary p-4">
<pre className="max-h-[150px] overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{codeContent}
</pre>
</div>
)}
<div
className={cn(
'relative flex-1 overflow-hidden bg-surface-primary-alt p-4',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: showCode ? 'calc(85vh - 200px)' : 'calc(85vh - 50px)' }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
>
<div
className="flex h-full w-full items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px)`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-h-full max-w-full select-none object-contain"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
</div>
<ZoomControls
zoom={zoom}
pan={pan}
codeContent={codeContent}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleResetZoom}
className="absolute bottom-4 right-4 z-10"
/>
</div>
</OGDialogContent>
</OGDialog>
);
},
);
MermaidDialog.displayName = 'MermaidDialog';
export default MermaidDialog;

View file

@ -2,7 +2,6 @@ import React from 'react';
interface MermaidErrorBoundaryProps {
children: React.ReactNode;
/** The mermaid code to display as fallback */
code: string;
}
@ -10,10 +9,6 @@ interface MermaidErrorBoundaryState {
hasError: boolean;
}
/**
* Error boundary specifically for Mermaid diagrams.
* Falls back to displaying the raw mermaid code if rendering fails.
*/
class MermaidErrorBoundary extends React.Component<
MermaidErrorBoundaryProps,
MermaidErrorBoundaryState
@ -32,7 +27,6 @@ class MermaidErrorBoundary extends React.Component<
}
componentDidUpdate(prevProps: MermaidErrorBoundaryProps) {
// Reset error state if code changes (e.g., user edits the message)
if (prevProps.code !== this.props.code && this.state.hasError) {
this.setState({ hasError: false });
}
@ -42,10 +36,10 @@ class MermaidErrorBoundary extends React.Component<
if (this.state.hasError) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
<div className="rounded-t-md bg-surface-secondary px-4 py-2 font-sans text-xs text-text-secondary">
{'mermaid'}
</div>
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary">
{this.props.code}
</pre>
</div>

View file

@ -0,0 +1,104 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { TooltipAnchor } from '@librechat/client';
import { Expand, ChevronUp, ChevronDown } from 'lucide-react';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidHeaderProps {
className?: string;
actionsClassName?: string;
codeContent: string;
showCode: boolean;
showExpandButton?: boolean;
expandButtonRef?: React.RefObject<HTMLButtonElement>;
onExpand?: () => void;
onToggleCode: () => void;
}
const iconBtnClass =
'flex items-center justify-center rounded-lg p-1.5 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-heavy';
const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
({
className,
actionsClassName,
codeContent,
showCode,
showExpandButton = false,
expandButtonRef,
onExpand,
onToggleCode,
}) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => clearTimeout(copyTimerRef.current);
}, []);
const handleCopy = useCallback(() => {
copy(codeContent.trim(), { format: 'text/plain' });
setIsCopied(true);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setIsCopied(false), 3000);
}, [codeContent]);
const handleToggleCode = useCallback(() => {
onToggleCode();
requestAnimationFrame(() => {
showCodeButtonRef.current?.focus();
});
}, [onToggleCode]);
return (
<div className={cn('flex items-center justify-between gap-1 px-2 py-1', className)}>
<span className="rounded text-xs font-medium text-text-secondary">
{localize('com_ui_mermaid')}
</span>
<div className={cn('flex items-center gap-1', actionsClassName)}>
{showExpandButton && onExpand && (
<TooltipAnchor
description={localize('com_ui_expand')}
render={
<button
ref={expandButtonRef}
type="button"
aria-label={localize('com_ui_expand')}
className={iconBtnClass}
onClick={onExpand}
>
<Expand className="h-4 w-4" />
</button>
}
/>
)}
<TooltipAnchor
description={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
render={
<button
ref={showCodeButtonRef}
type="button"
aria-label={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
className={iconBtnClass}
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
}
/>
<CopyButton ref={copyButtonRef} isCopied={isCopied} iconOnly onClick={handleCopy} />
</div>
</div>
);
},
);
MermaidHeader.displayName = 'MermaidHeader';
export default MermaidHeader;

View file

@ -0,0 +1,106 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { Clipboard, CheckMark } from '@librechat/client';
import { MIN_ZOOM, MAX_ZOOM } from './useMermaidZoom';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface ZoomControlsProps {
zoom: number;
pan: { x: number; y: number };
codeContent: string;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
className?: string;
}
const btnClass =
'rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent';
const ZoomControls: React.FC<ZoomControlsProps> = memo(
({ zoom, pan, codeContent, onZoomIn, onZoomOut, onReset, className }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyRef = useRef<HTMLButtonElement>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => clearTimeout(copyTimerRef.current);
}, []);
const handleCopy = useCallback(() => {
copy(codeContent.trim(), { format: 'text/plain' });
setIsCopied(true);
requestAnimationFrame(() => copyRef.current?.focus());
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => {
setIsCopied(false);
requestAnimationFrame(() => copyRef.current?.focus());
}, 3000);
}, [codeContent]);
const stop =
(fn: () => void) =>
(e: React.MouseEvent): void => {
e.stopPropagation();
fn();
};
return (
<div
className={cn(
'flex items-center gap-1 rounded-lg border border-border-light bg-surface-secondary p-1 shadow-md',
className,
)}
>
<button
type="button"
onClick={stop(onZoomOut)}
disabled={zoom <= MIN_ZOOM}
className={btnClass}
title={localize('com_ui_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
onClick={stop(onZoomIn)}
disabled={zoom >= MAX_ZOOM}
className={btnClass}
title={localize('com_ui_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
type="button"
onClick={stop(onReset)}
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
className={btnClass}
title={localize('com_ui_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={copyRef}
type="button"
onClick={stop(handleCopy)}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
{isCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
},
);
ZoomControls.displayName = 'ZoomControls';
export default ZoomControls;

View file

@ -0,0 +1,2 @@
export { default } from './Mermaid';
export { default as MermaidErrorBoundary } from './MermaidErrorBoundary';

View file

@ -0,0 +1,93 @@
import { useState, useCallback, useEffect, useRef } from 'react';
export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;
interface UseMermaidZoomOptions {
containerRef?: React.RefObject<HTMLDivElement | null>;
wheelDep?: unknown;
}
export default function useMermaidZoom({ containerRef, wheelDep }: UseMermaidZoomOptions = {}) {
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef({ x: 0, y: 0 });
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleResetZoom = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}, []);
const panRef = useRef(pan);
panRef.current = pan;
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (e.button === 0 && target.tagName !== 'BUTTON' && !target.closest('button')) {
setIsPanning(true);
panStartRef.current = { x: e.clientX - panRef.current.x, y: e.clientY - panRef.current.y };
}
}, []);
useEffect(() => {
if (!isPanning) {
return;
}
const onMove = (e: MouseEvent) => {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
};
const onUp = () => setIsPanning(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isPanning]);
useEffect(() => {
const container = containerRef?.current;
if (!container) {
return;
}
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}
};
container.addEventListener('wheel', onWheel, { passive: false });
return () => container.removeEventListener('wheel', onWheel);
}, [containerRef, wheelDep]);
return {
zoom,
pan,
isPanning,
handleZoomIn,
handleZoomOut,
handleResetZoom,
handleWheel,
handleMouseDown,
};
}

View file

@ -0,0 +1,176 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { fixSubgraphTitleContrast } from '~/utils/mermaid';
import { useDebouncedMermaid } from '~/hooks';
const MIN_CONTAINER_HEIGHT = 200;
const MAX_CONTAINER_HEIGHT = 500;
interface UseSvgProcessingOptions {
content: string;
id?: string;
theme?: string;
retryCount: number;
containerRef: React.RefObject<HTMLDivElement | null>;
}
function applyFallbackFixes(svgString: string): string {
let finalSvg = svgString;
if (
!svgString.includes('viewBox') &&
svgString.includes('height=') &&
svgString.includes('width=')
) {
const widthMatch = svgString.match(/width="(\d+)"/);
const heightMatch = svgString.match(/height="(\d+)"/);
if (widthMatch && heightMatch) {
finalSvg = finalSvg.replace('<svg', `<svg viewBox="0 0 ${widthMatch[1]} ${heightMatch[1]}"`);
}
}
if (!finalSvg.includes('xmlns')) {
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
return finalSvg;
}
function processSvgString(svg: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
if (doc.querySelector('parsererror')) {
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}
const svgElement = doc.querySelector('svg');
if (!svgElement) {
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}
let width = parseFloat(svgElement.getAttribute('width') || '0');
let height = parseFloat(svgElement.getAttribute('height') || '0');
if (!width || !height) {
const viewBox = svgElement.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.split(/[\s,]+/).map(Number);
if (parts.length === 4) {
width = parts[2];
height = parts[3];
}
}
}
let dimensions: { width: number; height: number } | null = null;
if (width > 0 && height > 0) {
dimensions = { width, height };
if (!svgElement.getAttribute('viewBox')) {
svgElement.setAttribute('viewBox', `0 0 ${width} ${height}`);
}
svgElement.removeAttribute('width');
svgElement.removeAttribute('height');
svgElement.removeAttribute('style');
}
if (!svgElement.getAttribute('xmlns')) {
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
fixSubgraphTitleContrast(svgElement);
return {
processedSvg: new XMLSerializer().serializeToString(doc),
parsedDimensions: dimensions,
};
}
export default function useSvgProcessing({
content,
id,
theme,
retryCount,
containerRef,
}: UseSvgProcessingOptions) {
const [blobUrl, setBlobUrl] = useState('');
const [svgDimensions, setSvgDimensions] = useState<{ width: number; height: number } | null>(
null,
);
const [containerWidth, setContainerWidth] = useState(700);
const lastValidSvgRef = useRef<string | null>(null);
const { svg, isLoading, error } = useDebouncedMermaid({
content,
id,
theme,
key: retryCount,
});
useEffect(() => {
if (svg) {
lastValidSvgRef.current = svg;
}
}, [svg]);
useEffect(() => {
if (!containerRef.current) {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [containerRef]);
const { processedSvg, parsedDimensions } = useMemo(() => {
if (!svg) {
return { processedSvg: null, parsedDimensions: null };
}
return processSvgString(svg);
}, [svg]);
useEffect(() => {
if (parsedDimensions) {
setSvgDimensions(parsedDimensions);
}
}, [parsedDimensions]);
useEffect(() => {
if (!processedSvg) {
return;
}
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [processedSvg]);
const { initialScale, calculatedHeight } = useMemo(() => {
if (!svgDimensions) {
return { initialScale: 1, calculatedHeight: MAX_CONTAINER_HEIGHT };
}
const padding = 32;
const availableWidth = containerWidth - padding;
const scaleX = availableWidth / svgDimensions.width;
const scaleY = MAX_CONTAINER_HEIGHT / svgDimensions.height;
const scale = Math.min(scaleX, scaleY, 1);
const height = Math.max(
MIN_CONTAINER_HEIGHT,
Math.min(MAX_CONTAINER_HEIGHT, svgDimensions.height * scale + padding),
);
return { initialScale: scale, calculatedHeight: height };
}, [svgDimensions, containerWidth]);
return {
blobUrl,
svgDimensions,
isLoading,
error,
lastValidSvgRef,
initialScale,
calculatedHeight,
};
}

View file

@ -1,111 +0,0 @@
import React, { memo, useState, useCallback, useRef } from 'react';
import copy from 'copy-to-clipboard';
import { Expand, ChevronUp, ChevronDown } from 'lucide-react';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidHeaderProps {
className?: string;
codeContent: string;
showCode: boolean;
showExpandButton?: boolean;
expandButtonRef?: React.RefObject<HTMLButtonElement>;
onExpand?: () => void;
onToggleCode: () => void;
}
const iconBtnClass =
'flex items-center justify-center rounded p-1.5 text-text-secondary hover:bg-surface-hover focus-visible:outline focus-visible:outline-white';
const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
({
className,
codeContent,
showCode,
showExpandButton = false,
expandButtonRef,
onExpand,
onToggleCode,
}) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const handleCopy = useCallback(() => {
copy(codeContent.trim(), { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
}, [codeContent]);
const handleToggleCode = useCallback(() => {
onToggleCode();
requestAnimationFrame(() => {
showCodeButtonRef.current?.focus();
});
}, [onToggleCode]);
return (
<div
className={cn(
'flex items-center justify-end gap-1 px-2 py-1 transition-opacity duration-200',
className,
)}
>
{showExpandButton && onExpand && (
<TooltipAnchor
description={localize('com_ui_expand')}
render={
<button
ref={expandButtonRef}
type="button"
aria-label={localize('com_ui_expand')}
className={iconBtnClass}
onClick={onExpand}
>
<Expand className="h-4 w-4" />
</button>
}
/>
)}
<TooltipAnchor
description={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
render={
<button
ref={showCodeButtonRef}
type="button"
aria-label={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
className={iconBtnClass}
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
}
/>
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
render={
<button
ref={copyButtonRef}
type="button"
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
className={iconBtnClass}
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
}
/>
</div>
);
},
);
MermaidHeader.displayName = 'MermaidHeader';
export default MermaidHeader;

View file

@ -1,3 +1,6 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface ResultSwitcherProps {
currentIndex: number;
totalCount: number;
@ -5,65 +8,47 @@ interface ResultSwitcherProps {
onNext: () => void;
}
const ResultSwitcher: React.FC<ResultSwitcherProps> = ({
export default function ResultSwitcher({
currentIndex,
totalCount,
onPrevious,
onNext,
}) => {
}: ResultSwitcherProps) {
const localize = useLocalize();
if (totalCount <= 1) {
return null;
}
const atFirst = currentIndex === 0;
const atLast = currentIndex === totalCount - 1;
return (
<div className="flex items-center justify-start gap-1 self-center bg-gray-700 pb-2 text-xs">
<nav
aria-label={localize('com_ui_navigate_results')}
className="flex items-center justify-center gap-1.5 border-t border-border-light px-3 py-1.5 text-xs"
>
<button
className="hover-button rounded-md p-1 text-gray-400 hover:bg-gray-700 hover:text-gray-200 disabled:hover:text-gray-400"
type="button"
onClick={onPrevious}
disabled={currentIndex === 0}
disabled={atFirst}
aria-label={localize('com_ui_prev_result')}
className="rounded p-0.5 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline focus:outline-2 focus:outline-border-heavy disabled:pointer-events-none disabled:opacity-30"
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<ChevronLeft className="size-3.5" aria-hidden="true" />
</button>
<span className="flex-shrink-0 tabular-nums">
{currentIndex + 1} / {totalCount}
<span className="min-w-[3ch] select-none text-center tabular-nums text-text-secondary">
{currentIndex + 1}/{totalCount}
</span>
<button
className="hover-button rounded-md p-1 text-gray-400 hover:bg-gray-700 hover:text-gray-200 disabled:hover:text-gray-400"
type="button"
onClick={onNext}
disabled={currentIndex === totalCount - 1}
disabled={atLast}
aria-label={localize('com_ui_next_result')}
className="rounded p-0.5 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline focus:outline-2 focus:outline-border-heavy disabled:pointer-events-none disabled:opacity-30"
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<ChevronRight className="size-3.5" aria-hidden="true" />
</button>
</div>
</nav>
);
};
export default ResultSwitcher;
}

View file

@ -1,14 +1,16 @@
import React, { useMemo, useCallback, useEffect } from 'react';
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { TerminalSquareIcon } from 'lucide-react';
import { Tools, AuthType } from 'librechat-data-provider';
import { TerminalSquareIcon, Check, X } from 'lucide-react';
import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import type { CodeBarProps } from '~/common';
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useCodeApiKeyForm } from '~/hooks';
import { useMessageContext } from '~/Providers';
import { cn, normalizeLanguage } from '~/utils';
import { useMessageContext } from '~/Providers';
type RunState = 'idle' | 'loading' | 'success' | 'error';
const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
({ lang, codeRef, blockIndex, iconOnly = false }) => {
@ -79,43 +81,108 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
};
}, [debouncedExecute]);
const [runState, setRunState] = useState<RunState>('idle');
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (execute.isLoading) {
setRunState('loading');
} else if (runState === 'loading') {
const next: RunState = execute.isError ? 'error' : 'success';
setRunState(next);
timerRef.current = setTimeout(() => setRunState('idle'), next === 'error' ? 2000 : 1500);
}
return () => clearTimeout(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [execute.isLoading, execute.isError]);
if (typeof normalizedLang !== 'string' || normalizedLang.length === 0) {
return null;
}
const buttonContent = (
<>
{execute.isLoading ? (
<Spinner className="animate-spin" size={18} />
) : (
<TerminalSquareIcon size={18} aria-hidden="true" />
)}
{!iconOnly && localize('com_ui_run_code')}
</>
);
const isLoading = runState === 'loading';
const isSuccess = runState === 'success';
const isError = runState === 'error';
const isIdle = runState === 'idle';
const label = localize('com_ui_run_code');
const iconClass = (active: boolean) =>
cn(
'absolute transition-all duration-300 ease-out',
active ? 'rotate-0 scale-100 opacity-100' : 'scale-0 opacity-0 rotate-90',
);
const button = (
<button
type="button"
className={cn(
'flex items-center justify-center rounded-sm hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
iconOnly ? 'p-1.5' : 'ml-auto gap-2 px-2 py-1',
)}
onClick={debouncedExecute}
disabled={execute.isLoading}
aria-label={localize('com_ui_run_code')}
disabled={isLoading}
aria-label={label}
aria-busy={isLoading || undefined}
className={cn(
'inline-flex select-none items-center justify-center text-text-secondary transition-all duration-200 ease-out',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-heavy',
'disabled:pointer-events-none disabled:opacity-50',
isError && 'text-text-destructive hover:text-text-destructive',
iconOnly ? 'rounded-lg p-1.5' : 'ml-auto gap-2 rounded-md px-2 py-1',
)}
>
{buttonContent}
<span className="relative flex size-[18px] items-center justify-center" aria-hidden="true">
<TerminalSquareIcon size={18} className={iconClass(isIdle)} />
<span
className={cn(
'absolute transition-opacity duration-300',
isLoading ? 'opacity-100' : 'opacity-0',
)}
>
<Spinner className="animate-spin" size={18} />
</span>
<Check size={18} className={iconClass(isSuccess)} />
<X size={18} className={iconClass(isError)} />
</span>
{!iconOnly && (
<span className="relative overflow-hidden">
<span
className={cn(
'block whitespace-nowrap transition-all duration-300 ease-out',
isIdle ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0',
)}
>
{localize('com_ui_run_code')}
</span>
<span
className={cn(
'absolute inset-0 whitespace-nowrap transition-all duration-300 ease-out',
isLoading ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0',
)}
>
{localize('com_ui_running')}
</span>
<span
className={cn(
'absolute inset-0 whitespace-nowrap transition-all duration-300 ease-out',
isSuccess ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0',
)}
>
{localize('com_ui_complete')}
</span>
<span
className={cn(
'absolute inset-0 whitespace-nowrap transition-all duration-300 ease-out',
isError ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0',
)}
>
{localize('com_ui_failed')}
</span>
</span>
)}
</button>
);
return (
<>
{iconOnly ? (
<TooltipAnchor description={localize('com_ui_run_code')} render={button} />
) : (
button
)}
{iconOnly ? <TooltipAnchor description={label} render={button} /> : button}
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,34 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import copy from 'copy-to-clipboard';
export default function useCopyCode(codeRef: React.RefObject<HTMLElement | null>) {
const [isCopied, setIsCopied] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
const handleCopy = useCallback(() => {
const codeString = codeRef.current?.textContent;
if (codeString == null) {
return;
}
const wasFocused = document.activeElement === buttonRef.current;
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
if (wasFocused) {
requestAnimationFrame(() => buttonRef.current?.focus());
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setIsCopied(false);
}, 3000);
}, [codeRef]);
return { isCopied, buttonRef, handleCopy };
}

View file

@ -50,11 +50,11 @@ const toggleSwitchConfigs = [
key: 'showThinking',
},
{
stateAtom: store.showCode,
localizationKey: 'com_nav_show_code' as const,
switchId: 'showCode',
stateAtom: store.autoExpandTools,
localizationKey: 'com_nav_auto_expand_tools' as const,
switchId: 'autoExpandTools',
hoverCardText: undefined,
key: 'showCode',
key: 'autoExpandTools',
},
{
stateAtom: store.LaTeXParsing,

View file

@ -10,21 +10,21 @@ import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enableUserMsgMarkdown,
localizationKey: 'com_nav_user_msg_markdown',
localizationKey: 'com_nav_user_msg_markdown' as const,
switchId: 'enableUserMsgMarkdown',
hoverCardText: undefined,
key: 'enableUserMsgMarkdown',
},
{
stateAtom: store.autoScroll,
localizationKey: 'com_nav_auto_scroll',
localizationKey: 'com_nav_auto_scroll' as const,
switchId: 'autoScroll',
hoverCardText: undefined,
key: 'autoScroll',
},
{
stateAtom: store.keepScreenAwake,
localizationKey: 'com_nav_keep_screen_awake',
localizationKey: 'com_nav_keep_screen_awake' as const,
switchId: 'keepScreenAwake',
hoverCardText: undefined,
key: 'keepScreenAwake',

View file

@ -1,12 +1,44 @@
import { memo, useState, useContext, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { Button } from '@librechat/client';
import { ChevronLeft, ChevronRight, FileText } from 'lucide-react';
import type { CitationProps } from './types';
import { SourceHovercard, FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
import { CitationContext, useCitation, useCompositeCitations } from './Context';
import { useFileDownload } from '~/data-provider';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface FileCitationMetadata {
fileBytes?: number;
fileType?: string;
}
interface FileCitationSource {
attribution?: string;
fileId?: string;
fileName?: string;
link?: string;
metadata?: FileCitationMetadata;
pageRelevance?: Record<number, number>;
pages?: number[];
refType?: string;
relevance?: number;
snippet?: string;
title?: string;
}
function getFileCitationData(source?: FileCitationSource) {
const isFileType = source?.refType === 'file' && source.fileId != null;
return {
isFileType,
fileId: isFileType ? source.fileId : undefined,
fileMeta: isFileType ? source.metadata : undefined,
fileName: isFileType ? source.fileName : undefined,
filePages: isFileType ? source.pages : undefined,
fileRelevance: isFileType ? source.relevance : undefined,
filePageRelevance: isFileType ? source.pageRelevance : undefined,
};
}
interface CompositeCitationProps {
citationId?: string;
@ -20,15 +52,20 @@ export function CompositeCitation(props: CompositeCitationProps) {
const { citations, citationId } = props.node?.properties ?? ({} as CitationProps);
const { setHoveredCitationId } = useContext(CitationContext);
const [currentPage, setCurrentPage] = useState(0);
const [showPreview, setShowPreview] = useState(false);
const sources = useCompositeCitations(citations || []);
if (!sources || sources.length === 0) return null;
if (!sources || sources.length === 0) {
return null;
}
const totalPages = sources.length;
const getCitationLabel = () => {
if (!sources || sources.length === 0) return localize('com_citation_source');
if (!sources || sources.length === 0) {
return localize('com_citation_source');
}
const firstSource = sources[0];
const firstSource = sources[0] as FileCitationSource;
const remainingCount = sources.length - 1;
const attribution =
firstSource.attribution ||
@ -55,56 +92,190 @@ export function CompositeCitation(props: CompositeCitationProps) {
}
};
const currentSource = sources?.[currentPage];
const currentSource = sources[currentPage] as FileCitationSource;
const { isFileType, fileId, fileMeta, fileName, filePages, fileRelevance, filePageRelevance } =
getFileCitationData(currentSource);
const handleFileClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isFileType && fileId) {
setShowPreview(true);
}
};
return (
<SourceHovercard
source={currentSource}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
>
{totalPages > 1 && (
<span className="mb-2 flex items-center justify-between border-b border-border-heavy pb-2">
<span className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={currentPage === 0}
style={{ opacity: currentPage === 0 ? 0.5 : 1 }}
className="flex cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base"
>
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages - 1}
style={{ opacity: currentPage === totalPages - 1 ? 0.5 : 1 }}
className="flex cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base"
>
</button>
</span>
<span className="text-xs text-text-tertiary">
{currentPage + 1}/{totalPages}
</span>
</span>
<>
<SourceHovercard
source={currentSource}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType ? handleFileClick : undefined}
isFile={isFileType}
filePages={filePages}
fileRelevance={fileRelevance}
>
{isFileType ? (
<>
<div className="flex items-center gap-2">
<FileText className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<button
onClick={handleFileClick}
className="min-w-0 truncate text-sm font-medium text-text-primary hover:underline"
>
{fileName || currentSource.title || localize('com_file_source')}
</button>
</div>
{(fileRelevance != null || (filePages && filePages.length > 0)) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{fileRelevance != null && fileRelevance > 0 && (
<span className="text-xs text-text-secondary">
{localize('com_ui_relevance')}: {Math.round(fileRelevance * 100)}%
</span>
)}
{filePages && filePages.length > 0 && (
<span className="text-xs text-text-secondary">
{localize('com_file_pages', { pages: filePages.join(', ') })}
</span>
)}
</div>
)}
{currentSource.snippet && (
<p className="mt-1.5 line-clamp-3 break-words text-xs leading-relaxed text-text-secondary">
{currentSource.snippet}
</p>
)}
{totalPages > 1 && (
<div className="mt-2 flex items-center gap-1 border-t border-border-light pt-2">
<Button
size="icon"
variant="ghost"
onClick={handlePrevPage}
disabled={currentPage === 0}
className="size-7"
aria-label="Previous source"
>
<ChevronLeft className="size-3.5" aria-hidden="true" />
</Button>
{sources.map((source, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCurrentPage(i);
}}
className={`flex size-6 items-center justify-center rounded text-xs transition-colors ${
i === currentPage
? 'bg-surface-hover font-medium text-text-primary'
: 'text-text-secondary hover:bg-surface-hover'
}`}
aria-label={`Source ${i + 1}`}
aria-current={i === currentPage ? 'true' : undefined}
>
{i + 1}
</button>
))}
<Button
size="icon"
variant="ghost"
onClick={handleNextPage}
disabled={currentPage === totalPages - 1}
className="size-7"
aria-label="Next source"
>
<ChevronRight className="size-3.5" aria-hidden="true" />
</Button>
</div>
)}
</>
) : (
<>
<div className="mb-1.5 overflow-hidden text-sm">
<FaviconImage
domain={getCleanDomain(currentSource.link || '')}
className="float-left mr-2 mt-0.5"
/>
<span className="float-right ml-2 max-w-[40%] truncate text-xs text-text-secondary">
{getCleanDomain(currentSource.link || '')}
</span>
<a
href={currentSource.link}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:underline"
>
{currentSource.title}
</a>
</div>
{currentSource.snippet && (
<p className="line-clamp-4 break-words text-xs text-text-secondary md:text-sm">
{currentSource.snippet}
</p>
)}
{totalPages > 1 && (
<div className="flex items-center gap-1 border-t border-border-light pt-2">
<Button
size="icon"
variant="ghost"
onClick={handlePrevPage}
disabled={currentPage === 0}
className="size-7"
aria-label="Previous source"
>
<ChevronLeft className="size-3.5" aria-hidden="true" />
</Button>
{sources.map((source, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCurrentPage(i);
}}
className={`flex size-6 items-center justify-center rounded text-xs transition-colors ${
i === currentPage
? 'bg-surface-hover font-medium text-text-primary'
: 'text-text-secondary hover:bg-surface-hover'
}`}
aria-label={`Source ${i + 1}`}
aria-current={i === currentPage ? 'true' : undefined}
>
{i + 1}
</button>
))}
<Button
size="icon"
variant="ghost"
onClick={handleNextPage}
disabled={currentPage === totalPages - 1}
className="size-7"
aria-label="Next source"
>
<ChevronRight className="size-3.5" aria-hidden="true" />
</Button>
</div>
)}
</>
)}
</SourceHovercard>
{isFileType && fileId && (
<FilePreviewDialog
open={showPreview}
onOpenChange={setShowPreview}
fileName={fileName || currentSource.title || ''}
fileId={fileId}
relevance={fileRelevance}
pages={filePages}
pageRelevance={filePageRelevance}
fileType={fileMeta?.fileType}
fileSize={fileMeta?.fileBytes}
/>
)}
<span className="mb-2 flex items-center">
<FaviconImage domain={getCleanDomain(currentSource.link || '')} className="mr-2" />
<a
href={currentSource.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
>
{currentSource.attribution}
</a>
</span>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">{currentSource.title}</h4>
<p className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{currentSource.snippet}
</p>
</SourceHovercard>
</>
);
}
@ -118,69 +289,33 @@ interface CitationComponentProps {
export function Citation(props: CitationComponentProps) {
const localize = useLocalize();
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const { citation, citationId } = props.node?.properties ?? {};
const { setHoveredCitationId } = useContext(CitationContext);
const refData = useCitation({
turn: citation?.turn || 0,
refType: citation?.refType,
index: citation?.index || 0,
});
}) as FileCitationSource | undefined;
// Setup file download hook
const isFileType = refData?.refType === 'file' && (refData as any)?.fileId;
const isLocalFile = isFileType && (refData as any)?.metadata?.storageType === 'local';
const { refetch: downloadFile } = useFileDownload(
user?.id ?? '',
isFileType && !isLocalFile ? (refData as any).fileId : '',
);
const { isFileType, fileId, fileMeta, fileName, filePages, fileRelevance, filePageRelevance } =
getFileCitationData(refData);
const handleFileDownload = useCallback(
async (e: React.MouseEvent) => {
const [showPreview, setShowPreview] = useState(false);
const handleFileClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isFileType || !(refData as any)?.fileId) return;
// Don't allow download for local files
if (isLocalFile) {
showToast({
status: 'error',
message: localize('com_sources_download_local_unavailable'),
});
return;
}
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', (refData as any).fileName || 'file');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
if (isFileType && fileId) {
setShowPreview(true);
}
},
[downloadFile, isFileType, isLocalFile, refData, localize, showToast],
[isFileType, fileId],
);
if (!refData) return null;
if (!refData) {
return null;
}
const getCitationLabel = () => {
return (
@ -192,15 +327,31 @@ export function Citation(props: CitationComponentProps) {
};
return (
<SourceHovercard
source={refData}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType && !isLocalFile ? handleFileDownload : undefined}
isFile={isFileType}
isLocalFile={isLocalFile}
/>
<>
<SourceHovercard
source={refData}
label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType ? handleFileClick : undefined}
isFile={isFileType}
filePages={filePages}
fileRelevance={fileRelevance}
/>
{isFileType && fileId && (
<FilePreviewDialog
open={showPreview}
onOpenChange={setShowPreview}
fileName={fileName || refData.title || ''}
fileId={fileId}
relevance={fileRelevance}
pages={filePages}
pageRelevance={filePageRelevance}
fileType={fileMeta?.fileType}
fileSize={fileMeta?.fileBytes}
/>
)}
</>
);
}

View file

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown, Paperclip } from 'lucide-react';
import { ChevronDown, FileText } from 'lucide-react';
import { VisuallyHidden } from '@ariakit/react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -21,14 +21,14 @@ interface SourceHovercardProps {
isFile?: boolean;
isLocalFile?: boolean;
children?: ReactNode;
filePages?: number[];
fileRelevance?: number;
}
/** Helper to get domain favicon */
function getFaviconUrl(domain: string) {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
}
/** Helper to get clean domain name */
export function getCleanDomain(url: string) {
const domain = url.replace(/(^\w+:|^)\/\//, '').split('/')[0];
return domain.startsWith('www.') ? domain.substring(4) : domain;
@ -36,11 +36,67 @@ export function getCleanDomain(url: string) {
export function FaviconImage({ domain, className = '' }: { domain: string; className?: string }) {
return (
<div className={cn('relative size-4 flex-shrink-0 overflow-hidden rounded-full', className)}>
<div className="absolute inset-0 rounded-full bg-white" />
<img src={getFaviconUrl(domain)} alt={domain} className="relative size-full" />
<div className="border-border-light/10 absolute inset-0 rounded-full border dark:border-transparent"></div>
</div>
<img
src={getFaviconUrl(domain)}
alt={domain}
className={cn('size-4 shrink-0 rounded-full', className)}
loading="lazy"
/>
);
}
const hovercardClass = cn(
'z-[999] w-[320px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg',
'origin-top -translate-y-1 opacity-0 transition-[opacity,transform] duration-150 ease-out',
'data-[enter]:translate-y-0 data-[enter]:opacity-100',
'data-[leave]:-translate-y-1 data-[leave]:opacity-0',
);
function FileHovercardContent({
source,
onClick,
filePages,
fileRelevance,
}: {
source: SourceData;
onClick?: (e: React.MouseEvent) => void;
filePages?: number[];
fileRelevance?: number;
}) {
const localize = useLocalize();
const fileName = source.attribution || source.title || localize('com_file_source');
return (
<>
<div className="flex items-center gap-2">
<FileText className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<button
onClick={onClick}
className="min-w-0 truncate text-sm font-medium text-text-primary hover:underline"
>
{fileName}
</button>
</div>
{(fileRelevance != null || (filePages && filePages.length > 0)) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{fileRelevance != null && fileRelevance > 0 && (
<span className="text-xs text-text-secondary">
{localize('com_ui_relevance')}: {Math.round(fileRelevance * 100)}%
</span>
)}
{filePages && filePages.length > 0 && (
<span className="text-xs text-text-secondary">
{localize('com_file_pages', { pages: filePages.join(', ') })}
</span>
)}
</div>
)}
{source.snippet && (
<p className="mt-1.5 line-clamp-3 break-words text-xs leading-relaxed text-text-secondary">
{source.snippet}
</p>
)}
</>
);
}
@ -53,26 +109,38 @@ export function SourceHovercard({
isFile = false,
isLocalFile = false,
children,
filePages,
fileRelevance,
}: SourceHovercardProps) {
const localize = useLocalize();
const domain = getCleanDomain(source.link || '');
const hovercard = Ariakit.useHovercardStore({ showTimeout: 150, hideTimeout: 150 });
const handleFileClick = React.useCallback(
(e: React.MouseEvent) => {
hovercard.hide();
onClick?.(e);
},
[hovercard, onClick],
);
return (
<span className="relative ml-0.5 inline-block">
<Ariakit.HovercardProvider showTimeout={150} hideTimeout={150}>
<Ariakit.HovercardProvider store={hovercard}>
<span className="flex items-center">
<Ariakit.HovercardAnchor
render={
isFile ? (
<button
onClick={onClick}
className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium text-blue-600 no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:text-blue-400 dark:hover:bg-surface-tertiary"
onClick={handleFileClick}
className="ml-1 inline-flex h-5 max-w-36 items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium text-text-primary no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:hover:bg-surface-tertiary"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
title={
isLocalFile ? localize('com_sources_download_local_unavailable') : undefined
}
>
<FileText className="size-2.5 shrink-0 text-text-secondary" aria-hidden="true" />
{label}
</button>
) : (
@ -96,62 +164,43 @@ export function SourceHovercard({
<Ariakit.Hovercard
gutter={16}
className="dark:shadow-lg-dark z-[999] w-[300px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg"
className={hovercardClass}
portal={true}
unmountOnHide={true}
>
{children}
{!children && (
<>
<span className="mb-2 flex items-center">
{isFile ? (
<div className="mr-2 flex h-4 w-4 items-center justify-center">
<Paperclip className="h-3 w-3 text-text-secondary" />
</div>
) : (
<FaviconImage domain={domain} className="mr-2" />
)}
{isFile ? (
<button
onClick={onClick}
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || source.title || localize('com_file_source')}
</button>
) : (
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || domain}
</a>
)}
</span>
{isFile ? (
<>
{source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
)}
</>
<div>
{children ??
(isFile ? (
<FileHovercardContent
source={source}
onClick={handleFileClick}
filePages={filePages}
fileRelevance={fileRelevance}
/>
) : (
<>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">
{source.title || source.link}
</h4>
{source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
{source.snippet}
<div className="mb-1.5 overflow-hidden text-sm">
<FaviconImage domain={domain} className="float-left mr-2 mt-0.5" />
<span className="float-right ml-2 max-w-[40%] truncate text-xs text-text-secondary">
{domain}
</span>
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:underline"
>
{source.title || source.link}
</a>
</div>
{source.snippet && (
<p className="line-clamp-4 break-words text-xs text-text-secondary md:text-sm">
{source.snippet}
</p>
)}
</>
)}
</>
)}
))}
</div>
</Ariakit.Hovercard>
</span>
</Ariakit.HovercardProvider>

View file

@ -19,6 +19,7 @@ import SourcesErrorBoundary from './SourcesErrorBoundary';
import { useFileDownload } from '~/data-provider';
import { useSearchContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
interface SourceItemProps {
@ -88,39 +89,45 @@ function SourceItem({ source, expanded = false }: SourceItemProps) {
</Ariakit.HovercardDisclosure>
<Ariakit.Hovercard
animated
gutter={16}
className="dark:shadow-lg-dark z-[999] w-[300px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg"
className={cn(
'z-[999] w-[320px] max-w-[calc(100vw-2rem)] rounded-xl border border-border-medium bg-surface-secondary p-3 text-text-primary shadow-lg',
'origin-top-left scale-95 opacity-0 transition-[opacity,transform] duration-150 ease-out',
'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[leave]:scale-95 data-[leave]:opacity-0',
)}
portal={true}
unmountOnHide={true}
>
<div className="flex gap-3">
<div className="flex-1">
<div className="mb-2 flex items-center">
<FaviconImage domain={domain} className="mr-2" />
<div className="min-w-0 flex-1">
<div className="mb-1.5 overflow-hidden text-sm">
<FaviconImage domain={domain} className="float-left mr-2 mt-0.5" />
<span className="float-right ml-2 max-w-[40%] truncate text-xs text-text-secondary">
{domain}
</span>
<a
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
className="font-medium text-text-primary hover:underline"
>
{source.attribution || domain}
{source.title || source.link}
</a>
</div>
<h4 className="mb-1.5 mt-0 text-xs text-text-primary md:text-sm">
{source.title || source.link}
</h4>
{'snippet' in source && source.snippet && (
<span className="my-2 text-ellipsis break-all text-xs text-text-secondary md:text-sm">
<p className="line-clamp-4 break-words text-xs text-text-secondary md:text-sm">
{source.snippet}
</span>
</p>
)}
</div>
{'imageUrl' in source && source.imageUrl && (
<div className="h-24 w-24 flex-shrink-0 overflow-hidden rounded-md">
<div className="size-24 shrink-0 overflow-hidden rounded-md">
<img
src={source.imageUrl}
alt={source.title || localize('com_sources_image_alt')}
className="h-full w-full object-cover"
className="size-full object-cover"
/>
</div>
)}
@ -298,10 +305,10 @@ const FileItem = React.memo(function FileItem({
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_agent_file')}
</span>
{!isLocalFile && <Download className="ml-auto h-3 w-3" aria-hidden="true" />}
{!isLocalFile && <Download className="ml-auto size-3" aria-hidden="true" />}
</div>
<div className="mt-1 min-w-0">
<span className="line-clamp-2 break-all text-left text-sm font-medium text-text-primary md:line-clamp-3">
<span className="line-clamp-2 break-words text-left text-sm font-medium text-text-primary md:line-clamp-3">
{file.filename}
</span>
{file.pages && file.pages.length > 0 && (
@ -337,10 +344,10 @@ const FileItem = React.memo(function FileItem({
<span className="truncate text-xs font-medium text-text-secondary">
{localize('com_sources_agent_file')}
</span>
{!isLocalFile && <Download className="ml-auto h-3 w-3" aria-hidden="true" />}
{!isLocalFile && <Download className="ml-auto size-3" aria-hidden="true" />}
</div>
<div className="mt-1 min-w-0">
<span className="line-clamp-2 break-all text-left text-sm font-medium text-text-primary md:line-clamp-3">
<span className="line-clamp-2 break-words text-left text-sm font-medium text-text-primary md:line-clamp-3">
{file.filename}
</span>
{file.pages && file.pages.length > 0 && (
@ -428,7 +435,7 @@ const SourcesGroup = React.memo(function SourcesGroup({
className="rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary"
aria-label={localize('com_ui_close')}
>
<X className="h-4 w-4" aria-hidden="true" />
<X className="size-4" aria-hidden="true" />
</OGDialogClose>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
@ -506,7 +513,7 @@ function FilesGroup({ files, messageId, conversationId, limit = 3 }: FilesGroupP
<div className="flex items-center gap-2">
<div className="relative flex">
{remainingFiles.slice(0, 3).map((_, i) => (
<File key={`file-icon-${i}`} className={`h-4 w-4 ${i > 0 ? 'ml-[-6px]' : ''}`} />
<File key={`file-icon-${i}`} className={`size-4 ${i > 0 ? 'ml-[-6px]' : ''}`} />
))}
</div>
<span className="truncate text-xs font-medium text-text-secondary">
@ -524,7 +531,7 @@ function FilesGroup({ files, messageId, conversationId, limit = 3 }: FilesGroupP
className="rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary"
aria-label={localize('com_ui_close')}
>
<X className="h-4 w-4" aria-hidden="true" />
<X className="size-4" aria-hidden="true" />
</OGDialogClose>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">

View file

@ -0,0 +1,145 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { SearchResultData } from 'librechat-data-provider';
import { Citation, CompositeCitation } from '~/components/Web/Citation';
import { CitationContext } from '~/components/Web/Context';
import { SearchContext } from '~/Providers';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: { label?: string; pages?: string }) => {
if (key === 'com_citation_source') {
return 'Source';
}
if (key === 'com_citation_more_details') {
return `More details about ${values?.label ?? ''}`;
}
if (key === 'com_ui_relevance') {
return 'Relevance';
}
if (key === 'com_file_pages') {
return `Pages: ${values?.pages ?? ''}`;
}
if (key === 'com_file_source') {
return 'File source';
}
return key;
},
}));
jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({
__esModule: true,
default: ({ open, fileId, fileName }: { open: boolean; fileId?: string; fileName: string }) =>
open ? (
<div data-testid="file-preview-dialog" data-file-id={fileId}>
{fileName}
</div>
) : null,
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, onClick, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
function renderWithProviders(
children: React.ReactNode,
searchResults: Record<string, SearchResultData>,
) {
return render(
<SearchContext.Provider value={{ searchResults }}>
<CitationContext.Provider
value={{
hoveredCitationId: null,
setHoveredCitationId: jest.fn(),
}}
>
{children}
</CitationContext.Provider>
</SearchContext.Provider>,
);
}
describe('Citation', () => {
it('renders composite file citations as buttons and opens the preview dialog', () => {
const searchResults = {
'0': {
references: [
{
attribution: 'Tutorial Imazing.pdf',
fileId: 'file-123',
fileName: 'Tutorial Imazing.pdf',
link: '#file-123',
metadata: {
fileBytes: 2048,
fileType: 'application/pdf',
},
pageRelevance: { 1: 0.92 },
pages: [1],
relevance: 0.92,
title: 'Tutorial Imazing.pdf',
type: 'file',
},
],
},
};
renderWithProviders(
<CompositeCitation
node={{
properties: {
citationId: 'cite-1',
citations: [{ turn: 0, refType: 'file', index: 0 }],
},
}}
/>,
searchResults as any,
);
const fileButton = screen.getByRole('button', { name: 'Tutorial Imazing.pdf' });
expect(fileButton).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Tutorial Imazing.pdf' })).not.toBeInTheDocument();
fireEvent.click(fileButton);
expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-id', 'file-123');
});
it('keeps standalone web citations as links', () => {
const searchResults = {
'0': {
organic: [
{
attribution: 'example.com',
link: 'https://example.com',
snippet: 'Example snippet',
title: 'Example',
},
],
},
};
renderWithProviders(
<Citation
citationId="cite-2"
citationType="standalone"
node={{
properties: {
citation: { turn: 0, refType: 'search', index: 0 },
citationId: 'cite-2',
},
}}
/>,
searchResults as any,
);
expect(screen.getByRole('link', { name: 'example.com' })).toHaveAttribute(
'href',
'https://example.com',
);
});
});

View file

@ -1,5 +1,7 @@
export * from './useMCPConnectionStatus';
export * from './useMCPSelect';
export * from './useVisibleTools';
export * from './useMCPServerManager';
export * from './useMCPConnectionStatus';
export { useMCPIconMap } from './useMCPIconMap';
export { useRemoveMCPTool } from './useRemoveMCPTool';

View file

@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { useMCPServersQuery } from '~/data-provider';
export function useMCPIconMap(): Map<string, string> {
const { data: servers } = useMCPServersQuery();
return useMemo(() => {
const map = new Map<string, string>();
if (!servers) {
return map;
}
for (const [serverName, config] of Object.entries(servers)) {
if (config.iconPath) {
map.set(serverName, config.iconPath);
}
}
return map;
}, [servers]);
}

View file

@ -1,10 +1,12 @@
export { default as useProgress } from './useProgress';
export { EXPAND_TRANSITION } from './useExpandCollapse';
export { default as useAttachments } from './useAttachments';
export { default as useSubmitMessage } from './useSubmitMessage';
export type { ContentMetadataResult } from './useContentMetadata';
export { default as useExpandCollapse } from './useExpandCollapse';
export { default as useMessageActions } from './useMessageActions';
export { default as useMessageProcess } from './useMessageProcess';
export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useCopyToClipboard } from './useCopyToClipboard';
export { default as useMessageScrolling } from './useMessageScrolling';
export { default as useContentMetadata } from './useContentMetadata';
export type { ContentMetadataResult } from './useContentMetadata';
export { default as useMessageScrolling } from './useMessageScrolling';

View file

@ -0,0 +1,37 @@
import { useRef, useLayoutEffect, useMemo } from 'react';
import type { CSSProperties } from 'react';
export const EXPAND_TRANSITION =
'grid-template-rows 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
export default function useExpandCollapse(isExpanded: boolean): {
style: CSSProperties;
ref: React.RefObject<HTMLDivElement>;
} {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) {
return;
}
if (isExpanded) {
el.removeAttribute('inert');
} else {
el.setAttribute('inert', '');
}
}, [isExpanded]);
const style = useMemo<CSSProperties>(
() => ({
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
transition: EXPAND_TRANSITION,
opacity: isExpanded ? 1 : 0,
}),
[isExpanded],
);
return { style, ref };
}

View file

@ -104,7 +104,6 @@
"com_agents_start_chat": "Start Chat",
"com_agents_top_picks": "Top Picks",
"com_agents_update_error": "There was an error updating your agent.",
"com_assistants_action_attempt": "Assistant wants to talk to {{0}}",
"com_assistants_actions": "Actions",
"com_assistants_actions_disabled": "You need to create an assistant before adding actions.",
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
@ -114,7 +113,6 @@
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
"com_assistants_append_date": "Append Current Date & Time",
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
"com_assistants_attempt_info": "Assistant wants to send the following:",
"com_assistants_available_actions": "Available Actions",
"com_assistants_capabilities": "Capabilities",
"com_assistants_code_interpreter": "Code Interpreter",
@ -129,7 +127,6 @@
"com_assistants_delete_actions_error": "There was an error deleting the action.",
"com_assistants_delete_actions_success": "Successfully deleted Action from Assistant",
"com_assistants_description_placeholder": "Optional: Describe your Assistant here",
"com_assistants_domain_info": "Assistant sent this info to {{0}}",
"com_assistants_file_search": "File Search",
"com_assistants_file_search_info": "File search enables the assistant with knowledge from files that you or your users upload. Once a file is uploaded, the assistant automatically decides when to retrieve content based on user requests. Attaching vector stores for File Search is not yet supported. You can attach them from the Provider Playground or attach files to messages for file search on a thread basis.",
"com_assistants_function_use": "Assistant used {{0}}",
@ -411,6 +408,7 @@
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
"com_nav_audio_play_error": "Error playing audio: {{0}}",
"com_nav_audio_process_error": "Error processing audio: {{0}}",
"com_nav_auto_expand_tools": "Auto-expand tool details",
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
"com_nav_auto_send_prompts": "Send prompts on select",
"com_nav_auto_send_prompts_desc": "Automatically submit prompt to chat when selected",
@ -587,7 +585,6 @@
"com_nav_setting_speech": "Speech",
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_show_code": "Always show code when using code interpreter",
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
@ -839,6 +836,7 @@
"com_ui_collapse_thoughts": "Collapse Thoughts",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
"com_ui_complete": "Complete!",
"com_ui_complete_setup": "Complete Setup",
"com_ui_concise": "Concise",
"com_ui_configure": "Configure",
@ -912,6 +910,7 @@
"com_ui_date_yesterday": "Yesterday",
"com_ui_decline": "I do not accept",
"com_ui_default_post_request": "Default (POST request)",
"com_ui_details": "Details",
"com_ui_delete": "Delete",
"com_ui_delete_action": "Delete Action",
"com_ui_delete_action_confirm": "Are you sure you want to delete this action?",
@ -1009,8 +1008,10 @@
"com_ui_feedback_tag_not_matched": "Didn't match my request",
"com_ui_feedback_tag_other": "Other issue",
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_failed": "Failed",
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
"com_ui_field_required": "This field is required",
"com_ui_file": "File",
"com_ui_file_input_avatar_label": "File input for avatar",
"com_ui_file_size": "File Size",
"com_ui_file_token_limit": "File Token Limit",
@ -1050,6 +1051,7 @@
"com_ui_fork_visible": "Visible messages only",
"com_ui_generate_qrcode": "Generate QR Code",
"com_ui_generating": "Generating...",
"com_ui_generating_image": "Generating image...",
"com_ui_generation_settings": "Generation Settings",
"com_ui_getting_started": "Getting Started",
"com_ui_global_group": "Global prompt",
@ -1075,6 +1077,7 @@
"com_ui_image_details": "Image Details",
"com_ui_image_edited": "Image edited",
"com_ui_image_gen": "Image Gen",
"com_ui_image_gen_failed": "Image generation failed",
"com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations",
"com_ui_import_conversation_file_type_error": "Unsupported import type",
@ -1200,10 +1203,12 @@
"com_ui_my_prompts": "My Prompts",
"com_ui_name": "Name",
"com_ui_name_sort": "Sort by Name",
"com_ui_navigate_results": "Navigate results",
"com_ui_new": "New",
"com_ui_new_chat": "New chat",
"com_ui_new_conversation_title": "New Conversation Title",
"com_ui_next": "Next",
"com_ui_next_result": "Next result",
"com_ui_no": "No",
"com_ui_no_api_keys": "No API keys yet. Create one to get started.",
"com_ui_no_auth": "None (Auto-detect)",
@ -1249,6 +1254,7 @@
"com_ui_openai": "OpenAI",
"com_ui_optional": "(optional)",
"com_ui_options": "options",
"com_ui_output": "Output",
"com_ui_page": "Page",
"com_ui_pagination": "Pagination",
"com_ui_people": "people",
@ -1262,7 +1268,9 @@
"com_ui_pin": "Pin",
"com_ui_preferences_updated": "Preferences updated successfully",
"com_ui_prev": "Prev",
"com_ui_prev_result": "Previous result",
"com_ui_preview": "Preview",
"com_ui_preview_unavailable": "Preview not available for this file type",
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_production": "Production",
@ -1298,6 +1306,7 @@
"com_ui_reference_saved_memories": "Reference saved memories",
"com_ui_reference_saved_memories_description": "Allow the assistant to reference and use your saved memories when responding",
"com_ui_refresh": "Refresh",
"com_ui_relevance": "Relevance",
"com_ui_refresh_link": "Refresh link",
"com_ui_refresh_page": "Refresh page",
"com_ui_regenerate": "Regenerate",
@ -1334,6 +1343,7 @@
"com_ui_result": "Result",
"com_ui_result_found": "{{count}} result found",
"com_ui_results_found": "{{count}} results found",
"com_ui_retrieved_files": "Searched your files",
"com_ui_retry": "Retry",
"com_ui_revoke": "Revoke",
"com_ui_revoke_info": "Revoke all user provided credentials",
@ -1358,6 +1368,7 @@
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_run_code": "Run Code",
"com_ui_run_code_error": "There was an error running the code",
"com_ui_running": "Running...",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save Changes",
@ -1377,6 +1388,7 @@
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
"com_ui_search_result_count": "{{count}} result found",
"com_ui_search_results_count": "{{count}} results found",
"com_ui_searching_files": "Searching your files",
"com_ui_seconds": "seconds",
"com_ui_secret_key": "Secret Key",
"com_ui_select": "Select",
@ -1412,6 +1424,8 @@
"com_ui_show_all": "Show All",
"com_ui_show_code": "Show Code",
"com_ui_show_image_details": "Show Image Details",
"com_ui_show_less": "Show less",
"com_ui_show_more": "Show more",
"com_ui_show_password": "Show password",
"com_ui_show_qr": "Show QR Code",
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
@ -1459,9 +1473,16 @@
"com_ui_token_exchange_method": "Token Exchange Method",
"com_ui_token_url": "Token URL",
"com_ui_tokens": "tokens",
"com_ui_tool_failed": "failed",
"com_ui_tool_collection_prefix": "A collection of tools from",
"com_ui_tool_list_collapse": "Collapse {{serverName}} tool list",
"com_ui_tool_list_expand": "Expand {{serverName}} tool list",
"com_ui_tool_name_code": "Code",
"com_ui_tool_name_code_analysis": "Code Analysis",
"com_ui_tool_name_file_search": "File Search",
"com_ui_tool_name_image_edit": "Image Edit",
"com_ui_tool_name_image_gen": "Image Generation",
"com_ui_tool_name_web_search": "Web Search",
"com_ui_tools": "Tools",
"com_ui_tools_and_actions": "Tools and Actions",
"com_ui_transferred_to": "Transferred to",
@ -1470,7 +1491,6 @@
"com_ui_try_adjusting_search": "Try adjusting your search terms",
"com_ui_ui_resource_error": "UI Resource Error ({{0}})",
"com_ui_ui_resource_not_found": "UI Resource not found (index: {{0}})",
"com_ui_ui_resources": "UI Resources",
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
@ -1508,12 +1528,15 @@
"com_ui_use_micrphone": "Use microphone",
"com_ui_use_prompt": "Use Prompt",
"com_ui_used": "Used",
"com_ui_used_n_tools": "Used {{0}} tools",
"com_ui_parameters": "Parameters",
"com_ui_user": "User",
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_user_provides_key": "Each user provides their own key",
"com_ui_value": "Value",
"com_ui_variables": "Variables",
"com_ui_variable_with_options": "{{name}} variable with {{count}} options",
"com_ui_via_server": "in {{0}}",
"com_ui_verify": "Verify",
"com_ui_version_var": "Version {{0}}",
"com_ui_versions": "Versions",
@ -1542,6 +1565,9 @@
"com_ui_web_search_scraper_serper_key": "Get your Serper API key",
"com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)",
"com_ui_web_search_searxng_instance_url": "SearXNG Instance URL",
"com_ui_web_search_source": "{{count}} source",
"com_ui_web_search_sources": "{{count}} sources",
"com_ui_web_searched": "Searched the web",
"com_ui_web_searching": "Searching the web",
"com_ui_web_searching_again": "Searching the web again",
"com_ui_weekend_morning": "Happy weekend",

View file

@ -31,7 +31,7 @@ const localStorageAtoms = {
enterToSend: atomWithLocalStorage('enterToSend', true),
maximizeChatSpace: atomWithLocalStorage('maximizeChatSpace', false),
chatDirection: atomWithLocalStorage('chatDirection', 'LTR'),
showCode: atomWithLocalStorage(LocalStorageKeys.SHOW_ANALYSIS_CODE, true),
autoExpandTools: atomWithLocalStorage(LocalStorageKeys.AUTO_EXPAND_TOOLS, false),
saveDrafts: atomWithLocalStorage('saveDrafts', true),
showScrollButton: atomWithLocalStorage('showScrollButton', true),
forkSetting: atomWithLocalStorage('forkSetting', ''),

View file

@ -1259,7 +1259,7 @@ code[class*='language-'],
pre[class*='language-'] {
word-wrap: normal;
background: none;
color: #fff;
color: var(--gray-800);
-webkit-hyphens: none;
hyphens: none;
font-size: 0.85rem;
@ -1281,27 +1281,27 @@ pre[class*='language-'] {
white-space: normal;
}
.hljs-comment {
color: hsla(0, 0%, 100%, 0.5);
color: var(--gray-500);
}
.hljs-meta {
color: hsla(0, 0%, 100%, 0.6);
color: var(--gray-600);
}
.hljs-built_in,
.hljs-class .hljs-title {
color: #e9950c;
color: #9a6700;
}
.hljs-doctag,
.hljs-formula,
.hljs-keyword,
.hljs-literal {
color: #2e95d3;
color: #0550ae;
}
.hljs-addition,
.hljs-attribute,
.hljs-meta-string,
.hljs-regexp,
.hljs-string {
color: #00a67d;
color: #0a7b62;
}
.hljs-attr,
.hljs-number,
@ -1311,13 +1311,58 @@ pre[class*='language-'] {
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #df3079;
color: #9a2f6a;
}
.hljs-bullet,
.hljs-link,
.hljs-selector-id,
.hljs-symbol,
.hljs-title {
color: #b42318;
}
.dark code.hljs,
.dark code[class*='language-'],
.dark pre[class*='language-'] {
color: #fff;
}
.dark .hljs-comment {
color: hsla(0, 0%, 100%, 0.5);
}
.dark .hljs-meta {
color: hsla(0, 0%, 100%, 0.6);
}
.dark .hljs-built_in,
.dark .hljs-class .hljs-title {
color: #e9950c;
}
.dark .hljs-doctag,
.dark .hljs-formula,
.dark .hljs-keyword,
.dark .hljs-literal {
color: #2e95d3;
}
.dark .hljs-addition,
.dark .hljs-attribute,
.dark .hljs-meta-string,
.dark .hljs-regexp,
.dark .hljs-string {
color: #00a67d;
}
.dark .hljs-attr,
.dark .hljs-number,
.dark .hljs-selector-attr,
.dark .hljs-selector-class,
.dark .hljs-selector-pseudo,
.dark .hljs-template-variable,
.dark .hljs-type,
.dark .hljs-variable {
color: #df3079;
}
.dark .hljs-bullet,
.dark .hljs-link,
.dark .hljs-selector-id,
.dark .hljs-symbol,
.dark .hljs-title {
color: #f22c3d;
}
@ -2532,7 +2577,7 @@ html {
}
.message-content pre code {
font-size: calc(0.85 * var(--markdown-font-size, var(--font-size-base)));
font-size: calc(0.9 * var(--markdown-font-size, var(--font-size-base)));
}
.message-content pre {
@ -2541,7 +2586,7 @@ html {
.code-analyze-block pre code,
.code-analyze-block .overflow-y-auto code {
font-size: calc(0.85 * var(--markdown-font-size, var(--font-size-base)));
font-size: calc(0.9 * var(--markdown-font-size, var(--font-size-base)));
}
.code-analyze-block pre,
@ -2549,9 +2594,14 @@ html {
font-size: var(--markdown-font-size, var(--font-size-base));
}
.tool-status-text {
font-size: calc(0.9 * var(--markdown-font-size, var(--font-size-base)));
line-height: calc(1.25 * 0.9 * var(--markdown-font-size, var(--font-size-base)));
}
.progress-text-wrapper {
font-size: var(--markdown-font-size, var(--font-size-base));
line-height: calc(1.25 * var(--markdown-font-size, var(--font-size-base)));
font-size: calc(0.9 * var(--markdown-font-size, var(--font-size-base)));
line-height: calc(1.25 * 0.9 * var(--markdown-font-size, var(--font-size-base)));
}
.progress-text-content {

View file

@ -0,0 +1,89 @@
import { scaleImage } from '~/utils/scaleImage';
import type { RefObject } from 'react';
function makeContainerRef(clientWidth: number): RefObject<HTMLDivElement> {
return {
current: { clientWidth } as HTMLDivElement,
};
}
const originalInnerHeight = window.innerHeight;
beforeEach(() => {
Object.defineProperty(window, 'innerHeight', { value: 1000, writable: true });
});
afterEach(() => {
Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true });
});
describe('scaleImage', () => {
it('returns auto dimensions when containerRef is null', () => {
const result = scaleImage({
originalWidth: 1024,
originalHeight: 1024,
containerRef: { current: null },
});
expect(result).toEqual({ width: 'auto', height: 'auto' });
});
it('scales a square image to fit container width, clamped by max height', () => {
// container=512, but 512px height exceeds 45vh (450px), so clamped
const result = scaleImage({
originalWidth: 1024,
originalHeight: 1024,
containerRef: makeContainerRef(512),
});
expect(result).toEqual({ width: '450px', height: '450px' });
});
it('scales to container width when height stays within max', () => {
const result = scaleImage({
originalWidth: 1024,
originalHeight: 1024,
containerRef: makeContainerRef(300),
});
expect(result).toEqual({ width: '300px', height: '300px' });
});
it('does not upscale when container is wider than the image', () => {
const result = scaleImage({
originalWidth: 256,
originalHeight: 256,
containerRef: makeContainerRef(800),
});
expect(result).toEqual({ width: '256px', height: '256px' });
});
it('constrains height to 45vh and adjusts width by aspect ratio', () => {
// window.innerHeight = 1000, so maxHeight = 450
const result = scaleImage({
originalWidth: 500,
originalHeight: 1000,
containerRef: makeContainerRef(600),
});
// container fits 500px width → height would be 1000, exceeds 450
// clamp: height=450, width=450*(500/1000)=225
expect(result).toEqual({ width: '225px', height: '450px' });
});
it('handles landscape images correctly', () => {
const result = scaleImage({
originalWidth: 1920,
originalHeight: 1080,
containerRef: makeContainerRef(800),
});
// width clamped to 800, height = 800 / (1920/1080) = 450, exactly maxHeight
expect(result).toEqual({ width: '800px', height: '450px' });
});
it('handles very wide panoramic images', () => {
const result = scaleImage({
originalWidth: 4000,
originalHeight: 500,
containerRef: makeContainerRef(600),
});
// width clamped to 600, height = 600 / (4000/500) = 75, well under maxHeight
expect(result).toEqual({ width: '600px', height: '75px' });
});
});

View file

@ -317,3 +317,13 @@ export const validateFiles = ({
return true;
};
export function sortPagesByRelevance(
pages: number[],
pageRelevance: Record<number, number>,
): number[] {
if (!pageRelevance || Object.keys(pageRelevance).length === 0) {
return pages;
}
return [...pages].sort((a, b) => (pageRelevance[b] || 0) - (pageRelevance[a] || 0));
}

View file

@ -0,0 +1,51 @@
import { Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
import type { TMessageContentParts, Agents } from 'librechat-data-provider';
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
export type GroupedPart =
| { type: 'single'; part: PartWithIndex }
| { type: 'tool-group'; parts: PartWithIndex[] };
function isGroupableToolCall(part: TMessageContentParts): boolean {
if (part.type !== ContentTypes.TOOL_CALL) {
return false;
}
const toolCall = part[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined;
if (!toolCall) {
return false;
}
const isStandardToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isStandardToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
return false;
}
return true;
}
export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] {
const result: GroupedPart[] = [];
let currentGroup: PartWithIndex[] = [];
const flushGroup = () => {
if (currentGroup.length >= 2) {
result.push({ type: 'tool-group', parts: [...currentGroup] });
} else {
for (const p of currentGroup) {
result.push({ type: 'single', part: p });
}
}
currentGroup = [];
};
for (const item of parts) {
if (isGroupableToolCall(item.part)) {
currentGroup.push(item);
} else {
flushGroup();
result.push({ type: 'single', part: item });
}
}
flushGroup();
return result;
}

View file

@ -3,31 +3,33 @@ import type { UIActionResult } from '@mcp-ui/client';
import { TAskFunction } from '~/common';
import logger from './logger';
export * from './errors';
export * from './map';
export * from './json';
export * from './email';
export * from './share';
export * from './files';
export * from './latex';
export * from './forms';
export * from './roles';
export * from './errors';
export * from './agents';
export * from './drafts';
export * from './convos';
export * from './routes';
export * from './redirect';
export * from './presets';
export * from './prompts';
export * from './textarea';
export * from './messages';
export * from './redirect';
export * from './languages';
export * from './endpoints';
export * from './resources';
export * from './roles';
export * from './scaleImage';
export * from './timestamps';
export * from './localStorage';
export * from './promptGroups';
export * from './previewCache';
export * from './email';
export * from './share';
export * from './timestamps';
export * from './groupToolCalls';
export { default as cn } from './cn';
export { default as logger } from './logger';
export { default as getLoginError } from './getLoginError';

View file

@ -0,0 +1,32 @@
import type { RefObject } from 'react';
const MAX_HEIGHT_VH = 0.45;
export function scaleImage({
originalWidth,
originalHeight,
containerRef,
}: {
originalWidth: number;
originalHeight: number;
containerRef: RefObject<HTMLDivElement | null>;
}): { width: string; height: string } {
const container = containerRef.current;
if (!container) {
return { width: 'auto', height: 'auto' };
}
const containerWidth = container.clientWidth;
const maxHeight = window.innerHeight * MAX_HEIGHT_VH;
const aspectRatio = originalWidth / originalHeight;
let width = Math.min(originalWidth, containerWidth);
let height = width / aspectRatio;
if (height > maxHeight) {
height = maxHeight;
width = height * aspectRatio;
}
return { width: `${Math.round(width)}px`, height: `${Math.round(height)}px` };
}

View file

@ -273,6 +273,10 @@ export default defineConfig(({ command }) => ({
return 'headlessui';
}
if (normalizedId.includes('@icons-pack/react-simple-icons/icons/')) {
return;
}
// Everything else falls into a generic vendor chunk.
return 'vendor';
}

View file

@ -1857,8 +1857,8 @@ export enum LocalStorageKeys {
LAST_PROMPT_CATEGORY = 'lastPromptCategory',
/** Key for rendering User Messages as Markdown */
ENABLE_USER_MSG_MARKDOWN = 'enableUserMsgMarkdown',
/** Key for displaying analysis tool code input */
SHOW_ANALYSIS_CODE = 'showAnalysisCode',
/** Key for auto-expanding tool call details */
AUTO_EXPAND_TOOLS = 'autoExpandTools',
/** Last selected MCP values per conversation ID */
LAST_MCP_ = 'LAST_MCP_',
/** Last checked toggle for Code Interpreter API per conversation ID */