mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-07 08:40:19 +01:00
🏎️ refactor: Replace Sandpack Code Editor with Monaco for Artifact Editing (#12109)
* refactor: Code Editor and Auto Scroll Functionality - Added a useEffect hook in CodeEditor to sync streaming content with Sandpack without remounting the provider, improving performance and user experience. - Updated useAutoScroll to accept an optional editorRef, allowing for dynamic scroll container selection based on the editor's state. - Refactored ArtifactTabs to utilize the new editorRef in the useAutoScroll hook, ensuring consistent scrolling behavior during content updates. - Introduced stableFiles and mergedFiles logic in CodeEditor to optimize file handling and prevent unnecessary updates during streaming content changes. * refactor: Update CodeEditor to Sync Streaming Content Based on Read-Only State - Modified the useEffect hook in CodeEditor to conditionally sync streaming content with Sandpack only when in read-only mode, preventing unnecessary updates during user edits. - Enhanced the dependency array of the useEffect hook to include the readOnly state, ensuring accurate synchronization behavior. * refactor: Monaco Editor for Artifact Code Editing * refactor: Clean up ArtifactCodeEditor and ArtifactTabs components - Removed unused scrollbar styles from mobile.css to streamline the code. - Refactored ArtifactCodeEditor to improve content synchronization and read-only state handling. - Enhanced ArtifactTabs by removing unnecessary context usage and optimizing component structure for better readability. * feat: Add support for new artifact type 'application/vnd.ant.react' - Introduced handling for 'application/vnd.ant.react' in artifactFilename, artifactTemplate, and dependenciesMap. - Updated relevant mappings to ensure proper integration of the new artifact type within the application. * refactor:ArtifactCodeEditor with Monaco Editor Configuration - Added support for disabling validation in the Monaco Editor to improve the artifact viewer/editor experience. - Introduced a new type definition for Monaco to enhance type safety. - Updated the handling of the 'application/vnd.ant.react' artifact type to ensure proper integration with the editor. * refactor: Clean up ArtifactCodeEditor and mobile.css - Removed unnecessary whitespace in mobile.css for cleaner code. - Refactored ArtifactCodeEditor to streamline language mapping and type handling, enhancing readability and maintainability. - Consolidated language and type mappings into dedicated constants for improved clarity and efficiency. * feat: Integrate Monaco Editor for Enhanced Code Editing Experience - Added the Monaco Editor as a dependency to improve the code editing capabilities within the ArtifactCodeEditor component. - Refactored the handling of TypeScript and JavaScript defaults in the Monaco Editor configuration for better type safety and clarity. - Streamlined the setup for disabling validation, enhancing the artifact viewer/editor experience. * fix: Update ArtifactCodeEditor to handle null content checks - Modified conditional checks in ArtifactCodeEditor to use `art.content != null` instead of `art.content` for improved null safety. - Ensured consistent handling of artifact content across various useEffect hooks to prevent potential errors when content is null. * fix: Refine content comparison logic in ArtifactCodeEditor - Updated the condition for checking if the code is not original by removing the redundant null check for `art.content`, ensuring more concise and clear logic. - This change enhances the readability of the code and maintains the integrity of content comparison within the editor. * fix: Simplify code comparison logic in ArtifactCodeEditor - Removed redundant null check for the `code` variable, ensuring a more straightforward comparison with the current update reference. - This change improves code clarity and maintains the integrity of the content comparison logic within the editor.
This commit is contained in:
parent
a79f7cebd5
commit
771227ecf9
9 changed files with 385 additions and 349 deletions
|
|
@ -38,6 +38,7 @@
|
|||
"@librechat/client": "*",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mcp-ui/client": "^5.7.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
|
|
@ -146,6 +147,7 @@
|
|||
"jest": "^30.2.0",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"monaco-editor": "^0.55.0",
|
||||
"jest-file-loader": "^1.0.3",
|
||||
"jest-junit": "^16.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
|
|
|
|||
|
|
@ -1,206 +1,326 @@
|
|||
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { KeyBinding } from '@codemirror/view';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import {
|
||||
useSandpack,
|
||||
SandpackCodeEditor,
|
||||
SandpackProvider as StyledProvider,
|
||||
} from '@codesandbox/sandpack-react';
|
||||
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
import { useEditArtifact } from '~/data-provider';
|
||||
|
||||
const CodeEditor = memo(
|
||||
({
|
||||
fileKey,
|
||||
readOnly,
|
||||
artifact,
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating } = useMutationState();
|
||||
const { setCurrentCode } = useCodeState();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
javascript: 'javascript',
|
||||
typescript: 'typescript',
|
||||
python: 'python',
|
||||
css: 'css',
|
||||
json: 'json',
|
||||
markdown: 'markdown',
|
||||
html: 'html',
|
||||
xml: 'xml',
|
||||
sql: 'sql',
|
||||
yaml: 'yaml',
|
||||
shell: 'shell',
|
||||
bash: 'shell',
|
||||
tsx: 'typescript',
|
||||
jsx: 'javascript',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
java: 'java',
|
||||
go: 'go',
|
||||
rust: 'rust',
|
||||
kotlin: 'kotlin',
|
||||
swift: 'swift',
|
||||
php: 'php',
|
||||
ruby: 'ruby',
|
||||
r: 'r',
|
||||
lua: 'lua',
|
||||
scala: 'scala',
|
||||
perl: 'perl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create stable debounced mutation that doesn't depend on changing callbacks
|
||||
* Use refs to always access the latest values without recreating the debounce
|
||||
*/
|
||||
const artifactRef = useRef(artifact);
|
||||
const isMutatingRef = useRef(isMutating);
|
||||
const currentUpdateRef = useRef(currentUpdate);
|
||||
const editArtifactRef = useRef(editArtifact);
|
||||
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
'text/html': 'html',
|
||||
'application/vnd.code-html': 'html',
|
||||
'application/vnd.react': 'typescript',
|
||||
'application/vnd.ant.react': 'typescript',
|
||||
'text/markdown': 'markdown',
|
||||
'text/md': 'markdown',
|
||||
'text/plain': 'plaintext',
|
||||
'application/vnd.mermaid': 'markdown',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
artifactRef.current = artifact;
|
||||
}, [artifact]);
|
||||
function getMonacoLanguage(type?: string, language?: string): string {
|
||||
if (language && LANG_MAP[language]) {
|
||||
return LANG_MAP[language];
|
||||
}
|
||||
return TYPE_MAP[type ?? ''] ?? 'plaintext';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
isMutatingRef.current = isMutating;
|
||||
}, [isMutating]);
|
||||
|
||||
useEffect(() => {
|
||||
currentUpdateRef.current = currentUpdate;
|
||||
}, [currentUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
editArtifactRef.current = editArtifact;
|
||||
}, [editArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCodeRef.current = setCurrentCode;
|
||||
}, [setCurrentCode]);
|
||||
|
||||
/**
|
||||
* Create debounced mutation once - never recreate it
|
||||
* All values are accessed via refs so they're always current
|
||||
*/
|
||||
const debouncedMutation = useMemo(
|
||||
() =>
|
||||
debounce((code: string) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
if (isMutatingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (artifactRef.current.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = artifactRef.current;
|
||||
const artifactIndex = artifact.index;
|
||||
const isNotOriginal =
|
||||
code && artifact.content != null && code.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdateRef.current == null
|
||||
? true
|
||||
: code != null && code.trim() !== currentUpdateRef.current.trim();
|
||||
|
||||
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
|
||||
setCurrentCodeRef.current(code);
|
||||
editArtifactRef.current.mutate({
|
||||
index: artifactIndex,
|
||||
messageId: artifact.messageId ?? '',
|
||||
original: artifact.content,
|
||||
updated: code,
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen to Sandpack file changes and trigger debounced mutation
|
||||
*/
|
||||
useEffect(() => {
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
if (currentCode) {
|
||||
debouncedMutation(currentCode);
|
||||
}
|
||||
}, [sandpack.files, fileKey, debouncedMutation]);
|
||||
|
||||
/**
|
||||
* Cleanup: cancel pending mutations when component unmounts or artifact changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedMutation.cancel();
|
||||
};
|
||||
}, [artifact.id, debouncedMutation]);
|
||||
|
||||
return (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
extensions={[autocompletion()]}
|
||||
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ArtifactCodeEditor = function ({
|
||||
files,
|
||||
fileKey,
|
||||
template,
|
||||
export const ArtifactCodeEditor = function ArtifactCodeEditor({
|
||||
artifact,
|
||||
editorRef,
|
||||
sharedProps,
|
||||
monacoRef,
|
||||
readOnly: externalReadOnly,
|
||||
}: {
|
||||
fileKey: string;
|
||||
artifact: Artifact;
|
||||
files: ArtifactFiles;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const options: typeof sharedOptions = useMemo(() => {
|
||||
if (!config) {
|
||||
return sharedOptions;
|
||||
}
|
||||
return {
|
||||
...sharedOptions,
|
||||
activeFile: '/' + fileKey,
|
||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config, template, fileKey]);
|
||||
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
|
||||
const [readOnly, setReadOnly] = useState(initialReadOnly);
|
||||
useEffect(() => {
|
||||
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
|
||||
}, [isSubmitting, externalReadOnly]);
|
||||
const readOnly = (externalReadOnly ?? false) || isSubmitting;
|
||||
const { setCurrentCode } = useCodeState();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating } = useMutationState();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
const artifactRef = useRef(artifact);
|
||||
const isMutatingRef = useRef(isMutating);
|
||||
const currentUpdateRef = useRef(currentUpdate);
|
||||
const editArtifactRef = useRef(editArtifact);
|
||||
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||
const prevContentRef = useRef(artifact.content ?? '');
|
||||
const prevArtifactId = useRef(artifact.id);
|
||||
const prevReadOnly = useRef(readOnly);
|
||||
|
||||
artifactRef.current = artifact;
|
||||
isMutatingRef.current = isMutating;
|
||||
currentUpdateRef.current = currentUpdate;
|
||||
editArtifactRef.current = editArtifact;
|
||||
setCurrentCodeRef.current = setCurrentCode;
|
||||
|
||||
const debouncedMutation = useMemo(
|
||||
() =>
|
||||
debounce((code: string) => {
|
||||
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
|
||||
return;
|
||||
}
|
||||
const art = artifactRef.current;
|
||||
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
|
||||
|
||||
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
|
||||
setCurrentCodeRef.current(code);
|
||||
editArtifactRef.current.mutate({
|
||||
index: art.index,
|
||||
messageId: art.messageId ?? '',
|
||||
original: art.content,
|
||||
updated: code,
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => debouncedMutation.cancel();
|
||||
}, [artifact.id, debouncedMutation]);
|
||||
|
||||
/**
|
||||
* Streaming: use model.applyEdits() to append new content.
|
||||
* Unlike setValue/pushEditOperations, applyEdits preserves existing
|
||||
* tokens so syntax highlighting doesn't flash during updates.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const ed = monacoRef.current;
|
||||
if (!ed || !readOnly) {
|
||||
return;
|
||||
}
|
||||
const newContent = artifact.content ?? '';
|
||||
const prev = prevContentRef.current;
|
||||
|
||||
if (newContent === prev) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = ed.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newContent.startsWith(prev) && prev.length > 0) {
|
||||
const appended = newContent.slice(prev.length);
|
||||
const endPos = model.getPositionAt(model.getValueLength());
|
||||
model.applyEdits([
|
||||
{
|
||||
range: {
|
||||
startLineNumber: endPos.lineNumber,
|
||||
startColumn: endPos.column,
|
||||
endLineNumber: endPos.lineNumber,
|
||||
endColumn: endPos.column,
|
||||
},
|
||||
text: appended,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
model.setValue(newContent);
|
||||
}
|
||||
|
||||
prevContentRef.current = newContent;
|
||||
ed.revealLine(model.getLineCount());
|
||||
}, [artifact.content, readOnly, monacoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.id === prevArtifactId.current) {
|
||||
return;
|
||||
}
|
||||
prevArtifactId.current = artifact.id;
|
||||
prevContentRef.current = artifact.content ?? '';
|
||||
const ed = monacoRef.current;
|
||||
if (ed && artifact.content != null) {
|
||||
ed.getModel()?.setValue(artifact.content);
|
||||
}
|
||||
}, [artifact.id, artifact.content, monacoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevReadOnly.current && !readOnly && artifact.content != null) {
|
||||
const ed = monacoRef.current;
|
||||
if (ed) {
|
||||
ed.getModel()?.setValue(artifact.content);
|
||||
prevContentRef.current = artifact.content;
|
||||
}
|
||||
}
|
||||
prevReadOnly.current = readOnly;
|
||||
}, [readOnly, artifact.content, monacoRef]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
if (value === undefined || readOnly) {
|
||||
return;
|
||||
}
|
||||
prevContentRef.current = value;
|
||||
setCurrentCode(value);
|
||||
if (value.length > 0) {
|
||||
debouncedMutation(value);
|
||||
}
|
||||
},
|
||||
[readOnly, debouncedMutation, setCurrentCode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Disable all validation — this is an artifact viewer/editor, not an IDE.
|
||||
* Note: these are global Monaco settings that affect all editor instances on the page.
|
||||
* The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript`
|
||||
* as `{ deprecated: true }` while the runtime API is fully functional.
|
||||
*/
|
||||
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
|
||||
.typescript as unknown as {
|
||||
typescriptDefaults: {
|
||||
setDiagnosticsOptions: (o: {
|
||||
noSemanticValidation: boolean;
|
||||
noSyntaxValidation: boolean;
|
||||
}) => void;
|
||||
setCompilerOptions: (o: {
|
||||
allowNonTsExtensions: boolean;
|
||||
allowJs: boolean;
|
||||
jsx: number;
|
||||
}) => void;
|
||||
};
|
||||
javascriptDefaults: {
|
||||
setDiagnosticsOptions: (o: {
|
||||
noSemanticValidation: boolean;
|
||||
noSyntaxValidation: boolean;
|
||||
}) => void;
|
||||
setCompilerOptions: (o: {
|
||||
allowNonTsExtensions: boolean;
|
||||
allowJs: boolean;
|
||||
jsx: number;
|
||||
}) => void;
|
||||
};
|
||||
JsxEmit: { React: number };
|
||||
};
|
||||
const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true };
|
||||
const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React };
|
||||
typescriptDefaults.setDiagnosticsOptions(diagnosticsOff);
|
||||
javascriptDefaults.setDiagnosticsOptions(diagnosticsOff);
|
||||
typescriptDefaults.setCompilerOptions(compilerBase);
|
||||
javascriptDefaults.setCompilerOptions(compilerBase);
|
||||
}, []);
|
||||
|
||||
const handleMount = useCallback(
|
||||
(ed: editor.IStandaloneCodeEditor) => {
|
||||
monacoRef.current = ed;
|
||||
prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? '';
|
||||
if (readOnly) {
|
||||
const model = ed.getModel();
|
||||
if (model) {
|
||||
ed.revealLine(model.getLineCount());
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[monacoRef],
|
||||
);
|
||||
|
||||
const language = getMonacoLanguage(artifact.type, artifact.language);
|
||||
|
||||
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
|
||||
() => ({
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
padding: { top: 8 },
|
||||
renderLineHighlight: readOnly ? 'none' : 'line',
|
||||
cursorStyle: readOnly ? 'underline-thin' : 'line',
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'auto',
|
||||
verticalScrollbarSize: 8,
|
||||
horizontalScrollbarSize: 8,
|
||||
useShadows: false,
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerBorder: false,
|
||||
folding: false,
|
||||
glyphMargin: false,
|
||||
colorDecorators: !readOnly,
|
||||
occurrencesHighlight: readOnly ? 'off' : 'singleFile',
|
||||
selectionHighlight: !readOnly,
|
||||
renderValidationDecorations: readOnly ? 'off' : 'editable',
|
||||
quickSuggestions: !readOnly,
|
||||
suggestOnTriggerCharacters: !readOnly,
|
||||
parameterHints: { enabled: !readOnly },
|
||||
hover: { enabled: !readOnly },
|
||||
matchBrackets: readOnly ? 'never' : 'always',
|
||||
}),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
if (!artifact.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledProvider
|
||||
theme="dark"
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
|
||||
</StyledProvider>
|
||||
<div className="h-full w-full bg-[#1e1e1e]">
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme="vs-dark"
|
||||
defaultValue={artifact.content}
|
||||
onChange={handleChange}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleMount}
|
||||
options={editorOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,30 +1,26 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
artifact,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSharedConvo,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isSharedConvo?: boolean;
|
||||
}) {
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const { currentCode, setCurrentCode } = useCodeState();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -34,33 +30,24 @@ export default function ArtifactTabs({
|
|||
lastIdRef.current = artifact.id;
|
||||
}, [setCurrentCode, artifact.id]);
|
||||
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
|
||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Tabs.Content
|
||||
ref={contentRef}
|
||||
value="code"
|
||||
id="artifacts-code"
|
||||
className="h-full w-full flex-grow overflow-auto"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactCodeEditor
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
template={template}
|
||||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
readOnly={isSharedConvo}
|
||||
/>
|
||||
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
|
||||
<Tabs.Content
|
||||
value="preview"
|
||||
className="h-full w-full flex-grow overflow-hidden"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as Tabs from '@radix-ui/react-tabs';
|
|||
import { Code, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
|
||||
import { useShareContext, useMutationState } from '~/Providers';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
|
|
@ -22,7 +22,6 @@ export default function Artifacts() {
|
|||
const { isMutating } = useMutationState();
|
||||
const { isSharedConvo } = useShareContext();
|
||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||
const editorRef = useRef<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
|
@ -297,7 +296,6 @@ export default function Artifacts() {
|
|||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
isSharedConvo={isSharedConvo}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Button } from '@librechat/client';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||
import { handleDoubleClick, langSubset } from '~/utils';
|
||||
import { handleDoubleClick } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TCodeProps = {
|
||||
|
|
@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
|
|||
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
|
||||
});
|
||||
|
||||
export const CodeMarkdown = memo(
|
||||
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const currentContent = content;
|
||||
const rehypePlugins = [
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
if (!isNearBottom) {
|
||||
setUserScrolled(true);
|
||||
} else {
|
||||
setUserScrolled(false);
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer || !isSubmitting || userScrolled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}, [content, isSubmitting, userScrolled]);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="max-h-full overflow-y-auto">
|
||||
<ReactMarkdown
|
||||
/* @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={
|
||||
{ code } as {
|
||||
[key: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{currentContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
// hooks/useAutoScroll.ts
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface UseAutoScrollProps {
|
||||
ref: React.RefObject<HTMLElement>;
|
||||
content: string;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => {
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = ref.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
if (!isNearBottom) {
|
||||
setUserScrolled(true);
|
||||
} else {
|
||||
setUserScrolled(false);
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = ref.current;
|
||||
if (!scrollContainer || !isSubmitting || userScrolled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}, [content, isSubmitting, userScrolled, ref]);
|
||||
|
||||
return { userScrolled };
|
||||
};
|
||||
|
|
@ -349,26 +349,6 @@
|
|||
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
|
||||
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] {
|
||||
scrollbar-gutter: stable !important;
|
||||
background-color: rgba(205, 205, 205, 0.66) !important;
|
||||
}
|
||||
|
||||
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar {
|
||||
width: 12px !important;
|
||||
}
|
||||
|
||||
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(56, 56, 56) !important;
|
||||
border-radius: 6px !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: padding-box !important;
|
||||
}
|
||||
|
||||
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-track {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.cm-content:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
|
||||
const artifactFilename = {
|
||||
'application/vnd.react': 'App.tsx',
|
||||
'application/vnd.ant.react': 'App.tsx',
|
||||
'text/html': 'index.html',
|
||||
'application/vnd.code-html': 'index.html',
|
||||
// mermaid and markdown types are handled separately in useArtifactProps.ts
|
||||
|
|
@ -28,6 +29,7 @@ const artifactTemplate: Record<
|
|||
> = {
|
||||
'text/html': 'static',
|
||||
'application/vnd.react': 'react-ts',
|
||||
'application/vnd.ant.react': 'react-ts',
|
||||
'application/vnd.mermaid': 'react-ts',
|
||||
'application/vnd.code-html': 'static',
|
||||
'text/markdown': 'react-ts',
|
||||
|
|
@ -119,6 +121,7 @@ const dependenciesMap: Record<
|
|||
> = {
|
||||
'application/vnd.mermaid': mermaidDependencies,
|
||||
'application/vnd.react': standardDependencies,
|
||||
'application/vnd.ant.react': standardDependencies,
|
||||
'text/html': standardDependencies,
|
||||
'application/vnd.code-html': standardDependencies,
|
||||
'text/markdown': markdownDependencies,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue