mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 03:10:15 +01:00
🚀 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:
parent
87383fec27
commit
ed57bb4711
34 changed files with 1156 additions and 237 deletions
32
client/src/Providers/ArtifactContext.tsx
Normal file
32
client/src/Providers/ArtifactContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
29
client/src/Providers/EditorContext.tsx
Normal file
29
client/src/Providers/EditorContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
151
client/src/components/Artifacts/ArtifactCodeEditor.tsx
Normal file
151
client/src/components/Artifacts/ArtifactCodeEditor.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
60
client/src/components/Artifacts/ArtifactTabs.tsx
Normal file
60
client/src/components/Artifacts/ArtifactTabs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
54
client/src/components/Artifacts/DownloadArtifact.tsx
Normal file
54
client/src/components/Artifacts/DownloadArtifact.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
2
client/src/data-provider/Messages/index.ts
Normal file
2
client/src/data-provider/Messages/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// export * from './queries';
|
||||
export * from './mutations';
|
||||
46
client/src/data-provider/Messages/mutations.ts
Normal file
46
client/src/data-provider/Messages/mutations.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
client/src/hooks/Artifacts/useArtifactProps.ts
Normal file
33
client/src/hooks/Artifacts/useArtifactProps.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue