🧜 feat: Open Mermaid Diagrams as Artifacts with SVG/PNG Export (#14713)

* feat: open Mermaid diagrams in the artifact panel with SVG and PNG export

Mermaid diagrams previously rendered inline only, and the artifact panel
routed every artifact through Sandpack even when no bundler was needed.

- Route Mermaid artifacts to a direct renderer in ArtifactTabs, moving the
  Sandpack path into a lazily loaded SandboxArtifactTabs so opening a
  diagram no longer pulls in the bundler chrome or the startup config.
- Add an inline artifact card that opens the diagram in the panel instead
  of rendering the same diagram twice.
- Add SVG and PNG export from both the inline diagram and the panel
  header, with size-capped canvas scaling and background compositing.
- Lazy-load the artifact panel in Presentation and ShareArtifacts.
- Accessibility: label the panel as a dialog on mobile with a focus trap,
  make the mobile resize handle keyboard operable, restore focus to the
  opener on close, and honor prefers-reduced-motion.
- Fix the generated Sandpack wrapper to serialize diagram source instead
  of interpolating it into a template literal.
- Cover the new paths with unit tests and a cross-browser Playwright spec.

* fix: keep Mermaid artifact identity and render state per diagram

Addresses three review findings on the Mermaid artifact panel.

Mermaid fences do not consume a code-block index, so every diagram in a
message received the same `mermaid-${blockIndex}` and therefore the same
Recoil artifact key: expanding one overwrote the other, and both cards
read as selected. Mermaid fences now carry their own index sequence,
seeded per markdown block the same way the code and artifact counters
are, so the id stays stable across streamed tokens.

The panel renderer is keyed by artifact id, so switching directly
between two diagrams can no longer carry the previous render, its
dimensions, or its export payload across the boundary while the new
source debounces. Editing an open diagram still does not remount.

The preview Refresh action drives the Sandpack client, which a Mermaid
preview never populates, so it only covered the panel with a spinner.
It is hidden for Mermaid, which offers its own retry on render failure.

Also drops com_ui_mermaid_export_preparing and com_ui_mermaid_source,
which no longer have call sites, fixing the unused-i18n-keys check.

* fix: bind Mermaid preview and export to the artifact on screen

Three further review findings, all on state outliving what it describes.

The editor reset in ArtifactTabs only lands after commit, so the render
that switched artifacts still passed the previous artifact's editor text
to the freshly keyed renderer, which mounted showing (and exporting) the
diagram just navigated away from. Editor text is now ignored until the
reset catches up. SandboxArtifactTabs carried the same pattern and gets
the same guard.

Switching to the code tab unmounts the preview, but the export payload
survived it, so the toolbar kept exporting a diagram that was no longer
on screen and no longer matched an edited source. The renderer now
withdraws its payload on unmount, and the export action is scoped to the
preview tab.

The diagram canvas mounts only once there is a diagram to show, so the
ResizeObserver ran against a null ref while the placeholder was up and
never saw the real element. Wide diagrams were fitted to the default
700px and clipped in narrower panels. Observation now re-runs when the
canvas appears.

* fix: scope Mermaid artifact ids to the content part

Each content part renders its own markdown tree, so the per-message
Mermaid counter restarts at zero in every part. Diagrams sitting either
side of a tool call therefore both resolved to
`mermaid-artifact-${messageId}-mermaid-0`: one registration overwrote
the other and both cards shared a selection state. The part index the
message context already carries now takes part in the scope.

* fix: keep the Mermaid export menu reachable in fullscreen

The artifact panel gained a fullscreen mode on dev, which re-roots the
panel into the fullscreen element and portals the copy and version
popovers there so they stay visible. The Mermaid export menu portals to
the body, so once these branches met it opened outside the fullscreen
element and rendered invisible. It now takes the same portal target.

* fix: heal Mermaid registrations and cap PNG canvases after rounding

Two findings from the latest review pass.

Closing the panel unmounts Artifacts, whose useArtifacts cleanup wipes
artifactsState while the inline cards stay on screen. The Mermaid card
never observed that, so reopening one card restored only itself and any
other expanded diagram vanished from the version navigator until it was
clicked again. It now subscribes to its own slice and re-registers when
the entry goes missing, matching the self-heal ToolArtifactCard already
documents. The write is a no-op when the entry matches, so it settles.

Rounding each PNG side independently could carry the product back over
the 16.7M pixel budget the scale was picked to satisfy: 3129x50000
resolved to 1025x16374, which is 16,783,350 pixels and enough for a
browser enforcing the area limit to reject toBlob outright. Rounding
down cannot exceed the budget, since the bounding scale is derived from
it.
This commit is contained in:
Marco Beretta 2026-08-09 15:02:42 +02:00 committed by GitHub
parent 152dcf4721
commit 8da51562f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3584 additions and 292 deletions

View file

@ -2,6 +2,7 @@ import { createContext, useContext, ReactNode, useCallback, useRef } from 'react
type TCodeBlockContext = {
getNextIndex: (skip: boolean) => number;
getNextMermaidIndex: () => number;
resetCounter: () => void;
};
@ -11,6 +12,7 @@ export const useCodeBlockContext = () => useContext(CodeBlockContext);
export function CodeBlockProvider({
children,
baseIndex = 0,
mermaidBaseIndex = 0,
}: {
children: ReactNode;
/**
@ -21,8 +23,14 @@ export function CodeBlockProvider({
* counter.
*/
baseIndex?: number;
/**
* The same offset for mermaid fences, which are skipped by the code-block
* counter and so need their own sequence to stay distinct from one another.
*/
mermaidBaseIndex?: number;
}) {
const counterRef = useRef(0);
const mermaidCounterRef = useRef(0);
const getNextIndex = useCallback(
(skip: boolean) => {
@ -36,12 +44,22 @@ export function CodeBlockProvider({
[baseIndex],
);
const getNextMermaidIndex = useCallback(() => {
const nextIndex = mermaidCounterRef.current;
mermaidCounterRef.current += 1;
return mermaidBaseIndex + nextIndex;
}, [mermaidBaseIndex]);
/* Both counters restart together. A streamed block re-renders its fences on
* every token, so restarting is what keeps a diagram's index tied to its
* position in the document instead of drifting upward as the message grows. */
const resetCounter = useCallback(() => {
counterRef.current = 0;
mermaidCounterRef.current = 0;
}, []);
return (
<CodeBlockContext.Provider value={{ getNextIndex, resetCounter }}>
<CodeBlockContext.Provider value={{ getNextIndex, getNextMermaidIndex, resetCounter }}>
{children}
</CodeBlockContext.Provider>
);

View file

@ -4,6 +4,8 @@ export interface CodeBlock {
content: string;
}
export const MERMAID_ARTIFACT_TYPE = 'application/vnd.mermaid' as const;
/**
* Original-file download metadata for artifacts backed by a real
* code-interpreter file (e.g. an office document whose panel preview is

View file

@ -0,0 +1,320 @@
import React from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { render, waitFor } from '@testing-library/react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { Artifact } from '~/common';
import ArtifactTabs from './ArtifactTabs';
interface EditorProps {
artifact: Artifact;
readOnly?: boolean;
}
const mockEditor = jest.fn((_props: EditorProps) => null);
const mockUseGetStartupConfig = jest.fn((_options?: unknown) => ({ data: {} }));
const mockUseGetSharedStartupConfig = jest.fn((_shareId?: unknown, _options?: unknown) => ({
data: {},
}));
let mockCurrentCode: string | undefined;
jest.mock('./ArtifactCodeEditor', () => ({
ArtifactCodeEditor: (props: EditorProps) => mockEditor(props),
}));
jest.mock('./ArtifactPreview', () => {
const testGlobal = globalThis as typeof globalThis & {
artifactPreviewModuleEvaluations?: number;
};
testGlobal.artifactPreviewModuleEvaluations =
(testGlobal.artifactPreviewModuleEvaluations ?? 0) + 1;
return { ArtifactPreview: () => null };
});
jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => {
const ReactModule = jest.requireActual<typeof import('react')>('react');
let mounts = 0;
/** Renders the mount ordinal so a remount (new key) is observable. */
const nativeRenderer = jest.fn(() => {
const [instance] = ReactModule.useState(() => {
mounts += 1;
return mounts;
});
return ReactModule.createElement('div', {
'data-testid': 'mermaid-renderer',
'data-instance': String(instance),
});
});
const testGlobal = globalThis as typeof globalThis & {
nativeMermaidRenderer?: typeof nativeRenderer;
};
testGlobal.nativeMermaidRenderer = nativeRenderer;
return { MermaidRenderer: nativeRenderer };
});
jest.mock('~/Providers/EditorContext', () => ({
useCodeState: () => ({ currentCode: mockCurrentCode, setCurrentCode: jest.fn() }),
}));
jest.mock('~/Providers', () => ({
useShareContext: () => ({ shareId: undefined }),
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: (options: unknown) => mockUseGetStartupConfig(options),
useGetSharedStartupConfig: (shareId: unknown, options: unknown) =>
mockUseGetSharedStartupConfig(shareId, options),
}));
jest.mock('~/hooks/Artifacts/useArtifactProps', () => ({
__esModule: true,
default: () => ({ files: {}, fileKey: 'diagram.mmd', template: 'static', sharedProps: {} }),
}));
const preview: SandpackPreviewRef = Object.create(null);
const previewRef: React.MutableRefObject<SandpackPreviewRef> = { current: preview };
function renderArtifact(artifact: Artifact, activeTab: 'code' | 'preview' = 'code') {
return render(
<Tabs.Root value={activeTab}>
<ArtifactTabs artifact={artifact} previewRef={previewRef} />
</Tabs.Root>,
);
}
describe('ArtifactTabs Mermaid editing', () => {
beforeEach(() => {
mockEditor.mockClear();
mockUseGetStartupConfig.mockClear();
mockUseGetSharedStartupConfig.mockClear();
mockCurrentCode = undefined;
});
it('renders Mermaid natively without loading startup config or Sandpack preview', () => {
const testGlobal = globalThis as typeof globalThis & {
artifactPreviewModuleEvaluations?: number;
nativeMermaidRenderer?: jest.Mock;
};
renderArtifact(
{
id: 'mermaid-chat-1',
type: 'application/vnd.mermaid',
title: 'Flow chart',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
},
'preview',
);
expect(mockUseGetStartupConfig).not.toHaveBeenCalled();
expect(mockUseGetSharedStartupConfig).not.toHaveBeenCalled();
expect(testGlobal.artifactPreviewModuleEvaluations ?? 0).toBe(0);
expect(testGlobal.nativeMermaidRenderer?.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
children: 'graph TD\nA-->B',
exportFilename: 'Flow chart',
fillContainer: true,
showExpandButton: false,
showHeader: false,
}),
);
});
it('previews the current Mermaid editor content', () => {
const testGlobal = globalThis as typeof globalThis & {
nativeMermaidRenderer?: jest.Mock;
};
const artifact: Artifact = {
id: 'mermaid-persisted-1',
type: 'application/vnd.mermaid',
title: 'Flow chart',
content: 'graph TD\nA-->B',
index: 0,
lastUpdateTime: 1,
};
const { rerender } = render(
<Tabs.Root value="preview">
<ArtifactTabs artifact={artifact} previewRef={previewRef} />
</Tabs.Root>,
);
/* Editor text only belongs to the preview once it was typed against the
* artifact on screen, so it is applied on a later render, not on mount. */
mockCurrentCode = 'graph TD\nA-->C';
rerender(
<Tabs.Root value="preview">
<ArtifactTabs artifact={artifact} previewRef={previewRef} />
</Tabs.Root>,
);
expect(testGlobal.nativeMermaidRenderer?.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({ children: 'graph TD\nA-->C' }),
);
});
it('makes chat Mermaid Artifacts read-only when they have no persisted edit target', () => {
renderArtifact({
id: 'mermaid-chat-1',
type: 'application/vnd.mermaid',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
});
expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true }));
});
it('keeps persisted Mermaid Artifacts editable', () => {
renderArtifact({
id: 'mermaid-persisted-1',
type: 'application/vnd.mermaid',
content: 'graph TD\nA-->B',
index: 0,
messageId: 'message-1',
lastUpdateTime: 1,
});
expect(mockEditor).toHaveBeenCalledWith(expect.objectContaining({ readOnly: false }));
});
it('remounts the renderer when switching between Mermaid Artifacts', () => {
const first: Artifact = {
id: 'mermaid-a',
type: 'application/vnd.mermaid',
title: 'First',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
};
const second: Artifact = {
id: 'mermaid-b',
type: 'application/vnd.mermaid',
title: 'Second',
content: 'graph TD\nC-->D',
lastUpdateTime: 2,
};
const { rerender, getByTestId } = render(
<Tabs.Root value="preview">
<ArtifactTabs artifact={first} previewRef={previewRef} />
</Tabs.Root>,
);
const initialInstance = getByTestId('mermaid-renderer').getAttribute('data-instance');
rerender(
<Tabs.Root value="preview">
<ArtifactTabs artifact={second} previewRef={previewRef} />
</Tabs.Root>,
);
expect(getByTestId('mermaid-renderer').getAttribute('data-instance')).not.toBe(initialInstance);
});
it('does not seed the next diagram with the previous artifact editor text', () => {
const testGlobal = globalThis as typeof globalThis & {
nativeMermaidRenderer?: jest.Mock;
};
const first: Artifact = {
id: 'mermaid-a',
type: 'application/vnd.mermaid',
title: 'First',
content: 'graph TD\nA-->B',
index: 0,
lastUpdateTime: 1,
};
const second: Artifact = {
id: 'mermaid-b',
type: 'application/vnd.mermaid',
title: 'Second',
content: 'graph TD\nC-->D',
index: 1,
lastUpdateTime: 2,
};
mockCurrentCode = 'graph TD\nEDITED-->A';
const { rerender } = render(
<Tabs.Root value="preview">
<ArtifactTabs artifact={first} previewRef={previewRef} />
</Tabs.Root>,
);
rerender(
<Tabs.Root value="preview">
<ArtifactTabs artifact={second} previewRef={previewRef} />
</Tabs.Root>,
);
const renderedContent = testGlobal.nativeMermaidRenderer?.mock.calls.map(
(call) => (call[0] as { children: string }).children,
);
expect(renderedContent).not.toContain('graph TD\nEDITED-->A');
expect(testGlobal.nativeMermaidRenderer?.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({ children: 'graph TD\nC-->D' }),
);
});
it('does not mount the Mermaid renderer on the code tab', () => {
const testGlobal = globalThis as typeof globalThis & {
nativeMermaidRenderer?: jest.Mock;
};
testGlobal.nativeMermaidRenderer?.mockClear();
renderArtifact(
{
id: 'mermaid-a',
type: 'application/vnd.mermaid',
title: 'First',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
},
'code',
);
expect(testGlobal.nativeMermaidRenderer).not.toHaveBeenCalled();
});
it('does not remount the renderer while the same Artifact is edited', () => {
const artifact: Artifact = {
id: 'mermaid-a',
type: 'application/vnd.mermaid',
title: 'First',
content: 'graph TD\nA-->B',
index: 0,
lastUpdateTime: 1,
};
const { rerender, getByTestId } = render(
<Tabs.Root value="preview">
<ArtifactTabs artifact={artifact} previewRef={previewRef} />
</Tabs.Root>,
);
const initialInstance = getByTestId('mermaid-renderer').getAttribute('data-instance');
mockCurrentCode = 'graph TD\nA-->C';
rerender(
<Tabs.Root value="preview">
<ArtifactTabs artifact={{ ...artifact, lastUpdateTime: 2 }} previewRef={previewRef} />
</Tabs.Root>,
);
expect(getByTestId('mermaid-renderer').getAttribute('data-instance')).toBe(initialInstance);
});
it('keeps non-Mermaid Artifacts on the startup-config and sandbox preview path', async () => {
const testGlobal = globalThis as typeof globalThis & {
artifactPreviewModuleEvaluations?: number;
};
renderArtifact(
{
id: 'html-1',
type: 'text/html',
content: '<h1>Hello</h1>',
lastUpdateTime: 1,
},
'preview',
);
await waitFor(() => expect(mockUseGetStartupConfig).toHaveBeenCalledWith({ enabled: true }));
expect(testGlobal.artifactPreviewModuleEvaluations).toBe(1);
});
});

View file

@ -1,44 +1,63 @@
import { useRef, useEffect } from 'react';
import { lazy, Suspense, useEffect, useRef } from 'react';
import { Spinner } from '@librechat/client';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import type { ProcessedMermaidSvg } from '~/utils/diagram/export';
import { MermaidRenderer } from '~/components/Messages/Content/Mermaid/Mermaid';
import { MERMAID_ARTIFACT_TYPE, type Artifact } from '~/common/artifacts';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useCodeState } from '~/Providers/EditorContext';
import { ArtifactPreview } from './ArtifactPreview';
import { useShareContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export default function ArtifactTabs({
artifact,
previewRef,
isSharedConvo,
}: {
const SandboxArtifactTabs = lazy(() => import('./SandboxArtifactTabs'));
interface ArtifactTabsProps {
artifact: Artifact;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
onMermaidExportReady?: (data: ProcessedMermaidSvg | null) => void;
}
function LoadingArtifactTabs() {
const localize = useLocalize();
return (
<div
className="flex h-full w-full items-center justify-center bg-surface-primary text-text-secondary"
role="status"
>
<Spinner className="size-5" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_loading')}</span>
</div>
);
}
function MermaidArtifactTabs({
artifact,
isSharedConvo,
onMermaidExportReady,
}: Omit<ArtifactTabsProps, 'previewRef'>) {
const localize = useLocalize();
const { currentCode, setCurrentCode } = useCodeState();
const { shareId } = useShareContext();
const shouldUseSharedConfig =
isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0;
const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig });
const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, {
enabled: shouldUseSharedConfig,
});
const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig;
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null);
/* The reset below only lands after commit, so on the render that switches
* artifacts `currentCode` still holds the previous artifact's editor text.
* Ignore it until the reset catches up, or the freshly keyed renderer would
* mount showing (and exporting) the diagram we just navigated away from. */
const hasCurrentArtifactCode = lastIdRef.current === artifact.id;
useEffect(() => {
if (artifact.id !== lastIdRef.current) {
setCurrentCode(undefined);
}
lastIdRef.current = artifact.id;
}, [setCurrentCode, artifact.id]);
}, [artifact.id, setCurrentCode]);
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
const content = (hasCurrentArtifactCode ? currentCode : undefined) ?? artifact.content ?? '';
const isReadOnly = isSharedConvo === true || artifact.index == null;
return (
<div className="flex h-full w-full flex-col">
@ -48,24 +67,46 @@ export default function ArtifactTabs({
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isReadOnly} />
</Tabs.Content>
<Tabs.Content
value="preview"
className="h-full w-full flex-grow overflow-hidden"
className="min-h-0 w-full flex-1 overflow-hidden p-4"
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={resolvedStartupConfig}
/>
{/* Keyed by artifact so switching between two diagrams cannot carry the
previous render, its dimensions, or its export payload across the
boundary while the new source debounces. */}
<MermaidRenderer
key={artifact.id}
exportFilename={artifact.title ?? localize('com_ui_mermaid_diagram')}
fillContainer
onExportReady={onMermaidExportReady}
showExpandButton={false}
showHeader={false}
>
{content}
</MermaidRenderer>
</Tabs.Content>
</div>
);
}
export default function ArtifactTabs(props: ArtifactTabsProps) {
if (props.artifact.type === MERMAID_ARTIFACT_TYPE) {
return (
<MermaidArtifactTabs
artifact={props.artifact}
isSharedConvo={props.isSharedConvo}
onMermaidExportReady={props.onMermaidExportReady}
/>
);
}
return (
<Suspense fallback={<LoadingArtifactTabs />}>
<SandboxArtifactTabs {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,256 @@
import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import Artifacts from './Artifacts';
import store from '~/store';
const mockUseArtifacts = jest.fn();
let mockIsMobile = false;
let mockPrefersReducedMotion = false;
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useMediaQuery: (query: string) =>
query === '(prefers-reduced-motion: reduce)' ? mockPrefersReducedMotion : mockIsMobile,
}));
jest.mock('~/Providers', () => ({
useMutationState: () => ({ isMutating: false }),
useShareContext: () => ({ isSharedConvo: false }),
}));
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
useFocusTrap: (
containerRef: React.RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void,
) => {
const ReactModule = jest.requireActual<typeof import('react')>('react');
ReactModule.useEffect(() => {
if (!active) {
return;
}
const container = containerRef.current;
const firstFocusable = container?.querySelector<HTMLElement>('button, [tabindex="0"]');
firstFocusable?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape?.();
}
};
container?.addEventListener('keydown', handleKeyDown);
return () => container?.removeEventListener('keydown', handleKeyDown);
}, [active, containerRef, onEscape]);
},
}));
jest.mock('~/hooks/Artifacts/useArtifacts', () => ({
__esModule: true,
default: () => mockUseArtifacts(),
}));
jest.mock('./ArtifactTabs', () => ({
__esModule: true,
default: () => <div data-testid="artifact-content" />,
}));
jest.mock('./ArtifactVersion', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('./DownloadArtifact', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('./Mermaid/Export', () => ({
__esModule: true,
default: () => <div data-testid="mermaid-export" />,
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
__esModule: true,
default: () => null,
}));
const ArtifactStateProbe = () => {
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const isVisible = useRecoilValue(store.artifactsVisibility);
return (
<output
data-testid="artifact-state"
data-current-id={currentArtifactId ?? ''}
data-visible={isVisible}
/>
);
};
describe('Artifacts panel accessibility', () => {
beforeEach(() => {
mockIsMobile = false;
mockPrefersReducedMotion = false;
mockUseArtifacts.mockReturnValue({
activeTab: 'code',
setActiveTab: jest.fn(),
currentIndex: 0,
currentArtifact: {
id: 'mermaid-artifact-1',
type: 'application/vnd.mermaid',
title: 'Diagram',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
},
orderedArtifactIds: ['mermaid-artifact-1'],
setCurrentArtifactId: jest.fn(),
});
});
it('hides the Sandpack refresh action for Mermaid previews', async () => {
mockUseArtifacts.mockReturnValue({
activeTab: 'preview',
setActiveTab: jest.fn(),
currentIndex: 0,
currentArtifact: {
id: 'mermaid-artifact-1',
type: 'application/vnd.mermaid',
title: 'Diagram',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
},
orderedArtifactIds: ['mermaid-artifact-1'],
setCurrentArtifactId: jest.fn(),
});
render(
<RecoilRoot>
<Artifacts />
</RecoilRoot>,
);
await screen.findByRole('region', { name: 'Diagram' });
expect(screen.queryByRole('button', { name: 'com_ui_refresh' })).not.toBeInTheDocument();
expect(screen.getByTestId('mermaid-export')).toBeInTheDocument();
});
it('hides the Mermaid export action outside the preview tab', async () => {
render(
<RecoilRoot>
<Artifacts />
</RecoilRoot>,
);
await screen.findByRole('region', { name: 'Diagram' });
expect(screen.queryByTestId('mermaid-export')).not.toBeInTheDocument();
});
it('keeps the refresh action for sandboxed previews', async () => {
mockUseArtifacts.mockReturnValue({
activeTab: 'preview',
setActiveTab: jest.fn(),
currentIndex: 0,
currentArtifact: {
id: 'html-artifact-1',
type: 'text/html',
title: 'Page',
content: '<h1>Hi</h1>',
lastUpdateTime: 1,
},
orderedArtifactIds: ['html-artifact-1'],
setCurrentArtifactId: jest.fn(),
});
render(
<RecoilRoot>
<Artifacts />
</RecoilRoot>,
);
await screen.findByRole('region', { name: 'Page' });
expect(screen.getByRole('button', { name: 'com_ui_refresh' })).toBeInTheDocument();
});
it('keeps the resizable layout ID distinct from the controlled Artifact region', async () => {
const { container } = render(
<RecoilRoot>
<div id="artifacts-panel">
<Artifacts />
</div>
</RecoilRoot>,
);
await screen.findByRole('region', { name: 'Diagram' });
expect(container.querySelectorAll('#artifacts-panel')).toHaveLength(1);
expect(container.querySelectorAll('#artifact-viewer')).toHaveLength(1);
});
it('supports keyboard resizing and restores focus after the mobile sheet closes', async () => {
mockIsMobile = true;
const opener = document.createElement('button');
opener.textContent = 'Open artifact';
document.body.appendChild(opener);
opener.focus();
render(
<RecoilRoot>
<Artifacts />
</RecoilRoot>,
);
const dialog = await screen.findByRole('dialog', { name: 'Diagram' });
const separator = screen.getByRole('separator', { name: 'com_ui_resize_artifact_panel' });
await waitFor(() => expect(separator).toHaveFocus());
fireEvent.keyDown(separator, { key: 'ArrowDown' });
expect(separator).toHaveAttribute('aria-valuenow', '80');
expect(dialog).toHaveStyle({ height: '80vh' });
fireEvent.keyDown(separator, { key: 'Home' });
expect(separator).toHaveAttribute('aria-valuenow', '10');
expect(dialog).toHaveStyle({ height: '10vh' });
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
expect(opener).not.toHaveFocus();
await waitFor(() => expect(opener).toHaveFocus());
opener.remove();
});
it('closes without the animation delay when reduced motion is preferred', async () => {
mockIsMobile = true;
mockPrefersReducedMotion = true;
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
render(
<RecoilRoot
initializeState={({ set }) => {
set(store.currentArtifactId, 'mermaid-artifact-1');
set(store.artifactsVisibility, true);
}}
>
<ArtifactStateProbe />
<Artifacts />
</RecoilRoot>,
);
const separator = await screen.findByRole('separator', {
name: 'com_ui_resize_artifact_panel',
});
await waitFor(() => expect(separator).toHaveFocus());
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
expect(screen.getByTestId('artifact-state')).toHaveAttribute('data-current-id', '');
expect(screen.getByTestId('artifact-state')).toHaveAttribute('data-visible', 'false');
await waitFor(() => expect(opener).toHaveFocus());
opener.remove();
});
});

View file

@ -5,15 +5,17 @@ import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import { Code, Maximize2, Minimize2, Play, RefreshCw, X } from 'lucide-react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import type { ProcessedMermaidSvg } from '~/utils/diagram/export';
import { TOOL_ARTIFACT_TYPES, isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts';
import { displayFilename } from '~/components/Chat/Messages/Content/Parts/attachmentTypes';
import { isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import { useFocusTrap, useLocalize } from '~/hooks';
import DownloadArtifact from './DownloadArtifact';
import ArtifactVersion from './ArtifactVersion';
import MermaidExport from './Mermaid/Export';
import ArtifactTabs from './ArtifactTabs';
import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
@ -25,9 +27,12 @@ export default function Artifacts() {
const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const previewRef = useRef<SandpackPreviewRef>();
const artifactContainerRef = useRef<HTMLDivElement>(null);
const fullscreenPortalRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
@ -37,6 +42,10 @@ export default function Artifacts() {
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0);
const [isCopied, setIsCopied] = useState(false);
const [mermaidExportState, setMermaidExportState] = useState<{
artifactId: string;
data: ProcessedMermaidSvg | null;
} | null>(null);
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
@ -59,6 +68,10 @@ export default function Artifacts() {
);
useEffect(() => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && activeElement !== document.body) {
openerRef.current = activeElement;
}
setIsMounted(true);
const delay = isMobile ? 50 : 30;
const timer = setTimeout(() => setIsVisible(true), delay);
@ -106,6 +119,70 @@ export default function Artifacts() {
setCurrentArtifactId,
} = useArtifacts();
const restoreArtifactTriggerFocus = useCallback(() => {
const opener = openerRef.current;
const artifactId = currentArtifact?.id;
requestAnimationFrame(() => {
if (opener?.isConnected) {
opener.focus();
return;
}
const trigger = Array.from(
document.querySelectorAll<HTMLElement>('[data-artifact-trigger]'),
).find((element) => element.dataset.artifactTrigger === artifactId);
trigger?.focus();
});
}, [currentArtifact?.id]);
const handleMermaidExportReady = useCallback(
(data: ProcessedMermaidSvg | null) => {
if (currentArtifact?.id == null) {
return;
}
setMermaidExportState({ artifactId: currentArtifact.id, data });
},
[currentArtifact?.id],
);
const mermaidExportData =
mermaidExportState != null && mermaidExportState.artifactId === currentArtifact?.id
? mermaidExportState.data
: null;
const isMermaidArtifact = currentArtifact?.type === TOOL_ARTIFACT_TYPES.MERMAID;
const closeArtifacts = useCallback(() => {
if (isMobile) {
setIsClosing(true);
setIsVisible(false);
const finishClose = () => {
resetCurrentArtifactId();
setArtifactsVisible(false);
setIsClosing(false);
setHeight(90);
restoreArtifactTriggerFocus();
};
if (prefersReducedMotion) {
finishClose();
} else {
setTimeout(finishClose, 250);
}
return;
}
resetCurrentArtifactId();
setArtifactsVisible(false);
restoreArtifactTriggerFocus();
}, [
isMobile,
prefersReducedMotion,
resetCurrentArtifactId,
restoreArtifactTriggerFocus,
setArtifactsVisible,
]);
useFocusTrap(panelRef, isMobile && isVisible && !isClosing, closeArtifacts);
/* Office artifacts have no source view, and source-code artifacts have
* no useful rendered preview. Filter each down to the only meaningful
* tab and label that tab with the file name instead of generic
@ -186,6 +263,24 @@ export default function Artifacts() {
}
};
const handleDragKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
let nextHeight = height;
if (e.key === 'ArrowUp') {
nextHeight = Math.min(100, height + 10);
} else if (e.key === 'ArrowDown') {
nextHeight = Math.max(10, height - 10);
} else if (e.key === 'Home') {
nextHeight = 10;
} else if (e.key === 'End') {
nextHeight = 100;
} else {
return;
}
e.preventDefault();
setHeight(nextHeight);
};
if (!currentArtifact || !isMounted) {
return null;
}
@ -216,21 +311,6 @@ export default function Artifacts() {
}
};
const closeArtifacts = () => {
if (isMobile) {
setIsClosing(true);
setIsVisible(false);
setTimeout(() => {
setArtifactsVisible(false);
setIsClosing(false);
setHeight(90);
}, 250);
} else {
resetCurrentArtifactId();
setArtifactsVisible(false);
}
};
const backdropOpacity =
blurAmount > 0
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
@ -243,7 +323,7 @@ export default function Artifacts() {
{isMobile && (
<div
className={cn(
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter] motion-reduce:transition-none',
isVisible && !isClosing
? 'transition-all duration-300'
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
@ -259,8 +339,13 @@ export default function Artifacts() {
/>
)}
<div
ref={panelRef}
id="artifact-viewer"
role={isMobile ? 'dialog' : 'region'}
aria-modal={isMobile || undefined}
aria-label={currentArtifact.title ?? localize('com_ui_artifacts')}
className={cn(
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
'flex w-full flex-col bg-surface-primary text-xl text-text-primary motion-reduce:transition-none',
isMobile
? cn(
'fixed z-[100] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
@ -283,27 +368,35 @@ export default function Artifacts() {
>
{isMobile && !isFullscreen && (
<div
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
role="separator"
tabIndex={0}
aria-label={localize('com_ui_resize_artifact_panel')}
aria-orientation="horizontal"
aria-valuemin={10}
aria-valuemax={100}
aria-valuenow={Math.round(height)}
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-heavy active:cursor-grabbing"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onKeyDown={handleDragKeyDown}
>
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60 motion-reduce:transition-none" />
</div>
)}
{/* Header */}
<div
className={cn(
'flex h-[52px] flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt p-2 transition-all duration-300',
'flex h-[52px] flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt p-2 transition-all duration-300 motion-reduce:transition-none',
isMobile ? 'justify-center' : 'overflow-hidden',
)}
>
{!isMobile && (
<div
className={cn(
'flex items-center transition-all duration-500',
'flex items-center transition-all duration-500 motion-reduce:transition-none',
isVisible && !isClosing
? 'translate-x-0 opacity-100'
: '-translate-x-2 opacity-0',
@ -321,12 +414,15 @@ export default function Artifacts() {
<div
className={cn(
'flex items-center gap-2 transition-all duration-500',
'flex items-center gap-2 transition-all duration-500 motion-reduce:transition-none',
isMobile ? 'min-w-max' : '',
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
)}
>
{displayedTab === 'preview' && (
{/* Refresh drives the Sandpack preview client; the Mermaid
renderer has no such client and offers its own retry, so the
action would spin over an unchanged diagram. */}
{displayedTab === 'preview' && !isMermaidArtifact && (
<Button
size="icon"
variant="ghost"
@ -340,7 +436,7 @@ export default function Artifacts() {
) : (
<RefreshCw
size={16}
className="transition-transform duration-200"
className="transition-transform duration-200 motion-reduce:transition-none"
aria-hidden="true"
/>
)}
@ -385,6 +481,13 @@ export default function Artifacts() {
portalElement={isFullscreen ? fullscreenPortalRef.current : undefined}
onClick={handleCopyArtifact}
/>
{isMermaidArtifact && displayedTab === 'preview' && (
<MermaidExport
artifact={currentArtifact}
exportData={mermaidExportData}
portalElement={isFullscreen ? fullscreenPortalRef.current : undefined}
/>
)}
<DownloadArtifact artifact={currentArtifact} />
<Button
size="icon"
@ -404,6 +507,7 @@ export default function Artifacts() {
artifact={currentArtifact}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
onMermaidExportReady={handleMermaidExportReady}
/>
</div>

View file

@ -0,0 +1,65 @@
import React from 'react';
import { render } from '@testing-library/react';
import type { ProcessedMermaidSvg } from '~/utils/diagram/export';
import type { Artifact } from '~/common';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import MermaidExport from './Export';
interface ExportProps {
svg?: string | null;
dimensions?: ProcessedMermaidSvg['dimensions'];
filename: string;
buttonClassName?: string;
}
const mockExport = jest.fn((_props: ExportProps) => null);
jest.mock('~/components/Messages/Content/Mermaid/Export', () => ({
__esModule: true,
default: (props: ExportProps) => mockExport(props),
}));
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
}));
const artifact: Artifact = {
id: 'tool-artifact-flow chart.mmd',
type: TOOL_ARTIFACT_TYPES.MERMAID,
title: 'flow.mmd',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
};
describe('Artifact Mermaid export', () => {
beforeEach(() => {
mockExport.mockClear();
});
it('reuses the SVG and dimensions already rendered by the Artifact preview', () => {
const exportData: ProcessedMermaidSvg = {
svg: '<svg viewBox="0 0 400 200" />',
dimensions: { width: 400, height: 200 },
};
render(<MermaidExport artifact={artifact} exportData={exportData} />);
expect(mockExport).toHaveBeenCalledWith(
expect.objectContaining({
svg: exportData.svg,
dimensions: exportData.dimensions,
filename: 'flow.mmd',
}),
);
expect(mockExport.mock.calls[0][0]).not.toHaveProperty('source');
});
it('does not offer export before the preview SVG is ready', () => {
render(<MermaidExport artifact={artifact} />);
expect(mockExport).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,36 @@
import React, { memo } from 'react';
import type { ProcessedMermaidSvg } from '~/utils/diagram/export';
import type { Artifact } from '~/common';
import MermaidExport from '~/components/Messages/Content/Mermaid/Export';
import { useLocalize } from '~/hooks';
const ArtifactMermaidExport = memo(function ArtifactMermaidExport({
artifact,
exportData,
portalElement,
}: {
artifact: Artifact;
exportData?: ProcessedMermaidSvg | null;
portalElement?: HTMLElement | null;
}) {
const localize = useLocalize();
if (exportData == null) {
return null;
}
return (
<MermaidExport
key={artifact.id}
svg={exportData.svg}
dimensions={exportData.dimensions}
filename={artifact.title ?? localize('com_ui_mermaid_diagram')}
buttonClassName="h-9 w-9 p-0"
portalElement={portalElement}
/>
);
});
ArtifactMermaidExport.displayName = 'ArtifactMermaidExport';
export default ArtifactMermaidExport;

View file

@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useCodeState } from '~/Providers/EditorContext';
import { ArtifactPreview } from './ArtifactPreview';
import { useShareContext } from '~/Providers';
export default function SandboxArtifactTabs({
artifact,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const { currentCode, setCurrentCode } = useCodeState();
const { shareId } = useShareContext();
const shouldUseSharedConfig =
isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0;
const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig });
const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, {
enabled: shouldUseSharedConfig,
});
const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig;
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null);
/* The reset lands only after commit, so the render that switches artifacts
* still sees the previous artifact's editor text. */
const hasCurrentArtifactCode = lastIdRef.current === artifact.id;
useEffect(() => {
if (artifact.id !== lastIdRef.current) {
setCurrentCode(undefined);
}
lastIdRef.current = artifact.id;
}, [artifact.id, setCurrentCode]);
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<div className="flex h-full w-full flex-col">
<Tabs.Content
value="code"
id="artifacts-code"
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor
artifact={artifact}
monacoRef={monacoRef}
readOnly={isSharedConvo === true}
/>
</Tabs.Content>
<Tabs.Content
value="preview"
className="h-full w-full flex-grow overflow-hidden"
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={hasCurrentArtifactCode ? currentCode : undefined}
startupConfig={resolvedStartupConfig}
/>
</Tabs.Content>
</div>
);
}

View file

@ -15,6 +15,7 @@ type MarkdownBlockProps = SharedProps & {
content: string;
codeBaseIndex: number;
artifactBaseIndex: number;
mermaidBaseIndex: number;
};
/**
@ -29,13 +30,14 @@ const MarkdownBlock = memo(
content,
codeBaseIndex,
artifactBaseIndex,
mermaidBaseIndex,
remarkPlugins,
rehypePlugins,
components,
}: MarkdownBlockProps) {
return (
<ArtifactProvider baseIndex={artifactBaseIndex}>
<CodeBlockProvider baseIndex={codeBaseIndex}>
<CodeBlockProvider baseIndex={codeBaseIndex} mermaidBaseIndex={mermaidBaseIndex}>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={remarkPlugins}
@ -52,7 +54,8 @@ const MarkdownBlock = memo(
(prev, next) =>
prev.content === next.content &&
prev.codeBaseIndex === next.codeBaseIndex &&
prev.artifactBaseIndex === next.artifactBaseIndex,
prev.artifactBaseIndex === next.artifactBaseIndex &&
prev.mermaidBaseIndex === next.mermaidBaseIndex,
);
MarkdownBlock.displayName = 'MarkdownBlock';
@ -76,10 +79,12 @@ const MarkdownBlocks = memo(function MarkdownBlocks({
const blocks = useMemo(() => {
let codeBaseIndex = 0;
let artifactBaseIndex = 0;
let mermaidBaseIndex = 0;
return splitMarkdownIntoBlocks(content).map((block) => {
const entry = { raw: block.raw, codeBaseIndex, artifactBaseIndex };
const entry = { raw: block.raw, codeBaseIndex, artifactBaseIndex, mermaidBaseIndex };
codeBaseIndex += block.codeBlockCount;
artifactBaseIndex += block.artifactCount;
mermaidBaseIndex += block.mermaidCount;
return entry;
});
}, [content]);
@ -93,10 +98,11 @@ const MarkdownBlocks = memo(function MarkdownBlocks({
// ref. During append-only streaming these stay constant, so completed
// blocks keep a stable key and are not remounted.
<MarkdownBlock
key={`${index}-${block.codeBaseIndex}-${block.artifactBaseIndex}`}
key={`${index}-${block.codeBaseIndex}-${block.artifactBaseIndex}-${block.mermaidBaseIndex}`}
content={block.raw}
codeBaseIndex={block.codeBaseIndex}
artifactBaseIndex={block.artifactBaseIndex}
mermaidBaseIndex={block.mermaidBaseIndex}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}

View file

@ -4,10 +4,10 @@ import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import Mermaid, { MermaidErrorBoundary } from '~/components/Messages/Content/Mermaid';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import { handleDoubleClick, triggerDownload } from '~/utils';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useFileDownload } from '~/data-provider';
import { useCodeBlockContext } from '~/Providers';
import { handleDoubleClick, triggerDownload } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -41,8 +41,12 @@ export const code: React.ElementType = memo(function MarkdownCode({
const isMermaid = lang === 'mermaid';
const isSingleLine = isSingleLineCode(children);
const { getNextIndex, resetCounter } = useCodeBlockContext();
const { getNextIndex, getNextMermaidIndex, resetCounter } = useCodeBlockContext();
const blockIndex = useRef(getNextIndex(isMath || isMermaid || isSingleLine)).current;
/* Mermaid fences do not consume a code-block index, so every one of them in a
* message would otherwise share `blockIndex` and collapse onto a single
* artifact id. They carry their own sequence instead. */
const mermaidIndex = useRef(isMermaid ? getNextMermaidIndex() : -1).current;
useEffect(() => {
resetCounter();
@ -54,7 +58,7 @@ export const code: React.ElementType = memo(function MarkdownCode({
const content = typeof children === 'string' ? children : String(children);
return (
<MermaidErrorBoundary code={content}>
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
<Mermaid id={`mermaid-${mermaidIndex}`}>{content}</Mermaid>
</MermaidErrorBoundary>
);
} else if (isSingleLine) {

View file

@ -1,9 +1,9 @@
import { memo, useId, useLayoutEffect } from 'react';
import { memo, useId, useLayoutEffect, useMemo } from 'react';
import { Download } from 'lucide-react';
import { useRecoilState } from 'recoil';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import { fileToArtifact, TOOL_ARTIFACT_TYPES, toolArtifactKey } from '~/utils/artifacts';
import Mermaid from '~/components/Messages/Content/Mermaid/Mermaid';
import { toolArtifactKey } from '~/utils/artifacts';
import { displayFilename } from './attachmentTypes';
import { useAttachmentLink } from './LogLink';
import { useLocalize } from '~/hooks';
@ -16,10 +16,9 @@ interface ToolMermaidArtifactProps {
}
/**
* Renders a code-execution-produced mermaid artifact inline. Skips the
* sandpack/react path the side-panel artifacts use the standalone
* Mermaid component has its own zoom/expand/code-toggle UI and we want
* to reuse it without bringing the bundler chrome along.
* Renders a code-execution-produced Mermaid artifact inline until the
* user opens it in the Artifact panel. The compact card keeps the file
* available in chat without rendering the same diagram twice.
*
* Shares the `toolArtifactClaim` dedup atom with `ToolArtifactCard` so
* the same `.mmd` file can't double-render across tool calls / messages.
@ -45,6 +44,11 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps
user: file.user,
source: file.source,
});
const artifact = useMemo(
() =>
fileToArtifact({ ...attachment, text }, { preClassifiedType: TOOL_ARTIFACT_TYPES.MERMAID }),
[attachment, text],
);
if (claim != null && !isMyClaim) {
return null;
@ -84,7 +88,13 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps
)}
{/* `id` is optional on Mermaid; pass only when we have a real file_id
so the component generates a unique render target on its own. */}
{file.file_id ? <Mermaid id={file.file_id}>{text}</Mermaid> : <Mermaid>{text}</Mermaid>}
{file.file_id ? (
<Mermaid id={file.file_id} artifact={artifact ?? undefined}>
{text}
</Mermaid>
) : (
<Mermaid artifact={artifact ?? undefined}>{text}</Mermaid>
)}
</div>
);
});

View file

@ -45,8 +45,21 @@ jest.mock('~/components/Chat/Messages/Content/Image', () => ({
jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => ({
__esModule: true,
default: ({ children }: { children: string }) => (
<div data-testid="mermaid-render">{children}</div>
default: ({
children,
artifact,
}: {
children: string;
artifact?: { id: string; title?: string; type?: string };
}) => (
<div
data-testid="mermaid-render"
data-artifact-id={artifact?.id}
data-artifact-title={artifact?.title}
data-artifact-type={artifact?.type}
>
{children}
</div>
),
}));
@ -187,7 +200,11 @@ describe('Attachment routing for tool artifacts', () => {
text: 'graph TD\nA-->B',
} as Partial<TAttachment>);
renderWith(<Attachment attachment={mmd} />);
expect(screen.getByTestId('mermaid-render')).toHaveTextContent('graph TD');
const renderer = screen.getByTestId('mermaid-render');
expect(renderer).toHaveTextContent('graph TD');
expect(renderer).toHaveAttribute('data-artifact-id', 'tool-artifact-file-1');
expect(renderer).toHaveAttribute('data-artifact-title', 'flow.mmd');
expect(renderer).toHaveAttribute('data-artifact-type', 'application/vnd.mermaid');
// The card-style trigger should NOT be rendered for mermaid
expect(screen.queryByText('com_ui_artifact_click')).not.toBeInTheDocument();
});

View file

@ -0,0 +1,147 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from '../markdownConfig';
import MarkdownBlocks from '../MarkdownBlocks';
/**
* Mermaid fences do not consume a code-block index, so before they carried
* their own sequence every diagram in a message received the same `mermaid-N`
* id and therefore the same Recoil artifact key.
*/
jest.mock('~/components/Messages/Content/Mermaid', () => ({
__esModule: true,
default: ({ id, children }: { id?: string; children: string }) => (
<div data-testid="mermaid" data-mermaid-id={String(id)}>
{children}
</div>
),
MermaidErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('~/components/Messages/Content/CodeBlock', () => ({
__esModule: true,
default: ({ blockIndex }: { blockIndex?: number }) => (
<div data-testid="code-block" data-block-index={String(blockIndex)} />
),
}));
const renderMarkdown = (content: string) =>
render(
<MarkdownBlocks
content={content}
remarkPlugins={getRemarkPlugins()}
rehypePlugins={getRehypePlugins()}
components={getMarkdownComponents()}
/>,
);
const mermaidIds = () =>
screen.getAllByTestId('mermaid').map((element) => element.getAttribute('data-mermaid-id'));
describe('Mermaid block ids', () => {
it('gives each Mermaid fence in a message a distinct id', () => {
renderMarkdown(
['```mermaid', 'graph TD', 'A-->B', '```', '', '```mermaid', 'graph TD', 'C-->D', '```'].join(
'\n',
),
);
const ids = mermaidIds();
expect(ids).toHaveLength(2);
expect(new Set(ids).size).toBe(2);
});
it('keeps Mermaid ids distinct across intervening executable code blocks', () => {
renderMarkdown(
[
'```mermaid',
'graph TD',
'A-->B',
'```',
'',
'```python',
'print("hi")',
'```',
'',
'```mermaid',
'graph TD',
'C-->D',
'```',
'',
'```mermaid',
'graph TD',
'E-->F',
'```',
].join('\n'),
);
const ids = mermaidIds();
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(3);
});
/**
* Fences nested in one top-level block share a provider and re-run their
* index on every streamed token, so the counter has to restart per render.
* Without that restart the indices climb as the message grows and a diagram
* already open in the panel loses the artifact id it was registered under.
*/
it('holds Mermaid ids steady as a nested block keeps streaming', () => {
const listWithOne = ['- step one', '', ' ```mermaid', ' graph TD', ' A-->B', ' ```'].join(
'\n',
);
const listWithTwo = [
listWithOne,
'',
'- step two',
'',
' ```mermaid',
' graph TD',
' C-->D',
' ```',
].join('\n');
const listStillGrowing = [listWithTwo, '', '- step three'].join('\n');
const { rerender } = renderMarkdown(listWithOne);
expect(mermaidIds()).toEqual(['mermaid-0']);
const view = (content: string) => (
<MarkdownBlocks
content={content}
remarkPlugins={getRemarkPlugins()}
rehypePlugins={getRehypePlugins()}
components={getMarkdownComponents()}
/>
);
rerender(view(listWithTwo));
expect(mermaidIds()).toEqual(['mermaid-0', 'mermaid-1']);
rerender(view(listStillGrowing));
expect(mermaidIds()).toEqual(['mermaid-0', 'mermaid-1']);
});
it('does not let a Mermaid fence disturb executable code block indices', () => {
renderMarkdown(
[
'```python',
'print("first")',
'```',
'',
'```mermaid',
'graph TD',
'A-->B',
'```',
'',
'```python',
'print("second")',
'```',
].join('\n'),
);
const indices = screen
.getAllByTestId('code-block')
.map((element) => element.getAttribute('data-block-index'));
expect(indices).toEqual(['0', '1']);
});
});

View file

@ -13,6 +13,8 @@ export type MarkdownBlock = {
codeBlockCount: number;
/** Artifact containers within this block. */
artifactCount: number;
/** Mermaid fences within this block, which carry their own index sequence. */
mermaidCount: number;
};
type MdastNode = {
@ -57,7 +59,10 @@ const containsDefinition = (node: MdastNode): boolean => {
const ARTIFACT_DIRECTIVE_TYPES = new Set(['containerDirective', 'leafDirective']);
const countWithin = (node: MdastNode, counts: { code: number; artifact: number }): void => {
const countWithin = (
node: MdastNode,
counts: { code: number; artifact: number; mermaid: number },
): void => {
if (ARTIFACT_DIRECTIVE_TYPES.has(node.type) && node.name === 'artifact') {
// artifactPlugin renders container (`:::artifact:::`) and leaf
// (`::artifact{}`) artifact directives as an Artifact, each consuming one
@ -68,8 +73,12 @@ const countWithin = (node: MdastNode, counts: { code: number; artifact: number }
counts.artifact += 1;
return;
}
if (node.type === 'code' && isExecutableCode(node.lang ?? '')) {
counts.code += 1;
if (node.type === 'code') {
if (isExecutableCode(node.lang ?? '')) {
counts.code += 1;
} else if (renderedCodeLang(node.lang ?? '') === 'mermaid') {
counts.mermaid += 1;
}
}
if (node.children) {
for (const child of node.children) {
@ -111,7 +120,7 @@ export function splitMarkdownIntoBlocks(content: string): MarkdownBlock[] {
const children = tree.children ?? [];
if (children.length === 0) {
return [{ raw: content, codeBlockCount: 0, artifactCount: 0 }];
return [{ raw: content, codeBlockCount: 0, artifactCount: 0, mermaidCount: 0 }];
}
// Per-block rendering loses document-global context, so render the whole
@ -136,22 +145,29 @@ export function splitMarkdownIntoBlocks(content: string): MarkdownBlock[] {
if (start == null || end == null) {
return [{ raw: content, ...blockCounts(children) }];
}
const counts = { code: 0, artifact: 0 };
const counts = { code: 0, artifact: 0, mermaid: 0 };
countWithin(node, counts);
blocks.push({
raw: content.slice(start, end),
codeBlockCount: counts.code,
artifactCount: counts.artifact,
mermaidCount: counts.mermaid,
});
}
return blocks;
}
const blockCounts = (children: MdastNode[]): { codeBlockCount: number; artifactCount: number } => {
const counts = { code: 0, artifact: 0 };
const blockCounts = (
children: MdastNode[],
): { codeBlockCount: number; artifactCount: number; mermaidCount: number } => {
const counts = { code: 0, artifact: 0, mermaid: 0 };
for (const node of children) {
countWithin(node, counts);
}
return { codeBlockCount: counts.code, artifactCount: counts.artifact };
return {
codeBlockCount: counts.code,
artifactCount: counts.artifact,
mermaidCount: counts.mermaid,
};
};

View file

@ -0,0 +1,108 @@
import React from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Artifact } from '~/common';
import Presentation from './Presentation';
import store from '~/store';
const mockArtifactPanelLabel = 'Artifact panel loaded';
const mockOpenArtifactLabel = 'Open Artifact';
jest.mock('~/components/Artifacts/Artifacts', () => {
const artifactPanelLabel = 'Artifact panel loaded';
const testGlobal = globalThis as typeof globalThis & {
presentationArtifactModuleEvaluations?: number;
};
testGlobal.presentationArtifactModuleEvaluations =
(testGlobal.presentationArtifactModuleEvaluations ?? 0) + 1;
return {
__esModule: true,
default: () => <aside>{artifactPanelLabel}</aside>,
};
});
jest.mock('~/components/Chat/Input/Files/DragDropWrapper', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('~/components/SidePanel', () => ({
SidePanelGroup: ({
artifacts,
children,
}: {
artifacts: React.ReactNode;
children: React.ReactNode;
}) => (
<div>
{children}
{artifacts}
</div>
),
}));
jest.mock('~/Providers', () => ({
ArtifactsProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
EditorProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('~/hooks/Artifacts/useResetArtifactsOnConversationChange', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
useDeleteFilesMutation: () => ({ mutateAsync: jest.fn() }),
}));
jest.mock('~/hooks', () => ({
useSetFilesToDelete: () => jest.fn(),
}));
const OpenArtifactPanel = () => {
const setArtifacts = useSetRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const open = () => {
const artifact: Artifact = {
id: 'mermaid-artifact',
type: 'application/vnd.mermaid',
title: 'Diagram',
content: 'graph TD\nA-->B',
lastUpdateTime: 1,
};
setArtifacts({ [artifact.id]: artifact });
setCurrentArtifactId(artifact.id);
setArtifactsVisible(true);
};
return (
<button type="button" onClick={open}>
{mockOpenArtifactLabel}
</button>
);
};
describe('Presentation Artifact loading', () => {
it('loads the Artifact panel bundle only when the panel is opened', async () => {
const testGlobal = globalThis as typeof globalThis & {
presentationArtifactModuleEvaluations?: number;
};
render(
<RecoilRoot>
<Presentation>
<OpenArtifactPanel />
</Presentation>
</RecoilRoot>,
);
expect(testGlobal.presentationArtifactModuleEvaluations ?? 0).toBe(0);
fireEvent.click(screen.getByRole('button', { name: mockOpenArtifactLabel }));
expect(await screen.findByText(mockArtifactPanelLabel)).toBeInTheDocument();
expect(testGlobal.presentationArtifactModuleEvaluations).toBe(1);
});
});

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { lazy, Suspense, useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
@ -6,11 +6,12 @@ import useResetArtifactsOnConversationChange from '~/hooks/Artifacts/useResetArt
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, ArtifactsProvider } from '~/Providers';
import { useDeleteFilesMutation } from '~/data-provider';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanelGroup } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
import store from '~/store';
const Artifacts = lazy(() => import('~/components/Artifacts/Artifacts'));
export default function Presentation({ children }: { children: React.ReactNode }) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
@ -67,7 +68,9 @@ export default function Presentation({ children }: { children: React.ReactNode }
return (
<ArtifactsProvider>
<EditorProvider>
<Artifacts />
<Suspense fallback={null}>
<Artifacts />
</Suspense>
</EditorProvider>
</ArtifactsProvider>
);

View file

@ -0,0 +1,160 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import { downloadMermaidPng, downloadMermaidSvg } from '~/utils/diagram/export';
import MermaidExport from './Export';
const mockShowToast = jest.fn();
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
}));
jest.mock('~/utils/diagram/export', () => ({
downloadMermaidPng: jest.fn(),
downloadMermaidSvg: jest.fn(),
}));
const mockDownloadMermaidPng = jest.mocked(downloadMermaidPng);
const mockDownloadMermaidSvg = jest.mocked(downloadMermaidSvg);
describe('MermaidExport', () => {
beforeEach(() => {
document.documentElement.style.setProperty('--surface-primary-alt', '23 23 23');
mockShowToast.mockReset();
mockDownloadMermaidPng.mockResolvedValue();
mockDownloadMermaidSvg.mockReset();
});
it('renders the menu inside the fullscreen element when one is given', async () => {
const user = userEvent.setup();
const fullscreenHost = document.createElement('div');
document.body.appendChild(fullscreenHost);
render(
<MermaidExport
svg={'<svg viewBox="0 0 400 200" />'}
dimensions={{ width: 400, height: 200 }}
filename="flow.mmd"
portalElement={fullscreenHost}
/>,
);
await user.click(screen.getByRole('button', { name: 'com_ui_export_mermaid' }));
const menu = await screen.findByRole('menu');
expect(fullscreenHost.contains(menu)).toBe(true);
fullscreenHost.remove();
});
it('exports an already-rendered inline diagram as SVG and PNG', async () => {
const user = userEvent.setup();
render(
<MermaidExport
svg={'<svg viewBox="0 0 400 200" />'}
dimensions={{ width: 400, height: 200 }}
filename="flow.mmd"
/>,
);
const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' });
await user.click(trigger);
expect(await screen.findByRole('menu')).toHaveClass('popover-ui');
await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_svg' }));
expect(mockDownloadMermaidSvg).toHaveBeenCalledWith(
'<svg viewBox="0 0 400 200" />',
'flow.mmd',
'rgb(23 23 23)',
);
await waitFor(() => expect(trigger).toHaveFocus());
await user.click(trigger);
await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_png' }));
expect(mockDownloadMermaidPng).toHaveBeenCalledWith(
'<svg viewBox="0 0 400 200" />',
'flow.mmd',
{ width: 400, height: 200 },
'rgb(23 23 23)',
);
});
it('keeps both formats disabled until an existing preview SVG is ready', async () => {
const user = userEvent.setup();
render(<MermaidExport filename="flow chart.mmd" />);
await user.click(screen.getByRole('button', { name: 'com_ui_export_mermaid' }));
expect(screen.getByRole('menuitem', { name: 'com_ui_export_svg' })).toHaveAttribute(
'aria-disabled',
'true',
);
expect(screen.getByRole('menuitem', { name: 'com_ui_export_png' })).toHaveAttribute(
'aria-disabled',
'true',
);
expect(mockDownloadMermaidSvg).not.toHaveBeenCalled();
expect(mockDownloadMermaidPng).not.toHaveBeenCalled();
});
it('supports keyboard export and restores focus to its trigger', async () => {
const user = userEvent.setup();
render(<MermaidExport svg={'<svg viewBox="0 0 400 200" />'} filename="flow.mmd" />);
await user.tab();
const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' });
expect(trigger).toHaveFocus();
await user.keyboard('{Enter}');
const svgItem = await screen.findByRole('menuitem', { name: 'com_ui_export_svg' });
expect(svgItem).toHaveFocus();
await user.keyboard('{Enter}');
expect(mockDownloadMermaidSvg).toHaveBeenCalledWith(
'<svg viewBox="0 0 400 200" />',
'flow.mmd',
'rgb(23 23 23)',
);
await waitFor(() => expect(trigger).toHaveFocus());
});
it('announces PNG generation and prevents duplicate export actions', async () => {
const user = userEvent.setup();
let finishExport: (() => void) | undefined;
mockDownloadMermaidPng.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishExport = resolve;
}),
);
render(<MermaidExport svg={'<svg viewBox="0 0 400 200" />'} filename="flow.mmd" />);
const trigger = screen.getByRole('button', { name: 'com_ui_export_mermaid' });
await user.click(trigger);
await user.click(await screen.findByRole('menuitem', { name: 'com_ui_export_png' }));
expect(trigger).toHaveAttribute('aria-busy', 'true');
expect(trigger.querySelector('.lucide-loader-circle')).not.toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('com_ui_mermaid_exporting_png');
await user.click(trigger);
expect(
within(screen.getByRole('menu')).getByText('com_ui_mermaid_exporting_png'),
).toBeVisible();
expect(screen.getByRole('menuitem', { name: 'com_ui_export_png' })).toHaveAttribute(
'aria-disabled',
'true',
);
await act(async () => finishExport?.());
expect(screen.getByRole('status')).toHaveTextContent('com_ui_mermaid_export_complete');
});
});

View file

@ -0,0 +1,170 @@
import React, { memo, useCallback, useId, useMemo, useRef, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { FileCode2, FileImage, ImageDown, LoaderCircle } from 'lucide-react';
import { DropdownPopup, TooltipAnchor, useToastContext } from '@librechat/client';
import type { MermaidDimensions } from '~/utils/diagram/export';
import type { MenuItemProps } from '~/common';
import { downloadMermaidPng, downloadMermaidSvg } from '~/utils/diagram/export';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidExportProps {
filename: string;
svg?: string | null;
dimensions?: MermaidDimensions | null;
buttonClassName?: string;
/** Fullscreen re-roots the panel, so a menu portalled to the body would be
* rendered outside the visible fullscreen element. */
portalElement?: HTMLElement | null;
}
function surfaceBackground(): string | undefined {
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--surface-primary-alt')
.trim();
if (!value) {
return undefined;
}
if (/^[\d.]+(?:\s+[\d.]+){2}(?:\s*\/\s*[\d.]+%?)?$/.test(value)) {
return `rgb(${value})`;
}
return value.startsWith('var(') ? undefined : value;
}
const MermaidExport = memo(function MermaidExport({
filename,
svg,
dimensions,
buttonClassName,
portalElement,
}: MermaidExportProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, '');
const triggerRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isExportingPng, setIsExportingPng] = useState(false);
const [exportStatus, setExportStatus] = useState('');
const isBusy = isExportingPng;
let liveMessage = exportStatus;
if (isExportingPng) {
liveMessage = localize('com_ui_mermaid_exporting_png');
}
const showExportError = useCallback(() => {
setExportStatus(localize('com_ui_mermaid_export_failed'));
showToast({ status: 'error', message: localize('com_ui_mermaid_export_failed') });
}, [localize, showToast]);
const restoreTriggerFocus = useCallback(() => {
requestAnimationFrame(() => triggerRef.current?.focus());
}, []);
const handleSvgExport = useCallback(() => {
if (svg == null) {
return;
}
try {
downloadMermaidSvg(svg, filename, surfaceBackground());
setExportStatus(localize('com_ui_mermaid_export_complete'));
} catch {
showExportError();
} finally {
restoreTriggerFocus();
}
}, [filename, localize, restoreTriggerFocus, showExportError, svg]);
const handlePngExport = useCallback(() => {
if (svg == null || isExportingPng) {
return;
}
setIsExportingPng(true);
setExportStatus('');
void downloadMermaidPng(svg, filename, dimensions, surfaceBackground())
.then(() => setExportStatus(localize('com_ui_mermaid_export_complete')))
.catch(showExportError)
.finally(() => setIsExportingPng(false));
restoreTriggerFocus();
}, [dimensions, filename, isExportingPng, localize, restoreTriggerFocus, showExportError, svg]);
const dropdownItems = useMemo<MenuItemProps[]>(() => {
const statusItems: MenuItemProps[] = [];
if (isBusy) {
statusItems.push({
label: liveMessage,
disabled: true,
icon: <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />,
className: 'text-text-secondary',
});
}
return [
...statusItems,
{
label: localize('com_ui_export_svg'),
icon: <FileCode2 className="size-4 text-text-secondary" />,
disabled: svg == null || isExportingPng,
onClick: handleSvgExport,
},
{
label: localize('com_ui_export_png'),
icon: <FileImage className="size-4 text-text-secondary" />,
disabled: svg == null || isExportingPng,
onClick: handlePngExport,
},
];
}, [handlePngExport, handleSvgExport, isBusy, isExportingPng, liveMessage, localize, svg]);
return (
<>
<DropdownPopup
portal
focusLoop
unmountOnHide
menuId={`mermaid-export-${instanceId}-menu`}
finalFocus={triggerRef}
isOpen={isOpen}
setIsOpen={setIsOpen}
items={dropdownItems}
portalElement={portalElement}
className="absolute right-0 top-0 mt-2 min-w-52 motion-reduce:!transition-none"
trigger={
<TooltipAnchor
portalElement={portalElement}
description={isBusy ? liveMessage : localize('com_ui_export_mermaid')}
render={
<Ariakit.MenuButton
ref={triggerRef}
aria-label={localize('com_ui_export_mermaid')}
aria-busy={isBusy || undefined}
className={cn(
'flex items-center justify-center rounded-lg p-1.5 text-text-secondary transition-colors motion-reduce:transition-none',
'hover:bg-surface-hover hover:text-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-heavy',
buttonClassName,
)}
>
{isBusy ? (
<LoaderCircle
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<ImageDown className="size-4" aria-hidden="true" />
)}
</Ariakit.MenuButton>
}
/>
}
/>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{liveMessage}
</span>
</>
);
});
MermaidExport.displayName = 'MermaidExport';
export default MermaidExport;

View file

@ -0,0 +1,560 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue, useResetRecoilState } from 'recoil';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { Artifact } from '~/common';
import Mermaid, { MermaidRenderer } from './Mermaid';
import { MessageContext } from '~/Providers';
import store from '~/store';
const mockOpenAsArtifactLabel = 'com_ui_open_as_artifact';
const mockExpandLabel = 'com_ui_expand';
const mockProcessedSvg = '<svg viewBox="0 0 400 200" />';
let mockSvgProcessingState = {
blobUrl: 'blob:mermaid-diagram',
processedSvg: mockProcessedSvg,
svgDimensions: { width: 400, height: 200 },
isLoading: false,
error: null,
lastValidSvgRef: { current: null as string | null },
initialScale: 1,
calculatedHeight: 240,
};
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
}));
jest.mock('./useSvgProcessing', () => ({
__esModule: true,
default: () => mockSvgProcessingState,
}));
jest.mock('./useMermaidZoom', () => ({
__esModule: true,
default: () => ({
zoom: 1,
pan: { x: 0, y: 0 },
isPanning: false,
handleZoomIn: jest.fn(),
handleZoomOut: jest.fn(),
handleResetZoom: jest.fn(),
handleMouseDown: jest.fn(),
}),
}));
jest.mock('./MermaidHeader', () => ({
__esModule: true,
default: ({
showExpandButton,
showCode,
onExpand,
onToggleCode,
expandLabel,
exportSvg,
exportFilename,
}: {
showExpandButton?: boolean;
showCode: boolean;
onExpand?: () => void;
onToggleCode: () => void;
expandLabel?: string;
exportSvg?: string | null;
exportFilename?: string;
}) => (
<>
{showExpandButton && onExpand ? (
<button type="button" aria-label={expandLabel ?? mockExpandLabel} onClick={onExpand}>
{expandLabel ?? mockExpandLabel}
</button>
) : null}
<button
type="button"
aria-label={showCode ? 'com_ui_preview' : 'com_ui_show_code'}
onClick={onToggleCode}
>
{showCode ? 'com_ui_preview' : 'com_ui_show_code'}
</button>
{exportSvg != null && (
<output data-testid="mermaid-export">
{exportFilename}:{exportSvg}
</output>
)}
</>
),
}));
jest.mock('./ZoomControls', () => ({
__esModule: true,
default: () => <div data-testid="mermaid-zoom-controls" />,
}));
jest.mock('./MermaidDialog', () => ({
__esModule: true,
default: ({ open }: { open: boolean }) => (open ? <div data-testid="mermaid-dialog" /> : null),
}));
interface ArtifactStateSnapshot {
artifacts: Record<string, Artifact | undefined> | null;
currentArtifactId: string | null;
visible: boolean;
}
const StateProbe = ({ onChange }: { onChange: (state: ArtifactStateSnapshot) => void }) => {
const artifacts = useRecoilValue(store.artifactsState);
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const visible = useRecoilValue(store.artifactsVisibility);
React.useEffect(() => {
onChange({ artifacts, currentArtifactId, visible });
}, [artifacts, currentArtifactId, onChange, visible]);
return null;
};
describe('Mermaid Artifact expansion', () => {
beforeEach(() => {
mockSvgProcessingState = {
blobUrl: 'blob:mermaid-diagram',
processedSvg: mockProcessedSvg,
svgDimensions: { width: 400, height: 200 },
isLoading: false,
error: null,
lastValidSvgRef: { current: null },
initialScale: 1,
calculatedHeight: 240,
};
});
it('opens in the Artifact panel and replaces the inline graph with a reopenable card', async () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
const handleStateChange = (nextState: ArtifactStateSnapshot) => {
state = nextState;
};
const { container } = render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<MessageContext.Provider
value={{ messageId: 'message-1', conversationId: 'conversation-1', isExpanded: true }}
>
<StateProbe onChange={handleStateChange} />
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MessageContext.Provider>
</MemoryRouter>
</RecoilRoot>,
);
expect(screen.getByTestId('mermaid-export')).toHaveTextContent(
'com_ui_mermaid_diagram:<svg viewBox="0 0 400 200" />',
);
fireEvent.click(screen.getByRole('button', { name: mockOpenAsArtifactLabel }));
const artifactButton = await screen.findByRole('button', { expanded: true });
expect(screen.queryByTestId('mermaid-dialog')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_mermaid_diagram')).toBeInTheDocument();
expect(screen.getByText('com_ui_close_artifact')).toBeInTheDocument();
expect(artifactButton).toHaveAttribute('aria-controls', 'artifact-viewer');
expect(artifactButton).toHaveClass('w-fit', 'max-w-full', 'bg-surface-hover');
expect(container.querySelector('.lucide-workflow')).not.toBeNull();
expect(container.querySelector('.lucide-workflow')?.parentElement).toHaveClass(
'bg-status-info-subtle',
'text-status-info',
);
await waitFor(() => expect(artifactButton).toHaveFocus());
const artifact = Object.values(state.artifacts ?? {})[0];
expect(artifact).toMatchObject({
type: 'application/vnd.mermaid',
title: 'com_ui_mermaid_diagram',
content: 'graph TD\nA-->B',
messageId: 'message-1',
});
expect(state.currentArtifactId).toBe(artifact?.id);
expect(state.visible).toBe(true);
fireEvent.click(artifactButton);
expect(state.currentArtifactId).toBeNull();
expect(state.visible).toBe(false);
expect(artifactButton).toHaveClass('bg-surface-tertiary');
expect(screen.getByText('com_ui_open_artifact')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { expanded: false }));
expect(state.currentArtifactId).toBe(artifact?.id);
expect(state.visible).toBe(true);
});
it('separates diagrams that sit in different content parts of one message', async () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
const handleStateChange = (nextState: ArtifactStateSnapshot) => {
state = nextState;
};
/* Every content part builds its own markdown tree, so a diagram before a
* tool call and one after it both arrive here as `mermaid-0`. */
render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<StateProbe onChange={handleStateChange} />
<MessageContext.Provider
value={{
messageId: 'message-1',
conversationId: 'conversation-1',
partIndex: 0,
isExpanded: true,
}}
>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MessageContext.Provider>
<MessageContext.Provider
value={{
messageId: 'message-1',
conversationId: 'conversation-1',
partIndex: 2,
isExpanded: true,
}}
>
<Mermaid id="mermaid-0">{'graph TD\nC-->D'}</Mermaid>
</MessageContext.Provider>
</MemoryRouter>
</RecoilRoot>,
);
const [firstExpand, secondExpand] = screen.getAllByRole('button', {
name: mockOpenAsArtifactLabel,
});
fireEvent.click(firstExpand);
fireEvent.click(secondExpand);
await waitFor(() => expect(Object.keys(state.artifacts ?? {})).toHaveLength(2));
const registered = Object.values(state.artifacts ?? {}).filter(
(entry): entry is Artifact => entry != null,
);
expect(new Set(registered.map((entry) => entry.id)).size).toBe(2);
expect(registered.map((entry) => entry.content)).toEqual(
expect.arrayContaining(['graph TD\nA-->B', 'graph TD\nC-->D']),
);
const cards = screen.getAllByRole('button', { expanded: false });
const selected = screen.getAllByRole('button', { expanded: true });
expect(cards).toHaveLength(1);
expect(selected).toHaveLength(1);
});
it('restores its registration after the Artifact store is reset', async () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
const handleStateChange = (nextState: ArtifactStateSnapshot) => {
state = nextState;
};
let resetStore: () => void = () => undefined;
const StoreResetter = () => {
resetStore = useResetRecoilState(store.artifactsState);
return null;
};
render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<StateProbe onChange={handleStateChange} />
<StoreResetter />
<MessageContext.Provider
value={{ messageId: 'message-1', conversationId: 'conversation-1', isExpanded: true }}
>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MessageContext.Provider>
</MemoryRouter>
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: mockOpenAsArtifactLabel }));
await waitFor(() => expect(Object.keys(state.artifacts ?? {})).toHaveLength(1));
/* Closing the panel unmounts `Artifacts`, whose cleanup wipes the store
* while this card stays on screen. */
act(() => resetStore());
await waitFor(() => expect(Object.keys(state.artifacts ?? {})).toHaveLength(1));
});
it('keeps the dialog expansion on routes without an Artifact panel', () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
render(
<RecoilRoot>
<MemoryRouter initialEntries={['/search']}>
<StateProbe
onChange={(nextState) => {
state = nextState;
}}
/>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MemoryRouter>
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: mockExpandLabel }));
expect(screen.getByTestId('mermaid-dialog')).toBeInTheDocument();
expect(screen.queryByRole('button', { expanded: true })).not.toBeInTheDocument();
expect(state.artifacts).toBeNull();
expect(state.currentArtifactId).toBeNull();
});
it('keeps Artifact and export controls while showing the last valid streaming diagram', () => {
mockSvgProcessingState = {
...mockSvgProcessingState,
isLoading: true,
lastValidSvgRef: { current: '<svg width="400" height="200" />' },
};
render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MemoryRouter>
</RecoilRoot>,
);
expect(screen.getByRole('button', { name: mockOpenAsArtifactLabel })).toBeInTheDocument();
expect(screen.getByTestId('mermaid-export')).toHaveTextContent(mockProcessedSvg);
});
it('switches exclusively between the inline diagram and its source', () => {
const { container } = render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MemoryRouter>
</RecoilRoot>,
);
expect(screen.getByRole('img', { name: 'com_ui_mermaid_diagram' })).toBeInTheDocument();
expect(screen.getByTestId('mermaid-zoom-controls')).toBeInTheDocument();
expect(container.querySelector('pre')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_code' }));
expect(container.querySelector('pre')?.textContent).toBe('graph TD\nA-->B');
expect(screen.queryByRole('img', { name: 'com_ui_mermaid_diagram' })).not.toBeInTheDocument();
expect(screen.getByTestId('mermaid-zoom-controls')).not.toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_preview' }));
expect(screen.getByRole('img', { name: 'com_ui_mermaid_diagram' })).toBeInTheDocument();
expect(container.querySelector('pre')).toBeNull();
});
it('switches exclusively to current source while a last-valid diagram is streaming', () => {
mockSvgProcessingState = {
...mockSvgProcessingState,
isLoading: true,
lastValidSvgRef: { current: '<svg width="400" height="200" />' },
};
const { container } = render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<Mermaid id="mermaid-0">{'graph TD\nA-->B'}</Mermaid>
</MemoryRouter>
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_code' }));
expect(container.querySelector('pre')?.textContent).toBe('graph TD\nA-->B');
expect(screen.queryByRole('img', { name: 'com_ui_mermaid_diagram' })).not.toBeInTheDocument();
expect(screen.getByTestId('mermaid-zoom-controls')).not.toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_preview' }));
expect(screen.getByRole('img', { name: 'com_ui_mermaid_diagram' })).toBeInTheDocument();
expect(container.querySelector('pre')).toBeNull();
});
it('renders a toolbar-free native preview for the Artifact panel', () => {
render(
<MermaidRenderer
exportFilename="Flow chart"
fillContainer
showExpandButton={false}
showHeader={false}
>
{'graph TD\nA-->B'}
</MermaidRenderer>,
);
const diagram = screen.getByRole('img', { name: 'com_ui_mermaid_diagram' });
const canvas = screen.getByTestId('mermaid-artifact-canvas');
expect(diagram).toBeInTheDocument();
expect(canvas).toHaveClass('h-full', 'rounded-lg');
expect(canvas).not.toHaveAttribute('style');
expect(canvas.parentElement).toHaveClass('h-full', 'rounded-xl');
expect(screen.getByTestId('mermaid-zoom-controls')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'com_ui_show_code' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: mockExpandLabel })).not.toBeInTheDocument();
});
it('shares its rendered SVG with the Artifact toolbar instead of rendering the source again', async () => {
const onExportReady = jest.fn();
render(
<MermaidRenderer
exportFilename="Flow chart"
onExportReady={onExportReady}
showExpandButton={false}
showHeader={false}
>
{'graph TD\nA-->B'}
</MermaidRenderer>,
);
await waitFor(() =>
expect(onExportReady).toHaveBeenCalledWith({
svg: mockProcessedSvg,
dimensions: { width: 400, height: 200 },
}),
);
});
it('withdraws the export payload when the preview unmounts', async () => {
const onExportReady = jest.fn();
const { unmount } = render(
<MermaidRenderer
exportFilename="Flow chart"
onExportReady={onExportReady}
showExpandButton={false}
showHeader={false}
>
{'graph TD\nA-->B'}
</MermaidRenderer>,
);
await waitFor(() => expect(onExportReady).toHaveBeenCalledWith(expect.objectContaining({})));
unmount();
expect(onExportReady).toHaveBeenLastCalledWith(null);
});
it('announces loading without exposing source in the native Artifact preview', () => {
mockSvgProcessingState = {
...mockSvgProcessingState,
isLoading: true,
lastValidSvgRef: { current: null },
};
const { container } = render(
<MermaidRenderer
exportFilename="Flow chart"
fillContainer
showExpandButton={false}
showHeader={false}
>
{'graph TD\nA-->B'}
</MermaidRenderer>,
);
expect(screen.getByRole('status')).toHaveTextContent('com_ui_loading');
expect(screen.getByRole('status')).toHaveClass('h-full', 'rounded-xl');
expect(container.querySelector('pre')).toBeNull();
});
it('updates an open tool Artifact when a newer instance replaces the same file', async () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
const handleStateChange = (nextState: ArtifactStateSnapshot) => {
state = nextState;
};
const renderTree = (content: string, version: string) => (
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<MessageContext.Provider
value={{ messageId: 'message-1', conversationId: 'conversation-1', isExpanded: true }}
>
<StateProbe onChange={handleStateChange} />
<Mermaid
key={version}
id="file-1"
artifact={{
id: 'tool-artifact-file-1',
type: 'application/vnd.mermaid',
title: 'flow.mmd',
content,
lastUpdateTime: 1,
}}
>
{content}
</Mermaid>
</MessageContext.Provider>
</MemoryRouter>
</RecoilRoot>
);
const { rerender } = render(renderTree('graph TD\nA-->B', 'v1'));
fireEvent.click(screen.getByRole('button', { name: mockOpenAsArtifactLabel }));
expect(state.artifacts?.['tool-artifact-file-1']?.content).toBe('graph TD\nA-->B');
rerender(renderTree('graph TD\nA-->C', 'v2'));
await waitFor(() =>
expect(state.artifacts?.['tool-artifact-file-1']?.content).toBe('graph TD\nA-->C'),
);
expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
expect(state.currentArtifactId).toBe('tool-artifact-file-1');
});
it('gives Mermaid blocks without explicit IDs separate Artifacts', async () => {
let state: ArtifactStateSnapshot = {
artifacts: null,
currentArtifactId: null,
visible: false,
};
render(
<RecoilRoot>
<MemoryRouter initialEntries={['/c/conversation-1']}>
<MessageContext.Provider
value={{ messageId: 'message-1', conversationId: 'conversation-1', isExpanded: true }}
>
<StateProbe
onChange={(nextState) => {
state = nextState;
}}
/>
<Mermaid>{'graph TD\nA-->B'}</Mermaid>
<Mermaid>{'graph TD\nC-->D'}</Mermaid>
</MessageContext.Provider>
</MemoryRouter>
</RecoilRoot>,
);
fireEvent.click(screen.getAllByRole('button', { name: mockOpenAsArtifactLabel })[0]);
fireEvent.click(screen.getByRole('button', { name: mockOpenAsArtifactLabel }));
await waitFor(() => expect(Object.keys(state.artifacts ?? {})).toHaveLength(2));
expect(Object.values(state.artifacts ?? {}).map((artifact) => artifact?.content)).toEqual(
expect.arrayContaining(['graph TD\nA-->B', 'graph TD\nC-->D']),
);
});
});

View file

@ -1,21 +1,176 @@
import React, { useEffect, useState, useRef, useCallback, memo } from 'react';
import React, { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { Button, Spinner } from '@librechat/client';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { ProcessedMermaidSvg } from '~/utils/diagram/export';
import { MERMAID_ARTIFACT_TYPE, type Artifact } from '~/common/artifacts';
import MermaidArtifactCard from './MermaidArtifactCard';
import useSvgProcessing from './useSvgProcessing';
import { useMessageContext } from '~/Providers';
import useMermaidZoom from './useMermaidZoom';
import MermaidDialog from './MermaidDialog';
import MermaidHeader from './MermaidHeader';
import ZoomControls from './ZoomControls';
import { isArtifactRoute } from '~/utils';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
import store from '~/store';
interface MermaidProps {
children: string;
id?: string;
theme?: string;
artifact?: Artifact;
}
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
interface MermaidRendererProps extends Omit<MermaidProps, 'artifact'> {
fillContainer?: boolean;
onExpand?: () => void;
onExportReady?: (data: ProcessedMermaidSvg | null) => void;
exportFilename: string;
showExpandButton?: boolean;
showHeader?: boolean;
}
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme, artifact: artifactProp }) => {
const localize = useLocalize();
const location = useLocation();
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, '');
const { messageId, partIndex } = useMessageContext();
const artifactButtonRef = useRef<HTMLButtonElement>(null);
const shouldFocusArtifactCardRef = useRef(false);
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const setArtifacts = useSetRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const defaultTitle = localize('com_ui_mermaid_diagram');
const canOpenArtifact = isArtifactRoute(location.pathname);
/* Each content part renders its own markdown tree, so the per-message
* Mermaid counter restarts at zero in every part: diagrams sitting either
* side of a tool call would otherwise share an id within one message. */
const artifactScope = messageId ? `${messageId}-part${partIndex ?? 0}` : instanceId;
const artifactId = `mermaid-artifact-${artifactScope}-${id || instanceId}`;
const artifact = useMemo<Artifact>(() => {
if (artifactProp != null) {
return {
...artifactProp,
type: artifactProp.type ?? MERMAID_ARTIFACT_TYPE,
title: artifactProp.title ?? defaultTitle,
content: children,
messageId: artifactProp.messageId ?? messageId,
};
}
return {
id: artifactId,
identifier: artifactId,
type: MERMAID_ARTIFACT_TYPE,
title: defaultTitle,
content: children,
messageId,
lastUpdateTime: Date.now(),
};
}, [artifactId, artifactProp, children, defaultTitle, messageId]);
const isSelected = currentArtifactId === artifact.id;
const [isArtifactCard, setIsArtifactCard] = useState(() => canOpenArtifact && isSelected);
const registerArtifact = useCallback(() => {
setArtifacts((previousArtifacts) => {
const existingArtifact = previousArtifacts?.[artifact.id];
if (
existingArtifact != null &&
existingArtifact.content === artifact.content &&
existingArtifact.type === artifact.type &&
existingArtifact.title === artifact.title
) {
return previousArtifacts;
}
return { ...(previousArtifacts ?? {}), [artifact.id]: artifact };
});
}, [artifact, setArtifacts]);
const openArtifact = useCallback(() => {
registerArtifact();
setCurrentArtifactId(artifact.id);
setArtifactsVisible(true);
}, [artifact.id, registerArtifact, setArtifactsVisible, setCurrentArtifactId]);
const handleExpand = useCallback(() => {
shouldFocusArtifactCardRef.current = true;
setIsArtifactCard(true);
openArtifact();
}, [openArtifact]);
const handleArtifactClick = useCallback(() => {
if (!isSelected) {
openArtifact();
return;
}
setCurrentArtifactId(null);
setArtifactsVisible(false);
}, [isSelected, openArtifact, setArtifactsVisible, setCurrentArtifactId]);
/* Closing the panel unmounts `Artifacts`, whose `useArtifacts` cleanup wipes
* `artifactsState`. Subscribing to this diagram's slice re-fires the
* registration once it goes missing, so reopening any card restores every
* expanded diagram to the version navigator rather than just the one
* clicked. `registerArtifact` no-ops when the entry already matches, so this
* cannot loop. Mirrors the self-heal in `ToolArtifactCard`. */
const existingEntry = useRecoilValue(store.artifactByIdSelector(artifact.id));
useEffect(() => {
if (!isArtifactCard) {
return;
}
registerArtifact();
}, [existingEntry, isArtifactCard, registerArtifact]);
useEffect(() => {
if (!isArtifactCard || !shouldFocusArtifactCardRef.current) {
return;
}
artifactButtonRef.current?.focus();
shouldFocusArtifactCardRef.current = false;
}, [isArtifactCard]);
if (canOpenArtifact && isArtifactCard) {
return (
<MermaidArtifactCard
ref={artifactButtonRef}
artifactId={artifact.id}
title={artifact.title ?? defaultTitle}
isSelected={isSelected}
onClick={handleArtifactClick}
/>
);
}
return (
<MermaidRenderer
id={id}
theme={theme}
exportFilename={artifact.title ?? defaultTitle}
onExpand={canOpenArtifact ? handleExpand : undefined}
>
{children}
</MermaidRenderer>
);
});
export const MermaidRenderer = memo(function MermaidRenderer({
children,
id,
theme,
onExpand,
onExportReady,
exportFilename,
fillContainer = false,
showExpandButton = true,
showHeader = true,
}: MermaidRendererProps) {
const localize = useLocalize();
const [showCode, setShowCode] = useState(false);
const [retryCount, setRetryCount] = useState(0);
@ -34,6 +189,7 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const {
blobUrl,
processedSvg,
svgDimensions,
isLoading,
error,
@ -42,6 +198,18 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
calculatedHeight,
} = useSvgProcessing({ content: children, id, theme, retryCount, containerRef });
const exportReadyRef = useRef(onExportReady);
useEffect(() => {
exportReadyRef.current = onExportReady;
onExportReady?.(processedSvg == null ? null : { svg: processedSvg, dimensions: svgDimensions });
}, [onExportReady, processedSvg, svgDimensions]);
/* The preview tab unmounts when the panel switches to the code view, which
* would otherwise leave the toolbar exporting a payload no longer on screen
* and no longer matching an edited source. */
useEffect(() => () => exportReadyRef.current?.(null), []);
const { zoom, pan, isPanning, handleZoomIn, handleZoomOut, handleResetZoom, handleMouseDown } =
useMermaidZoom({ containerRef, wheelDep: blobUrl });
@ -53,7 +221,13 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const handleToggleCode = useCallback(() => setShowCode((prev) => !prev), []);
const handleRetry = useCallback(() => setRetryCount((prev) => prev + 1), []);
const handleExpand = useCallback(() => setIsDialogOpen(true), []);
const handleExpand = useCallback(() => {
if (onExpand != null) {
onExpand();
return;
}
setIsDialogOpen(true);
}, [onExpand]);
const handleContainerClick = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
@ -91,70 +265,126 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
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',
<>
{showExpandButton && onExpand == null && (
<MermaidDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
triggerRef={expandButtonRef}
blobUrl={blobUrl}
codeContent={children}
exportSvg={processedSvg}
exportDimensions={svgDimensions}
exportFilename={exportFilename}
/>
)}
{...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',
'relative w-full overflow-hidden rounded-lg border transition-all duration-200',
fillContainer && 'h-full rounded-xl',
showControls ? 'border-border-light' : 'border-transparent',
)}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
{...hoverHandlers}
onClick={handleContainerClick}
>
<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>
{showHeader && (
<MermaidHeader
className={cn(
showCode
? 'relative z-20 border-b border-border-light bg-surface-secondary'
: 'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
showExpandButton={showExpandButton}
expandButtonRef={expandButtonRef}
expandLabel={onExpand != null ? localize('com_ui_open_as_artifact') : undefined}
exportSvg={processedSvg}
exportDimensions={svgDimensions}
exportFilename={exportFilename}
onExpand={handleExpand}
onToggleCode={handleToggleCode}
/>
)}
{showCode && (
<div className="min-h-[150px] bg-surface-primary-alt p-4">
<pre className="overflow-auto whitespace-pre-wrap font-mono text-xs text-text-secondary">
{children}
</pre>
</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',
}}
ref={containerRef}
hidden={showCode}
className={cn(
'relative overflow-hidden rounded-md p-4 transition-colors duration-200',
fillContainer && 'h-full rounded-lg',
'bg-surface-primary-alt',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={fillContainer ? undefined : { height: `${calculatedHeight}px` }}
data-testid={fillContainer ? 'mermaid-artifact-canvas' : undefined}
onMouseDown={handleMouseDown}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="select-none opacity-70"
style={diagramStyle}
draggable={false}
<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={localize('com_ui_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>
<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>
</>
);
}
if (!showHeader) {
return (
<div
className={cn(
'flex min-h-[200px] w-full items-center justify-center rounded-lg border border-border-light bg-surface-primary-alt text-text-secondary',
fillContainer && 'h-full rounded-xl',
)}
role="status"
>
<Spinner className="size-5" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_loading')}</span>
</div>
);
}
return (
<div className="w-full overflow-hidden rounded-lg border border-border-light">
<div
className={cn(
'w-full overflow-hidden rounded-lg border border-border-light',
fillContainer && 'h-full rounded-xl',
)}
>
<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>
@ -172,34 +402,38 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
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-text-destructive">
{localize('com_ui_mermaid_failed')}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleRetry}
className="h-auto 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>
{showHeader && (
<MermaidHeader
codeContent={children}
showCode={showCode}
onToggleCode={handleToggleCode}
/>
)}
{showCode ? (
<div className="border-t border-border-light bg-surface-primary-alt p-4">
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
<pre className="overflow-auto text-xs text-text-destructive">{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 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-text-destructive">
{localize('com_ui_mermaid_failed')}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleRetry}
className="h-auto gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
>
<RefreshCw className="h-3 w-3" aria-hidden="true" />
{localize('com_ui_retry')}
</Button>
</div>
)}
</div>
<pre className="overflow-auto text-xs text-text-destructive">{error.message}</pre>
</div>
)}
</div>
);
}
@ -210,30 +444,44 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
return (
<>
<MermaidDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
triggerRef={expandButtonRef}
blobUrl={blobUrl}
codeContent={children}
/>
{showExpandButton && onExpand == null && (
<MermaidDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
triggerRef={expandButtonRef}
blobUrl={blobUrl}
codeContent={children}
exportSvg={processedSvg}
exportDimensions={svgDimensions}
exportFilename={exportFilename}
/>
)}
<div
className="relative w-full overflow-hidden rounded-lg border border-border-light transition-all duration-200"
className={cn(
'relative w-full overflow-hidden rounded-lg border border-border-light transition-all duration-200',
fillContainer && 'h-full rounded-xl',
)}
{...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}
/>
{showHeader && (
<MermaidHeader
className="border-b border-border-light bg-surface-secondary"
actionsClassName="transition-opacity duration-200"
codeContent={children}
showCode={showCode}
showExpandButton={showExpandButton}
expandButtonRef={expandButtonRef}
expandLabel={onExpand != null ? localize('com_ui_open_as_artifact') : undefined}
exportSvg={processedSvg}
exportDimensions={svgDimensions}
exportFilename={exportFilename}
onExpand={handleExpand}
onToggleCode={handleToggleCode}
/>
)}
{showCode && (
<div className="border-b border-border-light bg-surface-secondary p-4">
<div className="bg-surface-primary-alt p-4">
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
@ -241,12 +489,15 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
)}
<div
ref={containerRef}
hidden={showCode}
className={cn(
'relative overflow-hidden p-4 transition-colors duration-200',
'bg-surface-primary-alt dark:bg-white/[0.03]',
fillContainer && 'h-full rounded-lg',
'bg-surface-primary-alt',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: `${calculatedHeight}px` }}
style={fillContainer ? undefined : { height: `${calculatedHeight}px` }}
data-testid={fillContainer ? 'mermaid-artifact-canvas' : undefined}
onMouseDown={handleMouseDown}
>
<div
@ -258,7 +509,7 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
>
<img
src={blobUrl}
alt="Mermaid diagram"
alt={localize('com_ui_mermaid_diagram')}
className="select-none"
style={diagramStyle}
draggable={false}
@ -283,5 +534,6 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
});
Mermaid.displayName = 'Mermaid';
MermaidRenderer.displayName = 'MermaidRenderer';
export default Mermaid;

View file

@ -0,0 +1,51 @@
import React, { forwardRef } from 'react';
import { Workflow } from 'lucide-react';
import { Button } from '@librechat/client';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidArtifactCardProps {
artifactId: string;
isSelected: boolean;
title: string;
onClick: () => void;
}
const MermaidArtifactCard = forwardRef<HTMLButtonElement, MermaidArtifactCardProps>(
({ artifactId, isSelected, title, onClick }, ref) => {
const localize = useLocalize();
const actionLabel = isSelected
? localize('com_ui_close_artifact')
: localize('com_ui_open_artifact');
return (
<Button
ref={ref}
variant="subtle"
aria-controls="artifact-viewer"
aria-expanded={isSelected}
data-artifact-trigger={artifactId}
onClick={onClick}
className={cn(
'group my-2 h-auto w-fit max-w-full justify-start gap-2 overflow-hidden whitespace-normal rounded-xl p-2 text-left text-sm shadow-sm',
'border-border-light bg-surface-tertiary transition-all duration-200 hover:bg-surface-hover active:scale-[0.99] motion-reduce:transition-none',
isSelected && 'border-border-medium bg-surface-hover',
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-status-info-subtle text-status-info">
<Workflow className="size-4" aria-hidden="true" />
</span>
<span className="min-w-0 max-w-64 flex-1 overflow-hidden">
<span className="block truncate font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs font-normal text-text-secondary">
{actionLabel}
</span>
</span>
</Button>
);
},
);
MermaidArtifactCard.displayName = 'MermaidArtifactCard';
export default MermaidArtifactCard;

View file

@ -10,9 +10,11 @@ import {
OGDialogTitle,
OGDialogContent,
} from '@librechat/client';
import type { MermaidDimensions } from '~/utils/diagram/export';
import useMermaidZoom from './useMermaidZoom';
import ZoomControls from './ZoomControls';
import { useLocalize } from '~/hooks';
import MermaidExport from './Export';
import cn from '~/utils/cn';
interface MermaidDialogProps {
@ -21,10 +23,22 @@ interface MermaidDialogProps {
triggerRef: React.RefObject<HTMLButtonElement>;
blobUrl: string;
codeContent: string;
exportSvg: string | null;
exportDimensions: MermaidDimensions | null;
exportFilename: string;
}
const MermaidDialog: React.FC<MermaidDialogProps> = memo(
({ open, onOpenChange, triggerRef, blobUrl, codeContent }) => {
({
open,
onOpenChange,
triggerRef,
blobUrl,
codeContent,
exportSvg,
exportDimensions,
exportFilename,
}) => {
const localize = useLocalize();
const [showCode, setShowCode] = useState(false);
const [isCopied, setIsCopied] = useState(false);
@ -74,26 +88,44 @@ const MermaidDialog: React.FC<MermaidDialogProps> = memo(
>
<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">
<div className="flex gap-1 sm:gap-2">
<MermaidExport
svg={exportSvg}
dimensions={exportDimensions}
filename={exportFilename}
buttonClassName="h-8 w-8 p-0"
/>
<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"
aria-label={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
className="size-8 min-w-0 gap-1 rounded-sm p-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0 sm:h-auto sm:w-auto sm:min-w-[6rem] sm:px-1 sm:py-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')}
{showCode ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
<span className="hidden sm:inline">
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</span>
</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"
aria-label={localize('com_ui_copy_code')}
className="size-8 min-w-0 gap-1 rounded-sm p-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0 sm:h-auto sm:w-auto sm:px-1 sm:py-0"
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard className="size-4" aria-hidden="true" />
)}
<span className="hidden sm:inline">{localize('com_ui_copy_code')}</span>
</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" />
@ -126,7 +158,7 @@ const MermaidDialog: React.FC<MermaidDialogProps> = memo(
>
<img
src={blobUrl}
alt="Mermaid diagram"
alt={localize('com_ui_mermaid_diagram')}
className="max-h-full max-w-full select-none object-contain"
style={{
transform: `scale(${zoom})`,

View file

@ -0,0 +1,47 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import MermaidHeader from './MermaidHeader';
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
}));
jest.mock('~/components/Messages/Content/CopyButton', () => {
const ReactModule = jest.requireActual<typeof import('react')>('react');
return {
__esModule: true,
default: ReactModule.forwardRef<HTMLButtonElement>(() => null),
};
});
describe('MermaidHeader', () => {
it('uses code and preview icons for the exclusive view action', async () => {
const user = userEvent.setup();
const onToggleCode = jest.fn();
const { container, rerender } = render(
<MermaidHeader
codeContent={'graph TD\nA-->B'}
showCode={false}
onToggleCode={onToggleCode}
/>,
);
const showCodeButton = screen.getByRole('button', { name: 'com_ui_show_code' });
expect(showCodeButton).toHaveAttribute('aria-pressed', 'false');
expect(container.querySelector('.lucide-code-xml')).not.toBeNull();
await user.click(showCodeButton);
expect(onToggleCode).toHaveBeenCalledTimes(1);
rerender(
<MermaidHeader codeContent={'graph TD\nA-->B'} showCode={true} onToggleCode={onToggleCode} />,
);
const showPreviewButton = screen.getByRole('button', { name: 'com_ui_preview' });
expect(showPreviewButton).toHaveAttribute('aria-pressed', 'true');
expect(container.querySelector('.lucide-eye')).not.toBeNull();
});
});

View file

@ -1,10 +1,11 @@
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 { Code2, Expand, Eye } from 'lucide-react';
import type { MermaidDimensions } from '~/utils/diagram/export';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize } from '~/hooks';
import MermaidExport from './Export';
import cn from '~/utils/cn';
interface MermaidHeaderProps {
@ -14,6 +15,10 @@ interface MermaidHeaderProps {
showCode: boolean;
showExpandButton?: boolean;
expandButtonRef?: React.RefObject<HTMLButtonElement>;
expandLabel?: string;
exportSvg?: string | null;
exportDimensions?: MermaidDimensions | null;
exportFilename?: string;
onExpand?: () => void;
onToggleCode: () => void;
}
@ -29,10 +34,15 @@ const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
showCode,
showExpandButton = false,
expandButtonRef,
expandLabel,
exportSvg,
exportDimensions,
exportFilename,
onExpand,
onToggleCode,
}) => {
const localize = useLocalize();
const toggleLabel = showCode ? localize('com_ui_preview') : localize('com_ui_show_code');
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
@ -64,31 +74,43 @@ const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
<div className={cn('flex items-center gap-1', actionsClassName)}>
{showExpandButton && onExpand && (
<TooltipAnchor
description={localize('com_ui_expand')}
description={expandLabel ?? localize('com_ui_expand')}
render={
<button
ref={expandButtonRef}
type="button"
aria-label={localize('com_ui_expand')}
aria-label={expandLabel ?? localize('com_ui_expand')}
className={iconBtnClass}
onClick={onExpand}
>
<Expand className="h-4 w-4" />
<Expand className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
)}
{exportSvg != null && exportFilename != null && (
<MermaidExport
svg={exportSvg}
dimensions={exportDimensions}
filename={exportFilename}
/>
)}
<TooltipAnchor
description={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
description={toggleLabel}
render={
<button
ref={showCodeButtonRef}
type="button"
aria-label={showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
aria-label={toggleLabel}
aria-pressed={showCode}
className={iconBtnClass}
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCode ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<Code2 className="h-4 w-4" aria-hidden="true" />
)}
</button>
}
/>

View file

@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import useSvgProcessing from './useSvgProcessing';
let mockMermaidResult: {
svg: string | undefined;
isLoading: boolean;
error: Error | undefined;
};
jest.mock('~/hooks', () => ({
useDebouncedMermaid: () => mockMermaidResult,
}));
describe('useSvgProcessing', () => {
let createObjectURL: jest.Mock;
let revokeObjectURL: jest.Mock;
beforeEach(() => {
mockMermaidResult = {
svg: '<svg width="400" height="200"><path /></svg>',
isLoading: false,
error: undefined,
};
createObjectURL = jest
.fn()
.mockReturnValueOnce('blob:first-diagram')
.mockReturnValueOnce('blob:second-diagram');
revokeObjectURL = jest.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
});
it('observes the diagram canvas once it replaces the loading placeholder', () => {
const observerConstructor = window.ResizeObserver as unknown as jest.Mock;
observerConstructor.mockClear();
/* The placeholder shown while the first diagram renders carries no ref, so
* the canvas only exists from the render that has a blob URL. */
mockMermaidResult = { svg: undefined, isLoading: true, error: undefined };
const containerRef: { current: HTMLDivElement | null } = { current: null };
const { rerender } = renderHook(
({ content }) => useSvgProcessing({ content, id: 'diagram', retryCount: 0, containerRef }),
{ initialProps: { content: 'graph TD' } },
);
expect(observerConstructor).not.toHaveBeenCalled();
const canvas = document.createElement('div');
containerRef.current = canvas;
mockMermaidResult = {
svg: '<svg width="400" height="200"><path /></svg>',
isLoading: false,
error: undefined,
};
rerender({ content: 'graph TD\nA-->B' });
const observer = observerConstructor.mock.results.at(-1)?.value as { observe: jest.Mock };
expect(observer.observe).toHaveBeenCalledWith(canvas);
});
it('keeps the last diagram URL while streaming and revokes it after replacement', () => {
const containerRef = { current: null };
const { result, rerender, unmount } = renderHook(
({ content }) =>
useSvgProcessing({
content,
id: 'diagram',
retryCount: 0,
containerRef,
}),
{ initialProps: { content: 'graph TD\nA-->B' } },
);
expect(result.current.blobUrl).toBe('blob:first-diagram');
mockMermaidResult = { svg: undefined, isLoading: true, error: undefined };
rerender({ content: 'graph TD\nA-->' });
expect(result.current.blobUrl).toBe('blob:first-diagram');
expect(result.current.processedSvg).toContain('viewBox="0 0 400 200"');
expect(result.current.svgDimensions).toEqual({ width: 400, height: 200 });
expect(revokeObjectURL).not.toHaveBeenCalled();
mockMermaidResult = {
svg: '<svg width="500" height="250"><path /></svg>',
isLoading: false,
error: undefined,
};
rerender({ content: 'graph TD\nA-->C' });
expect(result.current.blobUrl).toBe('blob:second-diagram');
expect(revokeObjectURL).toHaveBeenCalledWith('blob:first-diagram');
unmount();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:second-diagram');
});
});

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { fixSubgraphTitleContrast } from '~/utils/mermaid';
import { processMermaidSvg } from '~/utils/diagram/export';
import { useDebouncedMermaid } from '~/hooks';
const MIN_CONTAINER_HEIGHT = 200;
@ -13,78 +13,6 @@ interface UseSvgProcessingOptions {
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,
@ -98,6 +26,7 @@ export default function useSvgProcessing({
);
const [containerWidth, setContainerWidth] = useState(700);
const lastValidSvgRef = useRef<string | null>(null);
const lastProcessedSvgRef = useRef<string | null>(null);
const { svg, isLoading, error } = useDebouncedMermaid({
content,
@ -112,6 +41,10 @@ export default function useSvgProcessing({
}
}, [svg]);
/* The canvas only mounts once there is a diagram to show, so the first run
* of this effect sees a null ref while the placeholder is up. Re-running it
* on `blobUrl` picks the real element up; without that, the fit calculation
* below keeps the default width and clips wide diagrams in narrow panels. */
useEffect(() => {
if (!containerRef.current) {
return;
@ -123,14 +56,17 @@ export default function useSvgProcessing({
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [containerRef]);
}, [containerRef, blobUrl]);
const { processedSvg, parsedDimensions } = useMemo(() => {
const { svg: processedSvg, dimensions: parsedDimensions } = useMemo(() => {
if (!svg) {
return { processedSvg: null, parsedDimensions: null };
return { svg: null, dimensions: null };
}
return processSvgString(svg);
return processMermaidSvg(svg);
}, [svg]);
if (processedSvg) {
lastProcessedSvgRef.current = processedSvg;
}
useEffect(() => {
if (parsedDimensions) {
@ -145,9 +81,15 @@ export default function useSvgProcessing({
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [processedSvg]);
useEffect(() => {
if (!blobUrl) {
return;
}
return () => URL.revokeObjectURL(blobUrl);
}, [blobUrl]);
const { initialScale, calculatedHeight } = useMemo(() => {
if (!svgDimensions) {
return { initialScale: 1, calculatedHeight: MAX_CONTAINER_HEIGHT };
@ -166,6 +108,7 @@ export default function useSvgProcessing({
return {
blobUrl,
processedSvg: processedSvg ?? lastProcessedSvgRef.current,
svgDimensions,
isLoading,
error,

View file

@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { lazy, Suspense, useState, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
useMediaQuery,
@ -9,11 +9,12 @@ import {
import type { TMessage } from 'librechat-data-provider';
import type { ArtifactsContextValue } from '~/Providers';
import { ArtifactsProvider, EditorProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { isCodeOnlyArtifact } from '~/utils/artifacts';
import { getLatestText } from '~/utils';
import store from '~/store';
const Artifacts = lazy(() => import('~/components/Artifacts/Artifacts'));
const DEFAULT_ARTIFACT_PANEL_SIZE = 40;
const SHARE_ARTIFACT_PANEL_STORAGE_KEY = 'share:artifacts-panel-size';
const SHARE_ARTIFACT_PANEL_DEFAULT_KEY = 'share:artifacts-panel-size-default';
@ -152,7 +153,9 @@ function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) {
<ArtifactsProvider value={contextValue}>
<EditorProvider>
<div className="flex h-full w-full border-l border-border-light bg-surface-primary shadow-2xl">
<Artifacts />
<Suspense fallback={null}>
<Artifacts />
</Suspense>
</div>
</EditorProvider>
</ArtifactsProvider>

View file

@ -33,6 +33,8 @@ interface UseMermaidOptions {
theme?: string;
/** Custom mermaid configuration */
config?: Partial<MermaidConfig>;
/** Whether rendering should run */
enabled?: boolean;
}
interface UseMermaidReturn {
@ -51,6 +53,7 @@ export const useMermaid = ({
id = DEFAULT_ID_PREFIX,
theme: customTheme,
config,
enabled = true,
}: UseMermaidOptions): UseMermaidReturn => {
const { theme } = useContext(ThemeContext);
const isDarkMode = isDark(theme);
@ -59,7 +62,11 @@ export const useMermaid = ({
const [validContent, setValidContent] = useState<string>('');
// Generate cache key based on content, theme, and ID
const cacheKey = useMemo((): string => {
const cacheKey = useMemo((): string | null => {
if (!enabled) {
return null;
}
// For large diagrams, use MD5 hash instead of full content
const contentHash = content.length < MD5_LENGTH_THRESHOLD ? content : Md5.hashStr(content);
@ -67,7 +74,7 @@ export const useMermaid = ({
const themeKey = customTheme || (isDarkMode ? 'd' : 'l');
return [id, themeKey, contentHash].filter(Boolean).join('-');
}, [content, id, isDarkMode, customTheme]);
}, [content, enabled, id, isDarkMode, customTheme]);
// Generate unique diagram ID (mermaid requires unique IDs in the DOM)
// Include cacheKey to regenerate when content/theme changes, preventing mermaid internal conflicts

View file

@ -978,6 +978,7 @@
"com_ui_client_id": "Client ID",
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Close",
"com_ui_close_artifact": "Close artifact",
"com_ui_close_menu": "Close Menu",
"com_ui_close_settings": "Close Settings",
"com_ui_close_var": "Close {{0}}",
@ -1202,10 +1203,13 @@
"com_ui_export_convo_modal": "Export Conversation Modal",
"com_ui_export_file_search": "File Search",
"com_ui_export_image": "Image",
"com_ui_export_mermaid": "Export diagram",
"com_ui_export_png": "Export as PNG",
"com_ui_export_retrieval": "Retrieval",
"com_ui_export_share_link_active": "Export/Share, link active",
"com_ui_export_steer": "You (steered)",
"com_ui_export_summary": "Summary",
"com_ui_export_svg": "Export as SVG",
"com_ui_export_tool": "Tool",
"com_ui_export_video": "Video",
"com_ui_failed": "Failed",
@ -1515,8 +1519,11 @@
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
"com_ui_mermaid": "mermaid",
"com_ui_mermaid_diagram": "Mermaid diagram",
"com_ui_mermaid_export_complete": "Diagram download started.",
"com_ui_mermaid_export_failed": "Could not export this diagram. Please try again.",
"com_ui_mermaid_exporting_png": "Creating PNG...",
"com_ui_mermaid_failed": "Failed to render diagram:",
"com_ui_mermaid_source": "Source code:",
"com_ui_message_input": "Message input",
"com_ui_message_nav": "Message navigation",
"com_ui_message_nav_go_to_assistant": "Go to assistant message: {{0}}",
@ -1598,6 +1605,8 @@
"com_ui_omitted": "Omitted",
"com_ui_on": "On",
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
"com_ui_open_artifact": "Open artifact",
"com_ui_open_as_artifact": "Open as artifact",
"com_ui_open_project": "Open project",
"com_ui_open_source_chat_new_tab": "Open Source Chat in New Tab",
"com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}",
@ -1730,6 +1739,7 @@
"com_ui_reset_adjustments": "Reset adjustments",
"com_ui_reset_var": "Reset {{0}}",
"com_ui_reset_zoom": "Reset Zoom",
"com_ui_resize_artifact_panel": "Resize artifact panel",
"com_ui_resource": "resource",
"com_ui_respond": "Respond",
"com_ui_response": "Response",

View file

@ -83,6 +83,21 @@ describe('mermaid config', () => {
const files = getMermaidFiles('', true);
expect(files['diagram.mmd']).toBe('# No mermaid diagram content provided');
});
it('serializes special Mermaid labels as a TSX string literal', () => {
const specialContent = 'flowchart TD\n A["`code ${danger} C:\\temp`"] --> B';
const files = getMermaidFiles(specialContent, true);
expect(files['App.tsx']).toContain(`content={${JSON.stringify(specialContent)}}`);
expect(files['App.tsx']).not.toContain('content={`');
});
it('declares the generated App component before exporting it', () => {
const files = getMermaidFiles(content, true);
expect(files['App.tsx']).toContain('const App = () =>');
expect(files['App.tsx']).toContain('export default App;');
});
});
describe('fixSubgraphTitleContrast', () => {

View file

@ -6,6 +6,7 @@ import type {
} from '@codesandbox/sandpack-react';
import type { TStartupConfig, TAttachment, TFile } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import { MERMAID_ARTIFACT_TYPE } from '~/common/artifacts';
const artifactFilename = {
'application/vnd.react': 'App.tsx',
@ -284,7 +285,7 @@ export const TOOL_ARTIFACT_TYPES = {
HTML: 'text/html',
REACT: 'application/vnd.react',
MARKDOWN: 'text/markdown',
MERMAID: 'application/vnd.mermaid',
MERMAID: MERMAID_ARTIFACT_TYPE,
PLAIN_TEXT: 'text/plain',
CODE: 'application/vnd.code',
/* Office-format rich previews. The backend renders the binary file as a

View file

@ -0,0 +1,177 @@
import {
applyMermaidBackground,
processMermaidSvg,
resolveCanvasDimensions,
downloadMermaidPng,
downloadMermaidSvg,
} from './export';
import { triggerDownload } from '~/utils/downloadFile';
jest.mock('~/utils/downloadFile', () => ({
triggerDownload: jest.fn(),
}));
const mockTriggerDownload = jest.mocked(triggerDownload);
describe('Mermaid export', () => {
let createObjectURL: jest.Mock;
beforeEach(() => {
createObjectURL = jest.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
mockTriggerDownload.mockReset();
});
it('normalizes Mermaid SVG markup for display and export', () => {
const result = processMermaidSvg('<svg width="400" height="200"><path /></svg>');
expect(result.dimensions).toEqual({ width: 400, height: 200 });
expect(result.svg).toContain('viewBox="0 0 400 200"');
expect(result.svg).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(result.svg).not.toContain('width="400"');
expect(result.svg).not.toContain('height="200"');
});
it('uses the viewBox when Mermaid emits responsive percentage dimensions', () => {
const result = processMermaidSvg(
'<svg width="100%" height="100%" viewBox="-8 -8 416 216"><path /></svg>',
);
expect(result.dimensions).toEqual({ width: 416, height: 216 });
});
it('rounds rectangular Mermaid nodes without changing cluster containers', () => {
const result = processMermaidSvg(
'<svg viewBox="0 0 200 100"><g class="node default"><rect width="80" height="40" /></g><g class="cluster"><rect width="180" height="80" /></g></svg>',
);
const document = new DOMParser().parseFromString(result.svg, 'image/svg+xml');
const nodeRectangle = document.querySelector('g.node rect');
const clusterRectangle = document.querySelector('g.cluster rect');
expect(nodeRectangle?.getAttribute('rx')).toBe('8');
expect(nodeRectangle?.getAttribute('ry')).toBe('8');
expect(clusterRectangle?.hasAttribute('rx')).toBe(false);
});
it('embeds the active surface behind an exported SVG', () => {
const svg = applyMermaidBackground(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 416 216"><path /></svg>',
'rgb(23 23 23)',
);
expect(svg).toContain('<rect x="-8" y="-8" width="416" height="216" fill="rgb(23 23 23)"');
});
it('caps PNG canvas allocations to 16 megapixels', () => {
expect(resolveCanvasDimensions(10_000, 10_000)).toEqual({ width: 4096, height: 4096 });
});
it('keeps lopsided diagrams under the pixel budget after rounding', () => {
const cases: Array<[number, number]> = [
[3129, 50_000],
[50_000, 3129],
[1, 90_000],
[7777, 33_333],
[12_345, 4321],
];
for (const [width, height] of cases) {
const canvas = resolveCanvasDimensions(width, height);
expect(canvas.width * canvas.height).toBeLessThanOrEqual(16_777_216);
expect(canvas.width).toBeLessThanOrEqual(16_384);
expect(canvas.height).toBeLessThanOrEqual(16_384);
}
});
it('downloads a fresh SVG blob with a format-specific filename', () => {
createObjectURL.mockReturnValue('blob:svg-download');
downloadMermaidSvg('<svg xmlns="http://www.w3.org/2000/svg" />', 'flow.mmd');
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(blob.type).toBe('image/svg+xml;charset=utf-8');
expect(mockTriggerDownload).toHaveBeenCalledWith('blob:svg-download', 'flow.svg');
});
it('rasterizes a themed PNG at 2x from a canvas-safe SVG data URL', async () => {
const image = document.createElement('img');
let imageSource = '';
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 100 });
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 50 });
Object.defineProperty(image, 'src', {
configurable: true,
set: (value: string) => {
imageSource = value;
image.onload?.(new Event('load'));
},
});
const imageSpy = jest.spyOn(window, 'Image').mockImplementation(() => image);
const drawImage = jest.fn();
const fillRect = jest.fn();
const context: CanvasRenderingContext2D = Object.create(null);
context.drawImage = drawImage;
context.fillRect = fillRect;
context.imageSmoothingEnabled = false;
context.imageSmoothingQuality = 'low';
const canvases: HTMLCanvasElement[] = [];
const getContextSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(function (this: HTMLCanvasElement) {
canvases.push(this);
return context;
});
const toBlobSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'toBlob')
.mockImplementation((callback) => callback(new Blob(['png'], { type: 'image/png' })));
createObjectURL.mockReturnValue('blob:png-output');
await downloadMermaidPng(
'<svg xmlns="http://www.w3.org/2000/svg" />',
'flow.mermaid',
{ width: 100, height: 50 },
'rgb(247 247 248)',
);
expect(canvases[0]).toMatchObject({ width: 200, height: 100 });
expect(context.fillStyle).toBe('rgb(247 247 248)');
expect(fillRect).toHaveBeenCalledWith(0, 0, 200, 100);
expect(drawImage).toHaveBeenCalledWith(image, 0, 0, 200, 100);
expect(imageSource).toMatch(/^data:image\/svg\+xml;charset=utf-8;base64,/);
expect(atob(imageSource.split(',')[1])).toContain('<svg');
expect(mockTriggerDownload).toHaveBeenCalledWith('blob:png-output', 'flow.png');
imageSpy.mockRestore();
getContextSpy.mockRestore();
toBlobSpy.mockRestore();
});
it('does not download when PNG encoding fails', async () => {
const image = document.createElement('img');
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 100 });
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 50 });
Object.defineProperty(image, 'src', {
configurable: true,
set: () => image.onload?.(new Event('load')),
});
const imageSpy = jest.spyOn(window, 'Image').mockImplementation(() => image);
const context: CanvasRenderingContext2D = Object.create(null);
context.drawImage = jest.fn();
const getContextSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue(context);
const toBlobSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'toBlob')
.mockImplementation((callback) => callback(null));
await expect(
downloadMermaidPng('<svg xmlns="http://www.w3.org/2000/svg" />', 'flow'),
).rejects.toThrow('Failed to encode Mermaid diagram as PNG');
expect(mockTriggerDownload).not.toHaveBeenCalled();
imageSpy.mockRestore();
getContextSpy.mockRestore();
toBlobSpy.mockRestore();
});
});

View file

@ -0,0 +1,245 @@
import { fixSubgraphTitleContrast } from '~/utils/mermaid';
import { triggerDownload } from '~/utils/downloadFile';
const PNG_EXPORT_SCALE = 2;
const MAX_CANVAS_DIMENSION = 16_384;
const MAX_CANVAS_PIXELS = 16_777_216;
export interface MermaidDimensions {
width: number;
height: number;
}
export interface ProcessedMermaidSvg {
svg: string;
dimensions: MermaidDimensions | null;
}
function absoluteDimension(value: string | null): number {
const normalized = value?.trim() ?? '';
if (!/^(?:\d+(?:\.\d+)?|\.\d+)(?:px)?$/i.test(normalized)) {
return 0;
}
return parseFloat(normalized);
}
function applyFallbackFixes(svg: string): string {
let result = svg;
if (!svg.includes('viewBox') && svg.includes('height=') && svg.includes('width=')) {
const widthMatch = svg.match(/width="([\d.]+)"/);
const heightMatch = svg.match(/height="([\d.]+)"/);
if (widthMatch && heightMatch) {
result = result.replace('<svg', `<svg viewBox="0 0 ${widthMatch[1]} ${heightMatch[1]}"`);
}
}
if (!result.includes('xmlns')) {
result = result.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
return result;
}
function roundMermaidNodeCorners(svgElement: Element): void {
const radius = 8;
for (const rectangle of svgElement.querySelectorAll('g.node rect')) {
const currentRadius = Number.parseFloat(rectangle.getAttribute('rx') ?? '');
if (Number.isFinite(currentRadius) && currentRadius >= radius) {
continue;
}
rectangle.setAttribute('rx', String(radius));
rectangle.setAttribute('ry', String(radius));
}
}
export function processMermaidSvg(svg: string): ProcessedMermaidSvg {
const parser = new DOMParser();
const document = parser.parseFromString(svg, 'image/svg+xml');
if (document.querySelector('parsererror')) {
return { svg: applyFallbackFixes(svg), dimensions: null };
}
const svgElement = document.querySelector('svg');
if (!svgElement) {
return { svg: applyFallbackFixes(svg), dimensions: null };
}
let width = absoluteDimension(svgElement.getAttribute('width'));
let height = absoluteDimension(svgElement.getAttribute('height'));
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: MermaidDimensions | 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);
roundMermaidNodeCorners(svgElement);
return {
svg: new XMLSerializer().serializeToString(document),
dimensions,
};
}
export function applyMermaidBackground(svg: string, background?: string): string {
if (!background) {
return svg;
}
const parser = new DOMParser();
const document = parser.parseFromString(svg, 'image/svg+xml');
const svgElement = document.querySelector('svg');
if (!svgElement || document.querySelector('parsererror')) {
return svg;
}
const viewBox = svgElement
.getAttribute('viewBox')
?.split(/[\s,]+/)
.map(Number);
const hasViewBox =
viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0;
const backgroundElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
backgroundElement.setAttribute('x', hasViewBox ? String(viewBox[0]) : '0');
backgroundElement.setAttribute('y', hasViewBox ? String(viewBox[1]) : '0');
backgroundElement.setAttribute('width', hasViewBox ? String(viewBox[2]) : '100%');
backgroundElement.setAttribute('height', hasViewBox ? String(viewBox[3]) : '100%');
backgroundElement.setAttribute('fill', background);
backgroundElement.setAttribute('data-mermaid-export-background', 'true');
svgElement.insertBefore(backgroundElement, svgElement.firstChild);
return new XMLSerializer().serializeToString(document);
}
function exportFilename(filename: string, extension: 'svg' | 'png'): string {
const baseName = filename
.trim()
.replace(/\.(?:mermaid|mmd|svg|png)$/i, '')
.replace(/[\\/:*?"<>|]+/g, '-')
.trim();
return `${baseName || 'mermaid-diagram'}.${extension}`;
}
function svgBlob(svg: string): Blob {
return new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
}
function blobDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to prepare Mermaid SVG for PNG export'));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(new Error('Failed to prepare Mermaid SVG for PNG export'));
reader.readAsDataURL(blob);
});
}
function loadSvgImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load Mermaid SVG for PNG export'));
image.src = url;
});
}
export function resolveCanvasDimensions(width: number, height: number): MermaidDimensions {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new Error('Mermaid diagram has invalid export dimensions');
}
const scale = Math.min(
PNG_EXPORT_SCALE,
MAX_CANVAS_DIMENSION / width,
MAX_CANVAS_DIMENSION / height,
Math.sqrt(MAX_CANVAS_PIXELS / (width * height)),
);
/* Round down: rounding each side independently can carry the product back
* over the pixel budget the scale was chosen to satisfy, and a canvas above
* that area makes `toBlob` fail outright in browsers that enforce it. */
return {
width: Math.max(1, Math.floor(width * scale)),
height: Math.max(1, Math.floor(height * scale)),
};
}
function encodePng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to encode Mermaid diagram as PNG'));
return;
}
resolve(blob);
}, 'image/png');
});
}
export function downloadMermaidSvg(svg: string, filename: string, background?: string): void {
const url = URL.createObjectURL(svgBlob(applyMermaidBackground(svg, background)));
triggerDownload(url, exportFilename(filename, 'svg'));
}
export async function downloadMermaidPng(
svg: string,
filename: string,
dimensions?: MermaidDimensions | null,
background?: string,
): Promise<void> {
const sourceUrl = await blobDataUrl(svgBlob(svg));
const image = await loadSvgImage(sourceUrl);
const sourceWidth = dimensions?.width || image.naturalWidth || image.width;
const sourceHeight = dimensions?.height || image.naturalHeight || image.height;
const canvasDimensions = resolveCanvasDimensions(sourceWidth, sourceHeight);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas is unavailable for Mermaid PNG export');
}
canvas.width = canvasDimensions.width;
canvas.height = canvasDimensions.height;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
if (background) {
context.fillStyle = background;
context.fillRect(0, 0, canvas.width, canvas.height);
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const png = await encodePng(canvas);
const outputUrl = URL.createObjectURL(png);
triggerDownload(outputUrl, exportFilename(filename, 'png'));
}

View file

@ -449,12 +449,15 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
export default MermaidDiagram;`);
const wrapMermaidDiagram = (content: string) => {
const serializedContent = JSON.stringify(content);
return dedent(`import React from 'react';
import MermaidDiagram from '/components/ui/MermaidDiagram';
export default App = () => (
<MermaidDiagram content={\`${content}\`} />
const App = () => (
<MermaidDiagram content={${serializedContent}} />
);
export default App;
`);
};

View file

@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
import mockConfig from './playwright.config.mock';
export default defineConfig({
...mockConfig,
testMatch: /mermaid-artifacts\.spec\.ts/,
outputDir: 'specs/.test-results/mermaid-browsers',
use: {
...mockConfig.use,
video: 'off',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});

View file

@ -37,6 +37,9 @@ const ACTIVITY_REPLY_MARKER = 'E2E_ACTIVITY_REPLY:';
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
const MERMAID_ARTIFACT_REPLY_MARKER = 'E2E_MERMAID_ARTIFACT_REPLY';
const LARGE_MERMAID_ARTIFACT_REPLY_MARKER = 'E2E_LARGE_MERMAID_ARTIFACT_REPLY';
const HTML_ARTIFACT_REPLY_MARKER = 'E2E_HTML_ARTIFACT_REPLY';
const BACKGROUND_DISPATCH_MARKER = 'E2E_BACKGROUND_DISPATCH:';
const BACKGROUND_COLLECT_MARKER = 'E2E_BACKGROUND_COLLECT:';
const TOOL_APPROVAL_MARKER = 'E2E_TOOL_APPROVAL:';
@ -376,6 +379,37 @@ function quoteAssertionResponses({ messages, text }) {
}
function replyResponses(text) {
if (text.includes(LARGE_MERMAID_ARTIFACT_REPLY_MARKER)) {
const diagram = ['```mermaid', 'flowchart TB'];
for (let index = 0; index < 180; index++) {
diagram.push(`N${index}["Processing stage ${index} with representative content"]`);
if (index > 0) {
diagram.push(`N${index - 1} --> N${index}`);
}
}
diagram.push('```');
return { responses: [diagram.join('\n')], sleep: 0 };
}
if (text.includes(MERMAID_ARTIFACT_REPLY_MARKER)) {
return {
responses: [['```mermaid', 'flowchart LR', 'A[Start] --> B[Finish]', '```'].join('\n')],
};
}
if (text.includes(HTML_ARTIFACT_REPLY_MARKER)) {
return {
responses: [
[
':::artifact{identifier="e2e-html" type="text/html" title="E2E HTML Artifact"}',
'<h1>HTML sandbox fixture</h1>',
':::',
].join('\n'),
],
};
}
if (text.includes(MARKDOWN_REPLY_MARKER)) {
return {
responses: [

View file

@ -0,0 +1,199 @@
import { expect, test } from '@playwright/test';
import type { Download, Page, Request } from '@playwright/test';
import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
messagesView,
selectMockEndpoint,
sendMessage,
} from './helpers';
const isStartupConfigRequest = (request: Request) =>
new URL(request.url()).pathname === '/api/config';
const isSandboxResourceRequest = (request: Request) => {
if (request.resourceType() !== 'script') {
return false;
}
const pathname = new URL(request.url()).pathname.toLowerCase();
return pathname.includes('/sandboxartifacttabs.') || pathname.includes('/sandpack.');
};
const waitForForbiddenMermaidRequest = (page: Page) =>
page
.waitForRequest(
(request) => isStartupConfigRequest(request) || isSandboxResourceRequest(request),
{ timeout: 1500 },
)
.then((request) => request.url())
.catch(() => null);
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const MAX_EXPORT_DIMENSION = 16_384;
const MAX_EXPORT_PIXELS = 16_777_216;
const REPEATED_PNG_EXPORTS = 5;
async function downloadBytes(download: Download): Promise<Buffer> {
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
test.describe('Mermaid Artifact resource boundary', () => {
test('opens Mermaid as an Artifact without startup config or Sandpack requests', async ({
page,
}) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const unexpectedRequest = waitForForbiddenMermaidRequest(page);
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
await expect(panel).toBeVisible();
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const canvas = panel.getByTestId('mermaid-artifact-canvas');
await expect(canvas).toHaveClass(/\bh-full\b/);
await expect(canvas).toHaveClass(/\brounded-lg\b/);
const panelBox = await panel.boundingBox();
const canvasBox = await canvas.boundingBox();
expect(panelBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
expect(canvasBox!.height).toBeGreaterThan(panelBox!.height * 0.75);
await expect(panel.locator('iframe')).toHaveCount(0);
const artifactCard = messages.locator('[data-artifact-trigger^="mermaid-artifact-"]');
await expect(artifactCard).toHaveAttribute('aria-expanded', 'true');
await expect(artifactCard).toHaveClass(/\bw-fit\b/);
await expect(artifactCard.locator('.lucide-workflow').locator('..')).toHaveClass(
/\bbg-status-info-subtle\b/,
);
expect(await unexpectedRequest).toBeNull();
});
test('keeps HTML Artifacts on the lazy Sandpack path', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_HTML_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const artifactButton = messagesView(page).getByRole('button', {
name: 'E2E HTML Artifact Click to open',
exact: true,
});
await expect(artifactButton).toBeVisible();
const sandpackRequest = page.waitForRequest(isSandboxResourceRequest, { timeout: 10000 });
await artifactButton.click();
await expect(page.getByRole('region', { name: 'E2E HTML Artifact' })).toBeVisible();
expect((await sandpackRequest).resourceType()).toBe('script');
});
test('exports a Mermaid Artifact as valid SVG and PNG files', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
const exportButton = panel.getByRole('button', { name: 'Export diagram' });
await exportButton.click();
const svgItem = page.getByRole('menuitem', { name: 'Export as SVG', exact: true });
await expect(svgItem).toBeEnabled();
const [svgDownload] = await Promise.all([page.waitForEvent('download'), svgItem.click()]);
expect(svgDownload.suggestedFilename()).toBe('Mermaid diagram.svg');
const svg = (await downloadBytes(svgDownload)).toString('utf8');
expect(svg).toMatch(/<svg\b/);
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(svg).toContain('rx="8"');
await exportButton.click();
const pngItem = page.getByRole('menuitem', { name: 'Export as PNG', exact: true });
await expect(pngItem).toBeEnabled();
const [pngDownload] = await Promise.all([
page.waitForEvent('download', { timeout: 15000 }),
pngItem.click(),
]);
expect(pngDownload.suggestedFilename()).toBe('Mermaid diagram.png');
const png = await downloadBytes(pngDownload);
expect(png.length).toBeGreaterThan(24);
expect(png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)).toBe(true);
expect(png.readUInt32BE(16)).toBeGreaterThan(0);
expect(png.readUInt32BE(20)).toBeGreaterThan(0);
await expect(panel.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
});
test('repeatedly exports a large Mermaid Artifact without crashing the browser', async ({
page,
}, testInfo) => {
testInfo.setTimeout(90_000);
let didCrash = false;
page.on('crash', () => {
didCrash = true;
});
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'E2E_LARGE_MERMAID_ARTIFACT_REPLY');
expect(response.ok()).toBeTruthy();
const messages = messagesView(page);
await expect(messages.getByRole('img', { name: 'Mermaid diagram' })).toBeVisible();
await messages.getByRole('button', { name: 'Open as artifact', exact: true }).click();
const panel = page.getByRole('region', { name: 'Mermaid diagram' });
const diagram = panel.getByRole('img', { name: 'Mermaid diagram' });
await expect(diagram).toBeVisible();
const exportButton = panel.getByRole('button', { name: 'Export diagram' });
await exportButton.click();
const svgItem = page.getByRole('menuitem', { name: 'Export as SVG', exact: true });
const [svgDownload] = await Promise.all([page.waitForEvent('download'), svgItem.click()]);
const svg = (await downloadBytes(svgDownload)).toString('utf8');
expect(svg.length).toBeGreaterThan(100_000);
expect(svg).toContain('Processing stage 179 with representative content');
const pngItem = page.getByRole('menuitem', { name: 'Export as PNG', exact: true });
for (let attempt = 0; attempt < REPEATED_PNG_EXPORTS; attempt++) {
await exportButton.click();
await expect(pngItem).toBeEnabled();
const [pngDownload] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
pngItem.click(),
]);
expect(pngDownload.suggestedFilename()).toBe('Mermaid diagram.png');
const png = await downloadBytes(pngDownload);
expect(png.length).toBeGreaterThan(24);
expect(png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)).toBe(true);
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect(Math.max(width, height)).toBeLessThanOrEqual(MAX_EXPORT_DIMENSION);
expect(width * height).toBeLessThanOrEqual(MAX_EXPORT_PIXELS);
await expect(exportButton).not.toHaveAttribute('aria-busy', 'true');
expect(didCrash).toBe(false);
await expect(diagram).toBeVisible();
}
});
});