🚀 feat: Artifact Editing & Downloads (#5428)

* refactor: expand container

* chore: bump @codesandbox/sandpack-react to latest

* WIP: first pass, show editor

* feat: implement ArtifactCodeEditor and ArtifactTabs components for enhanced artifact management

* refactor: fileKey

* refactor: auto scrolling code editor and add messageId to artifact

* feat: first pass, editing artifact

* feat: first pass, robust artifact replacement

* fix: robust artifact replacement & re-render when expected

* feat: Download Artifacts

* refactor: improve artifact editing UX

* fix: layout shift of new download button

* fix: enhance missing output checks and logging in StreamRunManager
This commit is contained in:
Danny Avila 2025-01-23 18:19:04 -05:00 committed by GitHub
parent 87383fec27
commit ed57bb4711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1156 additions and 237 deletions

View file

@ -0,0 +1,32 @@
import { createContext, useContext, ReactNode, useCallback, useRef } from 'react';
type TArtifactContext = {
getNextIndex: (skip: boolean) => number;
resetCounter: () => void;
};
export const ArtifactContext = createContext<TArtifactContext>({} as TArtifactContext);
export const useArtifactContext = () => useContext(ArtifactContext);
export function ArtifactProvider({ children }: { children: ReactNode }) {
const counterRef = useRef(0);
const getNextIndex = useCallback((skip: boolean) => {
if (skip) {
return counterRef.current;
}
const nextIndex = counterRef.current;
counterRef.current += 1;
return nextIndex;
}, []);
const resetCounter = useCallback(() => {
counterRef.current = 0;
}, []);
return (
<ArtifactContext.Provider value={{ getNextIndex, resetCounter }}>
{children}
</ArtifactContext.Provider>
);
}

View file

@ -3,7 +3,6 @@ import { createContext, useContext, ReactNode, useCallback, useRef } from 'react
type TCodeBlockContext = {
getNextIndex: (skip: boolean) => number;
resetCounter: () => void;
// codeBlocks: Map<number, string>;
};
export const CodeBlockContext = createContext<TCodeBlockContext>({} as TCodeBlockContext);
@ -11,7 +10,6 @@ export const useCodeBlockContext = () => useContext(CodeBlockContext);
export function CodeBlockProvider({ children }: { children: ReactNode }) {
const counterRef = useRef(0);
// const codeBlocks = useRef(new Map<number, string>()).current;
const getNextIndex = useCallback((skip: boolean) => {
if (skip) {

View file

@ -0,0 +1,29 @@
import React, { createContext, useContext, useState } from 'react';
interface EditorContextType {
isMutating: boolean;
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
currentCode?: string;
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const EditorContext = createContext<EditorContextType | undefined>(undefined);
export function EditorProvider({ children }: { children: React.ReactNode }) {
const [isMutating, setIsMutating] = useState(false);
const [currentCode, setCurrentCode] = useState<string | undefined>();
return (
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
{children}
</EditorContext.Provider>
);
}
export function useEditorContext() {
const context = useContext(EditorContext);
if (context === undefined) {
throw new Error('useEditorContext must be used within an EditorProvider');
}
return context;
}

View file

@ -7,6 +7,7 @@ export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';
export * from './AddedChatContext';
export * from './EditorContext';
export * from './ChatFormContext';
export * from './BookmarkContext';
export * from './MessageContext';
@ -16,6 +17,7 @@ export * from './AgentsContext';
export * from './AssistantsMapContext';
export * from './AnnouncerContext';
export * from './AgentsMapContext';
export * from './ArtifactContext';
export * from './CodeBlockContext';
export * from './ToolCallsMapContext';
export * from './SetConvoContext';

View file

@ -7,9 +7,21 @@ export interface CodeBlock {
export interface Artifact {
id: string;
lastUpdateTime: number;
index?: number;
messageId?: string;
identifier?: string;
language?: string;
content?: string;
title?: string;
type?: string;
}
export type ArtifactFiles =
| {
'App.tsx': string;
'index.tsx': string;
'/components/ui/MermaidDiagram.tsx': string;
}
| Partial<{
[x: string]: string | undefined;
}>;

View file

@ -4,6 +4,7 @@ import { visit } from 'unist-util-visit';
import { useSetRecoilState } from 'recoil';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
import { artifactsState } from '~/store/artifacts';
import ArtifactButton from './ArtifactButton';
import { logger } from '~/utils';
@ -44,6 +45,10 @@ export function Artifact({
children: React.ReactNode | { props: { children: React.ReactNode } };
node: unknown;
}) {
const { messageId } = useMessageContext();
const { getNextIndex, resetCounter } = useArtifactContext();
const artifactIndex = useRef(getNextIndex(false)).current;
const setArtifacts = useSetRecoilState(artifactsState);
const [artifact, setArtifact] = useState<Artifact | null>(null);
@ -64,7 +69,9 @@ export function Artifact({
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();
const artifactKey = `${identifier}_${type}_${title}_${messageId}`
.replace(/\s+/g, '_')
.toLowerCase();
throttledUpdateRef.current(() => {
const now = Date.now();
@ -75,6 +82,8 @@ export function Artifact({
title,
type,
content,
messageId,
index: artifactIndex,
lastUpdateTime: now,
};
@ -94,11 +103,20 @@ export function Artifact({
setArtifact(currentArtifact);
});
}, [props.type, props.title, setArtifacts, props.children, props.identifier]);
}, [
props.type,
props.title,
setArtifacts,
props.children,
props.identifier,
messageId,
artifactIndex,
]);
useEffect(() => {
resetCounter();
updateArtifact();
}, [updateArtifact]);
}, [updateArtifact, resetCounter]);
return <ArtifactButton artifact={artifact} />;
}

View file

@ -0,0 +1,151 @@
import debounce from 'lodash/debounce';
import React, { memo, useEffect, useMemo, useCallback } from 'react';
import {
useSandpack,
SandpackCodeEditor,
SandpackProvider as StyledProvider,
} from '@codesandbox/sandpack-react';
import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditArtifact } from '~/data-provider';
import { useEditorContext } from '~/Providers';
const createDebouncedMutation = (
callback: (params: {
index: number;
messageId: string;
original: string;
updated: string;
}) => void,
) => debounce(callback, 500);
const CodeEditor = ({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: () => {
setIsMutating(true);
},
onSuccess: () => {
setIsMutating(false);
},
onError: () => {
setIsMutating(false);
setCurrentCode(artifact.content);
},
});
const mutationCallback = useCallback(
(params: { index: number; messageId: string; original: string; updated: string }) => {
editArtifact.mutate(params);
},
[editArtifact],
);
const debouncedMutation = useMemo(
() => createDebouncedMutation(mutationCallback),
[mutationCallback],
);
useEffect(() => {
if (readOnly) {
return;
}
if (isMutating) {
return;
}
const currentCode = sandpack.files['/' + fileKey].code;
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: currentCode,
});
}
return () => {
debouncedMutation.cancel();
};
}, [
fileKey,
artifact.index,
artifact.content,
artifact.messageId,
readOnly,
isMutating,
sandpack.files,
setIsMutating,
setCurrentCode,
debouncedMutation,
]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
readOnly={readOnly}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
className="hljs language-javascript bg-black"
/>
);
};
export const ArtifactCodeEditor = memo(function ({
files,
fileKey,
template,
artifact,
editorRef,
sharedProps,
isSubmitting,
}: {
fileKey: string;
artifact: Artifact;
files: ArtifactFiles;
isSubmitting: boolean;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) {
if (Object.keys(files).length === 0) {
return null;
}
return (
<StyledProvider
theme="dark"
files={{
...files,
...sharedFiles,
}}
options={{ ...sharedOptions }}
{...sharedProps}
template={template}
>
<CodeEditor
editorRef={editorRef}
fileKey={fileKey}
readOnly={isSubmitting}
artifact={artifact}
/>
</StyledProvider>
);
});

View file

@ -1,67 +1,51 @@
import React, { useMemo, memo } from 'react';
import { Sandpack } from '@codesandbox/sandpack-react';
import { removeNullishValues } from 'librechat-data-provider';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { Artifact } from '~/common';
import React, { memo, useMemo } from 'react';
import {
getKey,
getProps,
sharedFiles,
getTemplate,
sharedOptions,
getArtifactFilename,
} from '~/utils/artifacts';
import { getMermaidFiles } from '~/utils/mermaid';
SandpackPreview,
SandpackProvider,
SandpackProviderProps,
} from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditorContext } from '~/Providers';
export const ArtifactPreview = memo(function ({
showEditor = false,
artifact,
files,
fileKey,
previewRef,
sharedProps,
template,
}: {
showEditor?: boolean;
artifact: Artifact;
files: ArtifactFiles;
fileKey: string;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const files = useMemo(() => {
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
return getMermaidFiles(artifact.content ?? '');
const { currentCode } = useEditorContext();
const artifactFiles = useMemo(() => {
if (Object.keys(files).length === 0) {
return files;
}
return removeNullishValues({
[getArtifactFilename(artifact.type ?? '', artifact.language)]: artifact.content,
});
}, [artifact.type, artifact.content, artifact.language]);
const template = useMemo(
() => getTemplate(artifact.type ?? '', artifact.language),
[artifact.type, artifact.language],
);
const sharedProps = useMemo(() => getProps(artifact.type ?? ''), [artifact.type]);
if (Object.keys(files).length === 0) {
const code = currentCode ?? '';
if (!code) {
return files;
}
return {
...files,
[fileKey]: {
code,
},
};
}, [currentCode, files, fileKey]);
if (Object.keys(artifactFiles).length === 0) {
return null;
}
return showEditor ? (
<Sandpack
options={{
showNavigator: true,
editorHeight: '80vh',
showTabs: true,
...sharedOptions,
}}
files={{
...files,
...sharedFiles,
}}
{...sharedProps}
template={template}
/>
) : (
return (
<SandpackProvider
files={{
...files,
...artifactFiles,
...sharedFiles,
}}
options={{ ...sharedOptions }}

View file

@ -0,0 +1,60 @@
import { useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { ArtifactPreview } from './ArtifactPreview';
import { cn } from '~/utils';
export default function ArtifactTabs({
artifact,
isMermaid,
editorRef,
previewRef,
isSubmitting,
}: {
artifact: Artifact;
isMermaid: boolean;
isSubmitting: boolean;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<>
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className={cn('flex-grow overflow-auto')}
>
<ArtifactCodeEditor
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
isSubmitting={isSubmitting}
/>
</Tabs.Content>
<Tabs.Content
value="preview"
className={cn('flex-grow overflow-auto', isMermaid ? 'bg-[#282C34]' : 'bg-white')}
>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
/>
</Tabs.Content>
</>
);
}

View file

@ -2,18 +2,22 @@ import { useRef, useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import { CodeMarkdown, CopyCodeButton } from './Code';
import { getFileExtension } from '~/utils/artifacts';
import { ArtifactPreview } from './ArtifactPreview';
import { cn } from '~/utils';
import DownloadArtifact from './DownloadArtifact';
import { useEditorContext } from '~/Providers';
import useLocalize from '~/hooks/useLocalize';
import ArtifactTabs from './ArtifactTabs';
import { CopyCodeButton } from './Code';
import store from '~/store';
export default function Artifacts() {
const localize = useLocalize();
const { isMutating } = useEditorContext();
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
useEffect(() => {
@ -23,9 +27,9 @@ export default function Artifacts() {
const {
activeTab,
isMermaid,
isSubmitting,
setActiveTab,
currentIndex,
isSubmitting,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
@ -47,10 +51,10 @@ export default function Artifacts() {
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center py-2">
<div className="flex h-full w-full items-center justify-center">
{/* Main Container */}
<div
className={`flex h-[97%] w-[97%] flex-col overflow-hidden rounded-xl border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
isVisible
? 'translate-x-0 scale-100 opacity-100'
: 'translate-x-full scale-95 opacity-0'
@ -95,18 +99,23 @@ export default function Artifacts() {
/>
</button>
)}
{activeTab !== 'preview' && isMutating && (
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
)}
{/* Tabs */}
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Preview
{localize('com_ui_preview')}
</Tabs.Trigger>
<Tabs.Trigger
value="code"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Code
{localize('com_ui_code')}
</Tabs.Trigger>
</Tabs.List>
<button
@ -129,26 +138,13 @@ export default function Artifacts() {
</div>
</div>
{/* Content */}
<Tabs.Content
value="code"
className={cn('flex-grow overflow-x-auto overflow-y-scroll bg-gray-900 p-4')}
>
<CodeMarkdown
content={`\`\`\`${getFileExtension(currentArtifact.type)}\n${
currentArtifact.content ?? ''
}\`\`\``}
isSubmitting={isSubmitting}
/>
</Tabs.Content>
<Tabs.Content
value="preview"
className={cn('flex-grow overflow-auto', isMermaid ? 'bg-[#282C34]' : 'bg-white')}
>
<ArtifactPreview
artifact={currentArtifact}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
</Tabs.Content>
<ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact}
isSubmitting={isSubmitting}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
@ -178,20 +174,10 @@ export default function Artifacts() {
</svg>
</button>
</div>
<div className="flex items-center">
<div className="flex items-center gap-2">
<CopyCodeButton content={currentArtifact.content ?? ''} />
{/* Download Button */}
{/* <button className="mr-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z" />
</svg>
</button> */}
<DownloadArtifact artifact={currentArtifact} />
{/* Publish button */}
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
Publish

View file

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { Download } from 'lucide-react';
import type { Artifact } from '~/common';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useEditorContext } from '~/Providers';
import { CheckMark } from '~/components/svg';
import { useLocalize } from '~/hooks';
const DownloadArtifact = ({
artifact,
className = '',
}: {
artifact: Artifact;
className?: string;
}) => {
const localize = useLocalize();
const { currentCode } = useEditorContext();
const [isDownloaded, setIsDownloaded] = useState(false);
const { fileKey: fileName } = useArtifactProps({ artifact });
const handleDownload = () => {
try {
const content = currentCode ?? artifact.content ?? '';
if (!content) {
return;
}
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setIsDownloaded(true);
setTimeout(() => setIsDownloaded(false), 3000);
} catch (error) {
console.error('Download failed:', error);
}
};
return (
<button
className={`mr-2 text-text-secondary ${className}`}
onClick={handleDownload}
aria-label={localize('com_ui_download_artifact')}
>
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
</button>
);
};
export default DownloadArtifact;

View file

@ -8,9 +8,14 @@ import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkDirective from 'remark-directive';
import type { Pluggable } from 'unified';
import {
useToastContext,
ArtifactProvider,
CodeBlockProvider,
useCodeBlockContext,
} from '~/Providers';
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
import { useToastContext, CodeBlockProvider, useCodeBlockContext } from '~/Providers';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import { useFileDownload } from '~/data-provider';
import useLocalize from '~/hooks/useLocalize';
@ -194,27 +199,29 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
return (
<CodeBlockProvider>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code,
a,
p,
artifact: Artifact,
} as {
[nodeType: string]: React.ElementType;
<ArtifactProvider>
<CodeBlockProvider>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code,
a,
p,
artifact: Artifact,
} as {
[nodeType: string]: React.ElementType;
}
}
}
>
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
</ReactMarkdown>
</CodeBlockProvider>
>
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
</ReactMarkdown>
</CodeBlockProvider>
</ArtifactProvider>
);
});

View file

@ -7,7 +7,7 @@ import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import { code, codeNoExecution, a, p } from './Markdown';
import { CodeBlockProvider } from '~/Providers';
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
import { langSubset } from '~/utils';
const MarkdownLite = memo(
@ -25,30 +25,32 @@ const MarkdownLite = memo(
];
return (
<CodeBlockProvider>
<ReactMarkdown
remarkPlugins={[
<ArtifactProvider>
<CodeBlockProvider>
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
]}
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code: codeExecution ? code : codeNoExecution,
a,
p,
} as {
[nodeType: string]: React.ElementType;
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code: codeExecution ? code : codeNoExecution,
a,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
}
>
{content}
</ReactMarkdown>
</CodeBlockProvider>
>
{content}
</ReactMarkdown>
</CodeBlockProvider>
</ArtifactProvider>
);
},
);

View file

@ -121,13 +121,14 @@ const MessageRender = memo(
return (
<div
id={msg.messageId}
aria-label={`message-${msg.depth}-${msg.messageId}`}
className={cn(
baseClasses,
layoutClasses,
latestCardClasses,
showRenderClasses,
'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
'message-render focus:outline-none focus:ring-2 focus:ring-border-xheavy',
)}
onClick={clickHandler}
onKeyDown={(e) => {

View file

@ -8,6 +8,7 @@ import { useDeleteFilesMutation } from '~/data-provider';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanel } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
import { EditorProvider } from '~/Providers';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
@ -94,7 +95,9 @@ export default function Presentation({
artifactsVisible === true &&
codeArtifacts === true &&
Object.keys(artifacts ?? {}).length > 0 ? (
<Artifacts />
<EditorProvider>
<Artifacts />
</EditorProvider>
) : null
}
>

View file

@ -42,13 +42,11 @@ function ConvoOptions({
const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
if (data != null) {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_duplication_success'),
status: 'success',
});
}
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_duplication_success'),
status: 'success',
});
},
onMutate: () => {
showToast({

View file

@ -120,13 +120,15 @@ const ContentRender = memo(
return (
<div
id={msg.messageId}
aria-label={`message-${msg.depth}-${msg.messageId}`}
className={cn(
baseClasses,
isCard ? cardClasses : chatSpaceClasses,
isCard === true ? cardClasses : chatSpaceClasses,
conditionalClasses.latestCard,
conditionalClasses.cardRender,
conditionalClasses.focus,
'message-render',
)}
onClick={clickHandler}
onKeyDown={(e) => {

View file

@ -0,0 +1,2 @@
// export * from './queries';
export * from './mutations';

View file

@ -0,0 +1,46 @@
import { dataService, QueryKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
export const useEditArtifact = (
_options?: t.EditArtifactOptions,
): UseMutationResult<t.TEditArtifactResponse, Error, t.TEditArtifactRequest> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options ?? {};
return useMutation({
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
onSuccess: (data, vars, context) => {
queryClient.setQueryData<t.TMessage[]>([QueryKeys.messages, data.conversationId], (prev) => {
if (!prev) {
return prev;
}
const newArray = [...prev];
let targetIndex: number | undefined;
for (let i = newArray.length - 1; i >= 0; i--) {
if (newArray[i].messageId === vars.messageId) {
targetIndex = i;
break;
}
}
if (targetIndex == null) {
return prev;
}
newArray[targetIndex] = {
...newArray[targetIndex],
content: data.content,
text: data.text,
};
return newArray;
});
onSuccess?.(data, vars, context);
},
...options,
});
};

View file

@ -1,6 +1,7 @@
export * from './Auth';
export * from './Agents';
export * from './Files';
export * from './Messages';
export * from './Tools';
export * from './connection';
export * from './mutations';

View file

@ -579,9 +579,6 @@ export const useDuplicateConversationMutation = (
if (originalId.length === 0) {
return;
}
if (data == null) {
return;
}
queryClient.setQueryData(
[QueryKeys.conversation, data.conversation.conversationId],
data.conversation,

View file

@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { removeNullishValues } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
import { getMermaidFiles } from '~/utils/mermaid';
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 fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
const files = removeNullishValues({
[fileKey]: artifact.content,
});
return [fileKey, files];
}, [artifact.type, artifact.content, artifact.language]);
const template = useMemo(
() => getTemplate(artifact.type ?? '', artifact.language),
[artifact.type, artifact.language],
);
const sharedProps = useMemo(() => getProps(artifact.type ?? ''), [artifact.type]);
return {
files,
fileKey,
template,
sharedProps,
};
}

View file

@ -1,30 +1,47 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useChatContext } from '~/Providers';
// hooks/useAutoScroll.ts
import { useEffect, useState } from 'react';
export default function useAutoScroll() {
const { isSubmitting } = useChatContext();
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const contentEndRef = useRef<HTMLDivElement | null>(null);
interface UseAutoScrollProps {
ref: React.RefObject<HTMLElement>;
content: string;
isSubmitting: boolean;
}
const scrollToBottom = useCallback(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight;
}
}, []);
const handleScroll = useCallback(() => {
if (scrollableRef.current) {
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
}
}, []);
export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => {
const [userScrolled, setUserScrolled] = useState(false);
useEffect(() => {
if (isSubmitting) {
scrollToBottom();
const scrollContainer = ref.current;
if (!scrollContainer) {
return;
}
}, [isSubmitting, scrollToBottom]);
return { scrollableRef, contentEndRef, handleScroll, scrollToBottom, showScrollButton };
}
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 };
};

View file

@ -147,6 +147,8 @@ export default {
com_ui_date_november: 'November',
com_ui_date_december: 'December',
com_ui_field_required: 'This field is required',
com_ui_download_artifact: 'Download Artifact',
com_ui_download: 'Download',
com_ui_download_error: 'Error downloading file. The file may have been deleted.',
com_ui_attach_error_type: 'Unsupported file type for endpoint:',
com_ui_attach_error_openai: 'Cannot attach Assistant files to other endpoints',

View file

@ -337,4 +337,28 @@
.shake {
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(21, 21, 21, 0.5) !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;
}