mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-04 17:48:50 +01:00
WIP: artifacts first pass
This commit is contained in:
parent
0340b4acb9
commit
24d74044e4
16 changed files with 765 additions and 12 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
5
client/src/common/artifacts.ts
Normal file
5
client/src/common/artifacts.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface CodeBlock {
|
||||
id: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './artifacts';
|
||||
export * from './types';
|
||||
export * from './assistants-types';
|
||||
|
|
|
|||
94
client/src/components/Artifacts/Artifacts.tsx
Normal file
94
client/src/components/Artifacts/Artifacts.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
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 {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import { mapCodeFiles, sharedOptions, sharedFiles, sharedProps } from '~/utils/artifacts';
|
||||
import store from '~/store';
|
||||
|
||||
export function CodeViewer({ showEditor = false }: { showEditor?: boolean }) {
|
||||
const codeBlockIds = useRecoilValue(store.codeBlockIdsState);
|
||||
const codeBlocks = useRecoilValue(store.codeBlocksState);
|
||||
|
||||
const files = useMemo(() => mapCodeFiles(codeBlockIds, codeBlocks), [codeBlockIds, codeBlocks]);
|
||||
|
||||
console.log('CODE FILES & blocks', files, codeBlocks);
|
||||
if ((Object.keys(files)).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showEditor ? (
|
||||
<Sandpack
|
||||
options={{
|
||||
showNavigator: true,
|
||||
editorHeight: '80vh',
|
||||
showTabs: true,
|
||||
...sharedOptions,
|
||||
}}
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
{...sharedProps}
|
||||
/>
|
||||
) : (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
className="flex h-full w-full justify-center"
|
||||
options={{ ...sharedOptions }}
|
||||
{...sharedProps}
|
||||
>
|
||||
<SandpackPreview
|
||||
className="flex h-full w-full justify-center"
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Artifacts() {
|
||||
const [activeTab, setActiveTab] = useState('code');
|
||||
const codeBlockIds = useRecoilValue(store.codeBlockIdsState);
|
||||
const codeBlocks = useRecoilValue(store.codeBlocksState);
|
||||
|
||||
const files = useMemo(() => mapCodeFiles(codeBlockIds, codeBlocks), [codeBlockIds, codeBlocks]);
|
||||
const firstFileContent = Object.values(files)[0] || '';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col text-text-primary text-xl w-full rounded-xl py-2">
|
||||
<div className="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col h-full">
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
<Tabs.List className="flex bg-gray-700">
|
||||
<Tabs.Trigger
|
||||
value="code"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white focus:outline-none focus:text-white"
|
||||
>
|
||||
Code
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white focus:outline-none focus:text-white"
|
||||
>
|
||||
Preview
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="code" className="flex-grow overflow-auto">
|
||||
<pre className="h-full bg-gray-900 p-4 rounded text-sm overflow-auto">
|
||||
<code>{firstFileContent}</code>
|
||||
</pre>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" asChild>
|
||||
<CodeViewer />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
client/src/components/Artifacts/Code.tsx
Normal file
155
client/src/components/Artifacts/Code.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useRef, useState, RefObject, memo, useEffect } from 'react';
|
||||
|
||||
import copy from 'copy-to-clipboard';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { PluggableList } from 'unified';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { useDebounceCodeBlock } from './useDebounceCodeBlock';
|
||||
import { handleDoubleClick, cn, langSubset } from '~/utils';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import CodePreview from './CodePreview';
|
||||
|
||||
type CodeBarProps = {
|
||||
lang: string;
|
||||
codeRef: RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
interface CodeBlockArtifactProps {
|
||||
lang: string;
|
||||
codeString: string;
|
||||
artifactId: string;
|
||||
}
|
||||
type CodeBlockProps = Pick<CodeBarProps, 'lang'> & {
|
||||
codeChildren: React.ReactNode;
|
||||
classProp?: string;
|
||||
};
|
||||
|
||||
const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
return (
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
|
||||
<span className="">{lang}</span>
|
||||
<button
|
||||
type="button"
|
||||
className='ml-auto flex gap-2'
|
||||
onClick={async () => {
|
||||
const codeString = codeRef.current?.textContent;
|
||||
if (codeString != null) {
|
||||
setIsCopied(true);
|
||||
copy(codeString, { format: 'text/plain' });
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark className="h-[18px] w-[18px]" />
|
||||
{localize('com_ui_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
{localize('com_ui_copy_code')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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`}
|
||||
>
|
||||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />;
|
||||
});
|
||||
|
||||
const cursor = ' ';
|
||||
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,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
linkTarget="_new"
|
||||
components={{ code }
|
||||
}
|
||||
>
|
||||
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
29
client/src/components/Artifacts/CodePreview.tsx
Normal file
29
client/src/components/Artifacts/CodePreview.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType } from '~/utils';
|
||||
|
||||
const CodePreview = ({
|
||||
code,
|
||||
}: {
|
||||
code: string,
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const fileType = getFileType('text/x-');
|
||||
|
||||
return (
|
||||
<div className="group relative inline-block text-sm text-text-primary">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border-medium">
|
||||
<div className="w-60 bg-surface-active p-2">
|
||||
<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 text-text-secondary">{fileType.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodePreview;
|
||||
230
client/src/components/Artifacts/example.tsx
Normal file
230
client/src/components/Artifacts/example.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import dedent from 'dedent';
|
||||
import { Sandpack } from '@codesandbox/sandpack-react';
|
||||
import {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
// import './code-viewer.css';
|
||||
|
||||
const App = `import React, { useState } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
function App() {
|
||||
const [result, setResult] = useState('');
|
||||
|
||||
const handleClick = (e) => {
|
||||
setResult(result.concat(e.target.name));
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setResult('');
|
||||
}
|
||||
|
||||
const backspace = () => {
|
||||
setResult(result.slice(0, -1));
|
||||
}
|
||||
|
||||
const calculate = () => {
|
||||
try {
|
||||
setResult(eval(result).toString());
|
||||
} catch(err) {
|
||||
setResult('Error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calculator">
|
||||
<input type="text" value={result} />
|
||||
<div className="keypad">
|
||||
<button className="highlight" onClick={clear} id="clear">Clear</button>
|
||||
<button className="highlight" onClick={backspace} id="backspace">C</button>
|
||||
<button className="highlight" name="/" onClick={handleClick}>÷</button>
|
||||
<button name="7" onClick={handleClick}>7</button>
|
||||
<button name="8" onClick={handleClick}>8</button>
|
||||
<button name="9" onClick={handleClick}>9</button>
|
||||
<button className="highlight" name="*" onClick={handleClick}>×</button>
|
||||
<button name="4" onClick={handleClick}>4</button>
|
||||
<button name="5" onClick={handleClick}>5</button>
|
||||
<button name="6" onClick={handleClick}>6</button>
|
||||
<button className="highlight" name="-" onClick={handleClick}>–</button>
|
||||
<button name="1" onClick={handleClick}>1</button>
|
||||
<button name="2" onClick={handleClick}>2</button>
|
||||
<button name="3" onClick={handleClick}>3</button>
|
||||
<button className="highlight" name="+" onClick={handleClick}>+</button>
|
||||
<button name="0" onClick={handleClick}>0</button>
|
||||
<button name="." onClick={handleClick}>.</button>
|
||||
<button className="highlight" onClick={calculate} id="result">=</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;`;
|
||||
|
||||
const styles = `
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.calculator {
|
||||
width: 320px;
|
||||
margin: 100px auto;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
font-size: 20px;
|
||||
text-align: right;
|
||||
padding: 0 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #ff8c00;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.highlight:hover {
|
||||
background-color: #e67e00;
|
||||
}
|
||||
`;
|
||||
|
||||
export function DevCodeViewer({
|
||||
code,
|
||||
showEditor = false,
|
||||
}: {
|
||||
code?: string;
|
||||
showEditor?: boolean;
|
||||
}) {
|
||||
return showEditor ? (
|
||||
<Sandpack
|
||||
options={{
|
||||
showNavigator: true,
|
||||
editorHeight: '80vh',
|
||||
showTabs: true,
|
||||
...sharedOptions,
|
||||
}}
|
||||
files={{
|
||||
// 'App.tsx': code,
|
||||
'App.tsx': App,
|
||||
...sharedFiles,
|
||||
'styles.css': styles,
|
||||
}}
|
||||
{...sharedProps}
|
||||
/>
|
||||
) : (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
// 'App.tsx': code,
|
||||
'App.tsx': App,
|
||||
...sharedFiles,
|
||||
'styles.css': styles,
|
||||
}}
|
||||
className="flex h-full w-full grow flex-col justify-center"
|
||||
options={{ ...sharedOptions }}
|
||||
{...sharedProps}
|
||||
>
|
||||
<SandpackPreview
|
||||
className="flex h-full w-full grow flex-col justify-center p-4 md:pt-16"
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedProps = {
|
||||
template: 'react-ts',
|
||||
// theme: draculaTheme,
|
||||
customSetup: {
|
||||
dependencies: {
|
||||
'lucide-react': '^0.394.0',
|
||||
'react-router-dom': '^6.11.2',
|
||||
'class-variance-authority': '^0.6.0',
|
||||
clsx: '^1.2.1',
|
||||
'date-fns': '^3.3.1',
|
||||
'tailwind-merge': '^1.9.1',
|
||||
'tailwindcss-animate': '^1.0.5',
|
||||
// recharts: '2.9.0',
|
||||
// '@radix-ui/react-accordion': '^1.2.0',
|
||||
// '@radix-ui/react-alert-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-aspect-ratio': '^1.1.0',
|
||||
// '@radix-ui/react-avatar': '^1.1.0',
|
||||
// '@radix-ui/react-checkbox': '^1.1.1',
|
||||
// '@radix-ui/react-collapsible': '^1.1.0',
|
||||
// '@radix-ui/react-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-dropdown-menu': '^2.1.1',
|
||||
// '@radix-ui/react-hover-card': '^1.1.1',
|
||||
// '@radix-ui/react-label': '^2.1.0',
|
||||
// '@radix-ui/react-menubar': '^1.1.1',
|
||||
// '@radix-ui/react-navigation-menu': '^1.2.0',
|
||||
// '@radix-ui/react-popover': '^1.1.1',
|
||||
// '@radix-ui/react-progress': '^1.1.0',
|
||||
// '@radix-ui/react-radio-group': '^1.2.0',
|
||||
// '@radix-ui/react-select': '^2.1.1',
|
||||
// '@radix-ui/react-separator': '^1.1.0',
|
||||
// '@radix-ui/react-slider': '^1.2.0',
|
||||
// '@radix-ui/react-slot': '^1.1.0',
|
||||
// '@radix-ui/react-switch': '^1.1.0',
|
||||
// '@radix-ui/react-tabs': '^1.1.0',
|
||||
// '@radix-ui/react-toast': '^1.2.1',
|
||||
// '@radix-ui/react-toggle': '^1.1.0',
|
||||
// '@radix-ui/react-toggle-group': '^1.1.0',
|
||||
// '@radix-ui/react-tooltip': '^1.1.2',
|
||||
|
||||
// 'embla-carousel-react': '^8.1.8',
|
||||
// 'react-day-picker': '^8.10.1',
|
||||
// vaul: '^0.9.1',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const sharedOptions = {
|
||||
externalResources: [
|
||||
'https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css',
|
||||
],
|
||||
};
|
||||
|
||||
const sharedFiles = {
|
||||
'/public/index.html': dedent`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal file
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// client/src/hooks/useDebounceCodeBlock.ts
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
export function useDebounceCodeBlock() {
|
||||
const setCodeBlocks = useSetRecoilState(codeBlocksState);
|
||||
const setCodeBlockIds = useSetRecoilState(codeBlockIdsState);
|
||||
|
||||
const updateCodeBlock = useCallback((codeBlock: CodeBlock) => {
|
||||
console.log('Updating code block:', codeBlock);
|
||||
setCodeBlocks((prev) => ({
|
||||
...prev,
|
||||
[codeBlock.id]: codeBlock,
|
||||
}));
|
||||
setCodeBlockIds((prev) =>
|
||||
prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id],
|
||||
);
|
||||
}, [setCodeBlocks, setCodeBlockIds]);
|
||||
|
||||
const debouncedUpdateCodeBlock = useCallback(
|
||||
debounce((codeBlock: CodeBlock) => {
|
||||
updateCodeBlock(codeBlock);
|
||||
}, 25),
|
||||
[updateCodeBlock],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedUpdateCodeBlock.cancel();
|
||||
};
|
||||
}, [debouncedUpdateCodeBlock]);
|
||||
|
||||
return debouncedUpdateCodeBlock;
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, RefObject, useMemo, useRef } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import supersub from 'remark-supersub';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { PluggableList } from 'unified';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList, Pluggable } from 'unified';
|
||||
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
||||
import { CodeBlockArtifact, CodeMarkdown } from '~/components/Artifacts/Code';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import { filenameMap } from '~/utils/artifacts';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
|
@ -18,9 +21,14 @@ type TCodeProps = {
|
|||
inline: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
isLatestMessage: boolean;
|
||||
showCursor?: boolean;
|
||||
artifactId: string;
|
||||
codeBlocksRef: RefObject<number | null>;
|
||||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
|
||||
export const code: React.ElementType = memo(({ inline, className, children, ...props }: TCodeProps) => {
|
||||
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
|
||||
|
|
@ -30,12 +38,22 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
|
|||
{children}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />;
|
||||
}
|
||||
|
||||
const codeString = Array.isArray(children) ? children.join('') : children;
|
||||
console.log('code lang, children, props', lang, children, props);
|
||||
const isNonArtifact = filenameMap[lang ?? ''] === undefined;
|
||||
|
||||
if (codeArtifacts && typeof codeString === 'string' && isNonArtifact) {
|
||||
return <CodeMarkdown content={`\`\`\`${lang}\n${codeString}\n\`\`\``} {...props}/>;
|
||||
} else if (codeArtifacts && typeof codeString === 'string') {
|
||||
return <CodeBlockArtifact lang={lang ?? 'text'} codeString={codeString} {...props}/>;
|
||||
}
|
||||
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />;
|
||||
});
|
||||
|
||||
export const a = memo(({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
export const a: React.ElementType = memo(({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
const user = useRecoilValue(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
|
@ -101,7 +119,7 @@ export const a = memo(({ href, children }: { href: string; children: React.React
|
|||
);
|
||||
});
|
||||
|
||||
export const p = memo(({ children }: { children: React.ReactNode }) => {
|
||||
export const p: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
|
|
@ -114,7 +132,10 @@ type TContentProps = {
|
|||
};
|
||||
|
||||
const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => {
|
||||
const artifactIdRef = useRef<string | null>(null);
|
||||
const codeBlocksRef = useRef<number | null>(null);
|
||||
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
|
||||
const codeArtifacts = useRecoilValue<boolean>(store.codeArtifacts);
|
||||
|
||||
const isInitializing = content === '';
|
||||
|
||||
|
|
@ -124,7 +145,25 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
currentContent = LaTeXParsing ? preprocessLaTeX(currentContent) : currentContent;
|
||||
}
|
||||
|
||||
const rehypePlugins: PluggableList = [
|
||||
if (artifactIdRef.current === null) {
|
||||
artifactIdRef.current = new Date().toISOString();
|
||||
}
|
||||
|
||||
const codePlugin: Pluggable = () => {
|
||||
return (tree) => {
|
||||
visit(tree, { tagName: 'code' }, (node) => {
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
isLatestMessage,
|
||||
showCursor,
|
||||
artifactId: artifactIdRef.current,
|
||||
codeBlocksRef: codeBlocksRef.current,
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const rehypePlugins: PluggableList = codeArtifacts ? [[rehypeKatex, { output: 'mathml' }], [codePlugin], [rehypeRaw]] : [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
|
|
@ -156,8 +195,6 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
code,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { ExtendedFile } from '~/common';
|
|||
import { useDragHelpers, useSetFilesToDelete } from '~/hooks';
|
||||
import DragDropOverlay from './Input/Files/DragDropOverlay';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import Artifacts from '~/components/Artifacts/Artifacts';
|
||||
import { SidePanel } from '~/components/SidePanel';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -21,7 +22,9 @@ export default function Presentation({
|
|||
useSidePanel?: boolean;
|
||||
}) {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||
const codeBlockIds = useRecoilValue(store.codeBlockIdsState);
|
||||
const interfaceConfig = useMemo(
|
||||
() => startupConfig?.interface ?? defaultInterface,
|
||||
[startupConfig],
|
||||
|
|
@ -89,6 +92,7 @@ export default function Presentation({
|
|||
defaultLayout={defaultLayout}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
artifacts={codeArtifacts && codeBlockIds.length > 0 ? <Artifacts /> : null}
|
||||
>
|
||||
<main className="flex h-full flex-col" role="main">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
|
||||
return (
|
||||
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
|
||||
<CodeBar lang={lang} codeRef={codeRef} plugin={plugin === true} error={error} />
|
||||
<CodeBar lang={lang} codeRef={codeRef} plugin={(plugin === true ?? false)} error={error} />
|
||||
<div className={cn(classProp, 'overflow-y-auto p-4')}>
|
||||
<code
|
||||
ref={codeRef}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface SidePanelProps {
|
|||
defaultCollapsed?: boolean;
|
||||
navCollapsedSize?: number;
|
||||
fullPanelCollapse?: boolean;
|
||||
artifacts?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ const SidePanel = ({
|
|||
defaultCollapsed = false,
|
||||
fullPanelCollapse = false,
|
||||
navCollapsedSize = 3,
|
||||
artifacts,
|
||||
children,
|
||||
}: SidePanelProps) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -133,6 +135,8 @@ const SidePanel = ({
|
|||
}
|
||||
}, [isCollapsed, newUser, setNewUser, navCollapsedSize]);
|
||||
|
||||
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
|
@ -141,9 +145,17 @@ const SidePanel = ({
|
|||
onLayout={(sizes: number[]) => throttledSaveLayout(sizes)}
|
||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-white dark:bg-gray-800"
|
||||
>
|
||||
<ResizablePanel defaultSize={defaultLayout[0]} minSize={30}>
|
||||
<ResizablePanel defaultSize={defaultLayout[0]} minSize={minSizeMain}>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
{artifacts != null && (
|
||||
<>
|
||||
<ResizableHandleAlt withHandle className="bg-border-light dark:text-white mx-4" />
|
||||
<ResizablePanel defaultSize={defaultLayout[0]} minSize={minSizeMain}>
|
||||
{artifacts}
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -312,3 +312,11 @@
|
|||
.chrome-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent; /* Color of the tracking area */
|
||||
}
|
||||
|
||||
.sp-preview-container {
|
||||
@apply flex h-full w-full grow flex-col justify-center;
|
||||
}
|
||||
|
||||
.sp-preview-iframe {
|
||||
@apply grow;
|
||||
}
|
||||
|
|
|
|||
27
client/src/store/artifacts.ts
Normal file
27
client/src/store/artifacts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// client/src/store/artifacts.ts
|
||||
import { atom } from 'recoil';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
export const codeBlocksState = atom<Record<string, CodeBlock>>({
|
||||
key: 'codeBlocksState',
|
||||
default: {},
|
||||
effects: [
|
||||
({ onSet, node }) => {
|
||||
onSet(async (newValue) => {
|
||||
console.log('Recoil Effect: Setting codeBlocksState', { key: node.key, newValue });
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
export const codeBlockIdsState = atom<string[]>({
|
||||
key: 'codeBlockIdsState',
|
||||
default: [],
|
||||
effects: [
|
||||
({ onSet, node }) => {
|
||||
onSet(async (newValue) => {
|
||||
console.log('Recoil Effect: Setting codeBlockIdsState', { key: node.key, newValue });
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import * as artifacts from './artifacts';
|
||||
import conversation from './conversation';
|
||||
import conversations from './conversations';
|
||||
import families from './families';
|
||||
|
|
@ -13,6 +14,7 @@ import lang from './language';
|
|||
import settings from './settings';
|
||||
|
||||
export default {
|
||||
...artifacts,
|
||||
...families,
|
||||
...conversation,
|
||||
...conversations,
|
||||
|
|
|
|||
112
client/src/utils/artifacts.ts
Normal file
112
client/src/utils/artifacts.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import dedent from 'dedent';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
export function getFileExtension(language: string): string {
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
return 'jsx';
|
||||
case 'typescript':
|
||||
return 'tsx';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'html':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
default:
|
||||
return 'txt';
|
||||
}
|
||||
}
|
||||
|
||||
export const sharedProps = {
|
||||
template: 'react-ts',
|
||||
customSetup: {
|
||||
dependencies: {
|
||||
'lucide-react': '^0.394.0',
|
||||
'react-router-dom': '^6.11.2',
|
||||
'class-variance-authority': '^0.6.0',
|
||||
clsx: '^1.2.1',
|
||||
'date-fns': '^3.3.1',
|
||||
'tailwind-merge': '^1.9.1',
|
||||
'tailwindcss-animate': '^1.0.5',
|
||||
// recharts: '2.9.0',
|
||||
// '@radix-ui/react-accordion': '^1.2.0',
|
||||
// '@radix-ui/react-alert-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-aspect-ratio': '^1.1.0',
|
||||
// '@radix-ui/react-avatar': '^1.1.0',
|
||||
// '@radix-ui/react-checkbox': '^1.1.1',
|
||||
// '@radix-ui/react-collapsible': '^1.1.0',
|
||||
// '@radix-ui/react-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-dropdown-menu': '^2.1.1',
|
||||
// '@radix-ui/react-hover-card': '^1.1.1',
|
||||
// '@radix-ui/react-label': '^2.1.0',
|
||||
// '@radix-ui/react-menubar': '^1.1.1',
|
||||
// '@radix-ui/react-navigation-menu': '^1.2.0',
|
||||
// '@radix-ui/react-popover': '^1.1.1',
|
||||
// '@radix-ui/react-progress': '^1.1.0',
|
||||
// '@radix-ui/react-radio-group': '^1.2.0',
|
||||
// '@radix-ui/react-select': '^2.1.1',
|
||||
// '@radix-ui/react-separator': '^1.1.0',
|
||||
// '@radix-ui/react-slider': '^1.2.0',
|
||||
// '@radix-ui/react-slot': '^1.1.0',
|
||||
// '@radix-ui/react-switch': '^1.1.0',
|
||||
// '@radix-ui/react-tabs': '^1.1.0',
|
||||
// '@radix-ui/react-toast': '^1.2.1',
|
||||
// '@radix-ui/react-toggle': '^1.1.0',
|
||||
// '@radix-ui/react-toggle-group': '^1.1.0',
|
||||
// '@radix-ui/react-tooltip': '^1.1.2',
|
||||
|
||||
// 'embla-carousel-react': '^8.1.8',
|
||||
// 'react-day-picker': '^8.10.1',
|
||||
// vaul: '^0.9.1',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const sharedOptions = {
|
||||
externalResources: [
|
||||
'https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css',
|
||||
],
|
||||
};
|
||||
|
||||
export const sharedFiles = {
|
||||
'/public/index.html': dedent`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
|
||||
export const filenameMap = {
|
||||
'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>) => {
|
||||
|
||||
return codeBlockIds.reduce((acc, id) => {
|
||||
const block = codeBlocks[id];
|
||||
if (block) {
|
||||
const fileName = `${filenameMap[block.language]}.${getFileExtension(block.language)}`;
|
||||
acc[fileName] = typeof block.content === 'string' ? block.content : '';
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue