mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
📝 feat: Add Markdown Rendering Support for Artifacts (#10049)
* Add Markdown rendering support for artifacts * Add tests * Remove custom code for mermaid * Remove unnecessary dark mode hook * refactor: optimize mermaid dependencies - Added support for additional MIME types in artifact templates. - Updated mermaidDependencies to include new packages: class-variance-authority, clsx, tailwind-merge, and @radix-ui/react-slot. - Refactored zoom and refresh icons in MermaidDiagram component for improved clarity and maintainability. * fix: add Markdown support for artifacts rendering * feat: support 'text/md' as an additional MIME type for Markdown artifacts * refactor: simplify markdownDependencies structure in artifacts utility --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
e9a85d5c65
commit
7c9a868d34
13 changed files with 760 additions and 88 deletions
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal file
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import useArtifactProps from '../useArtifactProps';
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
describe('useArtifactProps', () => {
|
||||
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
|
||||
id: 'test-id',
|
||||
lastUpdateTime: Date.now(),
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('markdown artifacts', () => {
|
||||
it('should handle text/markdown type with content.md as fileKey', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '# Hello World\n\nThis is markdown.',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.fileKey).toBe('content.md');
|
||||
expect(result.current.template).toBe('react-ts');
|
||||
});
|
||||
|
||||
it('should handle text/plain type with content.md as fileKey', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/plain',
|
||||
content: '# Plain text as markdown',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.fileKey).toBe('content.md');
|
||||
expect(result.current.template).toBe('react-ts');
|
||||
});
|
||||
|
||||
it('should include content.md in files with original markdown', () => {
|
||||
const markdownContent = '# Test\n\n- Item 1\n- Item 2';
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: markdownContent,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.files['content.md']).toBe(markdownContent);
|
||||
});
|
||||
|
||||
it('should include App.tsx with wrapped markdown renderer', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '# Test',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.files['App.tsx']).toContain('MarkdownRenderer');
|
||||
expect(result.current.files['App.tsx']).toContain('import React from');
|
||||
});
|
||||
|
||||
it('should include all required markdown files', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '# Test',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
// Check all required files are present
|
||||
expect(result.current.files['content.md']).toBeDefined();
|
||||
expect(result.current.files['App.tsx']).toBeDefined();
|
||||
expect(result.current.files['index.tsx']).toBeDefined();
|
||||
expect(result.current.files['/components/ui/MarkdownRenderer.tsx']).toBeDefined();
|
||||
expect(result.current.files['markdown.css']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should escape special characters in markdown content', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: 'Code: `const x = 1;`\nPath: C:\\Users',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
// Original content should be preserved in content.md
|
||||
expect(result.current.files['content.md']).toContain('`const x = 1;`');
|
||||
expect(result.current.files['content.md']).toContain('C:\\Users');
|
||||
|
||||
// App.tsx should have escaped content
|
||||
expect(result.current.files['App.tsx']).toContain('\\`');
|
||||
expect(result.current.files['App.tsx']).toContain('\\\\');
|
||||
});
|
||||
|
||||
it('should handle empty markdown content', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.files['content.md']).toBe('# No content provided');
|
||||
});
|
||||
|
||||
it('should handle undefined markdown content', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.files['content.md']).toBe('# No content provided');
|
||||
});
|
||||
|
||||
it('should provide marked-react dependency', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '# Test',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('marked-react');
|
||||
});
|
||||
|
||||
it('should update files when content changes', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
content: '# Original',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ artifact }) => useArtifactProps({ artifact }), {
|
||||
initialProps: { artifact },
|
||||
});
|
||||
|
||||
expect(result.current.files['content.md']).toBe('# Original');
|
||||
|
||||
// Update the artifact content
|
||||
const updatedArtifact = createArtifact({
|
||||
...artifact,
|
||||
content: '# Updated',
|
||||
});
|
||||
|
||||
rerender({ artifact: updatedArtifact });
|
||||
|
||||
expect(result.current.files['content.md']).toBe('# Updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mermaid artifacts', () => {
|
||||
it('should handle mermaid type with content.md as fileKey', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'application/vnd.mermaid',
|
||||
content: 'graph TD\n A-->B',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.fileKey).toBe('diagram.mmd');
|
||||
expect(result.current.template).toBe('react-ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('react artifacts', () => {
|
||||
it('should handle react type with App.tsx as fileKey', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'application/vnd.react',
|
||||
content: 'export default () => <div>Test</div>',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.fileKey).toBe('App.tsx');
|
||||
expect(result.current.template).toBe('react-ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('html artifacts', () => {
|
||||
it('should handle html type with index.html as fileKey', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/html',
|
||||
content: '<html><body>Test</body></html>',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
expect(result.current.fileKey).toBe('index.html');
|
||||
expect(result.current.template).toBe('static');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle artifact with language parameter', () => {
|
||||
const artifact = createArtifact({
|
||||
type: 'text/markdown',
|
||||
language: 'en',
|
||||
content: '# Test',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
// Language parameter should not affect markdown handling
|
||||
// It checks the type directly, not the key
|
||||
expect(result.current.fileKey).toBe('content.md');
|
||||
expect(result.current.files['content.md']).toBe('# Test');
|
||||
});
|
||||
|
||||
it('should handle artifact with undefined type', () => {
|
||||
const artifact = createArtifact({
|
||||
content: '# Test',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifactProps({ artifact }));
|
||||
|
||||
// Should use default behavior
|
||||
expect(result.current.template).toBe('static');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider';
|
|||
import type { Artifact } from '~/common';
|
||||
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
|
||||
import { getMermaidFiles } from '~/utils/mermaid';
|
||||
import { getMarkdownFiles } from '~/utils/markdown';
|
||||
|
||||
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
|
||||
const [fileKey, files] = useMemo(() => {
|
||||
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
|
||||
return ['App.tsx', getMermaidFiles(artifact.content ?? '')];
|
||||
const key = getKey(artifact.type ?? '', artifact.language);
|
||||
const type = artifact.type ?? '';
|
||||
|
||||
if (key.includes('mermaid')) {
|
||||
return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')];
|
||||
}
|
||||
|
||||
if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') {
|
||||
return ['content.md', getMarkdownFiles(artifact.content ?? '')];
|
||||
}
|
||||
|
||||
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
|
||||
|
|
|
|||
|
|
@ -122,17 +122,8 @@ export default function useArtifacts() {
|
|||
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
||||
};
|
||||
|
||||
const isMermaid = useMemo(() => {
|
||||
if (currentArtifact?.type == null) {
|
||||
return false;
|
||||
}
|
||||
const key = getKey(currentArtifact.type, currentArtifact.language);
|
||||
return key.includes('mermaid');
|
||||
}, [currentArtifact?.type, currentArtifact?.language]);
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
isMermaid,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue