mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-29 14:48:51 +01:00
feat: second pass
This commit is contained in:
parent
6ee70acdc5
commit
0a54489842
9 changed files with 163 additions and 121 deletions
|
|
@ -2,4 +2,13 @@ export interface CodeBlock {
|
|||
id: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
identifier?: string;
|
||||
language?: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { artifactsState, artifactIdsState } from '~/store/artifacts';
|
||||
import type { Pluggable } from 'unified';
|
||||
import throttle from 'lodash/throttle';
|
||||
import type { Artifact } from '~/common';
|
||||
import { artifactsState, artifactIdsState } from '~/store/artifacts';
|
||||
import CodePreview from './CodePreview';
|
||||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
return (tree) => {
|
||||
|
|
@ -18,9 +20,32 @@ export const artifactPlugin: Pluggable = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export function Artifact({ node, ...props }) {
|
||||
const extractContent = (
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } } | string,
|
||||
): string => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
if (React.isValidElement(children)) {
|
||||
return extractContent((children.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(extractContent).join('');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function Artifact({
|
||||
node,
|
||||
...props
|
||||
}: Artifact & {
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||
node: unknown;
|
||||
}) {
|
||||
const setArtifacts = useSetRecoilState(artifactsState);
|
||||
const setArtifactIds = useSetRecoilState(artifactIdsState);
|
||||
const [artifact, setArtifact] = useState<Artifact | null>(null);
|
||||
|
||||
const throttledUpdateRef = useRef(
|
||||
throttle((updateFn: () => void) => {
|
||||
|
|
@ -29,31 +54,34 @@ export function Artifact({ node, ...props }) {
|
|||
);
|
||||
|
||||
const updateArtifact = useCallback(() => {
|
||||
const content =
|
||||
props.children && typeof props.children === 'string'
|
||||
? props.children
|
||||
: props.children?.props?.children || '';
|
||||
const content = extractContent(props.children);
|
||||
console.log('Content:', content);
|
||||
|
||||
const title = props.title || 'Untitled Artifact';
|
||||
const type = props.type || 'unknown';
|
||||
const identifier = props.identifier || 'no-identifier';
|
||||
const title = props.title ?? 'Untitled Artifact';
|
||||
const type = props.type ?? 'unknown';
|
||||
const identifier = props.identifier ?? 'no-identifier';
|
||||
const artifactKey = `${identifier}_${type}_${title}`.replace(/\s+/g, '_').toLowerCase();
|
||||
|
||||
throttledUpdateRef.current(() => {
|
||||
const currentArtifact = {
|
||||
id: artifactKey,
|
||||
identifier,
|
||||
title,
|
||||
type,
|
||||
content,
|
||||
};
|
||||
|
||||
setArtifacts((prevArtifacts) => {
|
||||
if (prevArtifacts[artifactKey] && prevArtifacts[artifactKey].content === content) {
|
||||
if (
|
||||
(prevArtifacts as Record<string, Artifact | undefined>)[artifactKey] &&
|
||||
prevArtifacts[artifactKey].content === content
|
||||
) {
|
||||
return prevArtifacts;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevArtifacts,
|
||||
[artifactKey]: {
|
||||
id: artifactKey,
|
||||
identifier,
|
||||
title,
|
||||
type,
|
||||
content,
|
||||
},
|
||||
[artifactKey]: currentArtifact,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -63,19 +91,28 @@ export function Artifact({ node, ...props }) {
|
|||
}
|
||||
return prevIds;
|
||||
});
|
||||
|
||||
setArtifact(currentArtifact);
|
||||
});
|
||||
}, [props, setArtifacts, setArtifactIds]);
|
||||
|
||||
console.log('Artifact updated:', artifactKey);
|
||||
}, [props.children, props.title, props.type, props.identifier, setArtifacts, setArtifactIds]);
|
||||
|
||||
useEffect(() => {
|
||||
updateArtifact();
|
||||
}, [updateArtifact]);
|
||||
|
||||
return (
|
||||
<div className="artifact">
|
||||
<b>{props.title || 'Untitled Artifact'}</b>
|
||||
<p>Type: {props.type || 'unknown'}</p>
|
||||
<p>Identifier: {props.identifier || 'No identifier'}</p>
|
||||
{props.children}
|
||||
</div>
|
||||
<>
|
||||
<CodePreview artifact={artifact} />
|
||||
{/* {props.children} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// <div className="artifact">
|
||||
// <b>{props.title ?? 'Untitled Artifact'}</b>
|
||||
// <p>Type: {props.type ?? 'unknown'}</p>
|
||||
// <p>Identifier: {props.identifier ?? 'No identifier'}</p>
|
||||
// {props.children as React.ReactNode}
|
||||
// </div>
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ import React, { useMemo, useState } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Sandpack } from '@codesandbox/sandpack-react';
|
||||
import { removeNullishValues } from 'librechat-data-provider';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import { sharedOptions, sharedFiles, sharedProps } from '~/utils/artifacts';
|
||||
import { sharedOptions, sharedFiles, sharedProps, getArtifactFilename } from '~/utils/artifacts';
|
||||
import type { Artifact } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
export function CodeViewer({
|
||||
export function ArtifactPreview({
|
||||
showEditor = false,
|
||||
content,
|
||||
artifact,
|
||||
}: {
|
||||
showEditor?: boolean;
|
||||
content: string;
|
||||
artifact: Artifact;
|
||||
}) {
|
||||
const files = { '/App.js': content };
|
||||
const files = useMemo(() => {
|
||||
return removeNullishValues({ [getArtifactFilename(artifact.type ?? '')]: artifact.content });
|
||||
}, [artifact.type, artifact.content]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
|
|
@ -128,11 +132,11 @@ export default function Artifacts() {
|
|||
{/* Content */}
|
||||
<Tabs.Content value="code" className="flex-grow overflow-auto bg-surface-secondary">
|
||||
<pre className="h-full w-full overflow-auto rounded bg-surface-primary-alt p-2 text-sm">
|
||||
<code>{currentArtifact.content}</code>
|
||||
{currentArtifact.content}
|
||||
</pre>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto bg-surface-secondary">
|
||||
<CodeViewer content={currentArtifact.content} />
|
||||
<ArtifactPreview artifact={currentArtifact} />
|
||||
</Tabs.Content>
|
||||
|
||||
{/* Footer */}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef }) => {
|
|||
<span className="">{lang}</span>
|
||||
<button
|
||||
type="button"
|
||||
className='ml-auto flex gap-2'
|
||||
className="ml-auto flex gap-2"
|
||||
onClick={async () => {
|
||||
const codeString = codeRef.current?.textContent;
|
||||
if (codeString != null) {
|
||||
|
|
@ -64,20 +64,13 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef }) => {
|
|||
);
|
||||
});
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
lang,
|
||||
codeChildren,
|
||||
classProp = '',
|
||||
}) => {
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ lang, codeChildren, classProp = '' }) => {
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
return (
|
||||
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
|
||||
<CodeBar lang={lang} codeRef={codeRef} />
|
||||
<div className={cn(classProp, 'overflow-y-auto p-4')}>
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={`hljs language-${lang} !whitespace-pre`}
|
||||
>
|
||||
<code ref={codeRef} className={`hljs language-${lang} !whitespace-pre`}>
|
||||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
|
|
@ -85,22 +78,6 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const CodeBlockArtifact: React.FC<CodeBlockArtifactProps> = ({ lang, codeString: content }) => {
|
||||
const debouncedUpdateCodeBlock = useDebounceCodeBlock();
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateCodeBlock({
|
||||
id: `${lang}-${Date.now()}`,
|
||||
language: lang,
|
||||
content,
|
||||
});
|
||||
}, [lang, content, debouncedUpdateCodeBlock]);
|
||||
|
||||
return (
|
||||
<CodePreview code={content} />
|
||||
);
|
||||
};
|
||||
|
||||
type TCodeProps = {
|
||||
inline: boolean;
|
||||
className?: string;
|
||||
|
|
@ -123,33 +100,37 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
|
|||
});
|
||||
|
||||
const cursor = ' ';
|
||||
export const CodeMarkdown = memo(({ content = '', showCursor, isLatestMessage }: {
|
||||
content: string;
|
||||
showCursor?: boolean;
|
||||
isLatestMessage: boolean;
|
||||
}) => {
|
||||
export const CodeMarkdown = memo(
|
||||
({
|
||||
content = '',
|
||||
showCursor,
|
||||
isLatestMessage,
|
||||
}: {
|
||||
content: string;
|
||||
showCursor?: boolean;
|
||||
isLatestMessage: boolean;
|
||||
}) => {
|
||||
const currentContent = content;
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const currentContent = content;
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
// linkTarget="_new"
|
||||
components={{ code }
|
||||
}
|
||||
>
|
||||
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
// linkTarget="_new"
|
||||
components={{ code }}
|
||||
>
|
||||
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType } from '~/utils';
|
||||
|
||||
const CodePreview = ({
|
||||
code,
|
||||
}: {
|
||||
code: string,
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const CodePreview = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
const fileType = getFileType('text/x-');
|
||||
|
||||
return (
|
||||
|
|
@ -16,7 +15,7 @@ const CodePreview = ({
|
|||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden">
|
||||
<div className="truncate font-medium">{'Code Artifact'}</div>
|
||||
<div className="truncate font-medium">{artifact.title}</div>
|
||||
<div className="truncate text-text-secondary">{fileType.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import remarkDirective from 'remark-directive';
|
|||
import type { PluggableList, Pluggable } from 'unified';
|
||||
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
||||
import { CodeBlockArtifact, CodeMarkdown } from '~/components/Artifacts/Code';
|
||||
import { Artifact as artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
|
@ -173,7 +173,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
code,
|
||||
a,
|
||||
p,
|
||||
artifact,
|
||||
artifact: Artifact,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,12 +47,15 @@ export default function Presentation({
|
|||
const filesToDelete = localStorage.getItem(LocalStorageKeys.FILES_TO_DELETE);
|
||||
const map = JSON.parse(filesToDelete ?? '{}') as Record<string, ExtendedFile>;
|
||||
const files = Object.values(map)
|
||||
.filter((file) => file.filepath && file.source && !file.embedded && file.temp_file_id)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.filepath != null && file.source && !(file.embedded ?? false) && file.temp_file_id,
|
||||
)
|
||||
.map((file) => ({
|
||||
file_id: file.file_id,
|
||||
filepath: file.filepath as string,
|
||||
source: file.source as FileSources,
|
||||
embedded: !!file.embedded,
|
||||
embedded: !!(file.embedded ?? false),
|
||||
}));
|
||||
|
||||
if (files.length === 0) {
|
||||
|
|
@ -106,7 +109,7 @@ export default function Presentation({
|
|||
return (
|
||||
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
{layout()}
|
||||
{panel && panel}
|
||||
{panel != null && panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { atom } from 'recoil';
|
||||
import { logger } from '~/utils';
|
||||
export interface Artifact {
|
||||
identifier?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
export const artifactsState = atom<Record<string, Artifact>>({
|
||||
key: 'artifactsState',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import dedent from 'dedent';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
const artifactFilename = {
|
||||
'application/vnd.react': 'App.tsx',
|
||||
'text/html': 'index.html',
|
||||
// 'css': 'css',
|
||||
// 'javascript': 'js',
|
||||
// 'typescript': 'ts',
|
||||
// 'jsx': 'jsx',
|
||||
// 'tsx': 'tsx',
|
||||
};
|
||||
|
||||
export function getArtifactFilename(type: string): string {
|
||||
return artifactFilename[type] ?? 'App.tsx';
|
||||
}
|
||||
|
||||
export function getFileExtension(language: string): string {
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
|
|
@ -66,9 +80,7 @@ export const sharedProps = {
|
|||
} as const;
|
||||
|
||||
export const sharedOptions = {
|
||||
externalResources: [
|
||||
'https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css',
|
||||
],
|
||||
externalResources: ['https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css'],
|
||||
};
|
||||
|
||||
export const sharedFiles = {
|
||||
|
|
@ -89,18 +101,20 @@ export const sharedFiles = {
|
|||
};
|
||||
|
||||
export const filenameMap = {
|
||||
'tsx': 'App',
|
||||
'css': 'styles',
|
||||
'html': 'index',
|
||||
'jsx': 'App',
|
||||
'js': 'App',
|
||||
'ts': 'App',
|
||||
'typescript': 'App',
|
||||
'javascript': 'App',
|
||||
tsx: 'App',
|
||||
css: 'styles',
|
||||
html: 'index',
|
||||
jsx: 'App',
|
||||
js: 'App',
|
||||
ts: 'App',
|
||||
typescript: 'App',
|
||||
javascript: 'App',
|
||||
};
|
||||
|
||||
export const mapCodeFiles = (codeBlockIds:string[], codeBlocks: Record<string, CodeBlock | undefined>) => {
|
||||
|
||||
export const mapCodeFiles = (
|
||||
codeBlockIds: string[],
|
||||
codeBlocks: Record<string, CodeBlock | undefined>,
|
||||
) => {
|
||||
return codeBlockIds.reduce((acc, id) => {
|
||||
const block = codeBlocks[id];
|
||||
if (block) {
|
||||
|
|
@ -109,4 +123,4 @@ export const mapCodeFiles = (codeBlockIds:string[], codeBlocks: Record<string, C
|
|||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue