mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
✨ feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
* ✨ feat: Enhance Artifact Management with Version Control and UI Improvements ✨ feat: Improve mobile layout and responsiveness in Artifacts component ✨ feat: Refactor imports and remove unnecessary props in Artifact components ✨ feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions feat: Enhance artifact panel animations and improve UI responsiveness - Updated Thinking component button styles for smoother transitions. - Implemented dynamic rendering for artifacts panel with animation effects. - Refactored localization keys for consistency across multiple languages. - Added new CSS animations for iOS-inspired smooth transitions. - Improved Tailwind CSS configuration to support enhanced animation effects. ✨ feat: Add fullWidth and icon support to Radio component for enhanced flexibility refactor: Remove unused PreviewProps import in ArtifactPreview component refactor: Improve button class handling and blur effect constants in Artifact components ✨ feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI chore: Bump @librechat/client version to 0.3.2 refactor: Update button styles and transition durations for improved UI responsiveness refactor: revert back localization key refactor: remove unused scaling and animation properties for cleaner CSS refactor: remove unused animation properties for cleaner configuration * ✨ refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components * refactor: Remove cycleArtifact function from useArtifacts hook * ✨ feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component * ✨ feat: Update Badge component for responsive design and improve tap scaling behavior * chore: Update react-resizable-panels dependency to version 3.0.6 * ✨ feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles * ✨ style: Update text color for improved visibility in Artifacts component * ✨ style: Remove text color class for improved Spinner styling in Artifacts component * refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks * refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders * fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact' * feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management * fix: proper switch to preview as soon as artifact becomes enclosed * refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic * test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management * test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
4186db3ce2
commit
b8b1217c34
25 changed files with 1565 additions and 345 deletions
|
|
@ -93,7 +93,7 @@
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-lazy-load-image-component": "^1.6.0",
|
"react-lazy-load-image-component": "^1.6.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.11.2",
|
"react-router-dom": "^6.11.2",
|
||||||
"react-speech-recognition": "^3.10.0",
|
"react-speech-recognition": "^3.10.0",
|
||||||
"react-textarea-autosize": "^8.4.0",
|
"react-textarea-autosize": "^8.4.0",
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,76 @@
|
||||||
import React, { createContext, useContext, useState } from 'react';
|
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||||
|
|
||||||
interface EditorContextType {
|
/**
|
||||||
|
* Mutation state context - for components that need to know about save/edit status
|
||||||
|
* Separated from code state to prevent unnecessary re-renders
|
||||||
|
*/
|
||||||
|
interface MutationContextType {
|
||||||
isMutating: boolean;
|
isMutating: boolean;
|
||||||
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code state context - for components that need the current code content
|
||||||
|
* Changes frequently (on every keystroke), so only subscribe if needed
|
||||||
|
*/
|
||||||
|
interface CodeContextType {
|
||||||
currentCode?: string;
|
currentCode?: string;
|
||||||
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
|
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorContext = createContext<EditorContextType | undefined>(undefined);
|
const MutationContext = createContext<MutationContextType | undefined>(undefined);
|
||||||
|
const CodeContext = createContext<CodeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides editor state management for artifact code editing
|
||||||
|
* Split into two contexts to prevent unnecessary re-renders:
|
||||||
|
* - MutationContext: for save/edit status (changes rarely)
|
||||||
|
* - CodeContext: for code content (changes on every keystroke)
|
||||||
|
*/
|
||||||
export function EditorProvider({ children }: { children: React.ReactNode }) {
|
export function EditorProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
const [currentCode, setCurrentCode] = useState<string | undefined>();
|
const [currentCode, setCurrentCode] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
|
||||||
|
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
|
<MutationContext.Provider value={mutationValue}>
|
||||||
{children}
|
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
|
||||||
</EditorContext.Provider>
|
</MutationContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditorContext() {
|
/**
|
||||||
const context = useContext(EditorContext);
|
* Hook to access mutation state only
|
||||||
|
* Use this when you only need to know about save/edit status
|
||||||
|
*/
|
||||||
|
export function useMutationState() {
|
||||||
|
const context = useContext(MutationContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useEditorContext must be used within an EditorProvider');
|
throw new Error('useMutationState must be used within an EditorProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access code state only
|
||||||
|
* Use this when you need the current code content
|
||||||
|
*/
|
||||||
|
export function useCodeState() {
|
||||||
|
const context = useContext(CodeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useCodeState must be used within an EditorProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use useMutationState() and/or useCodeState() instead
|
||||||
|
* This hook causes components to re-render on every keystroke
|
||||||
|
*/
|
||||||
|
export function useEditorContext() {
|
||||||
|
const mutation = useMutationState();
|
||||||
|
const code = useCodeState();
|
||||||
|
return { ...mutation, ...code };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
|
||||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||||
import { getFileType, logger } from '~/utils';
|
import { cn, getFileType, logger } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
|
||||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||||
|
const isSelected = artifact?.id === currentArtifactId;
|
||||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||||
|
|
||||||
const debouncedSetVisibleRef = useRef(
|
const debouncedSetVisibleRef = useRef(
|
||||||
|
|
@ -54,35 +55,54 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||||
<button
|
{(() => {
|
||||||
type="button"
|
const handleClick = () => {
|
||||||
onClick={() => {
|
if (!location.pathname.includes('/c/')) return;
|
||||||
if (!location.pathname.includes('/c/')) {
|
|
||||||
|
if (isSelected) {
|
||||||
|
resetCurrentArtifactId();
|
||||||
|
setVisible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCurrentArtifactId();
|
resetCurrentArtifactId();
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
|
||||||
if (artifacts?.[artifact.id] == null) {
|
if (artifacts?.[artifact.id] == null) {
|
||||||
setArtifacts(visibleArtifacts);
|
setArtifacts(visibleArtifacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentArtifactId(artifact.id);
|
setCurrentArtifactId(artifact.id);
|
||||||
}, 15);
|
}, 15);
|
||||||
}}
|
};
|
||||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
|
||||||
>
|
const buttonClass = cn(
|
||||||
<div className="w-fit bg-surface-tertiary p-2">
|
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
|
||||||
<div className="flex flex-row items-center gap-2">
|
{
|
||||||
<FilePreview fileType={fileType} className="relative" />
|
'border-border-medium bg-surface-hover shadow-lg': isSelected,
|
||||||
<div className="overflow-hidden text-left">
|
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
|
||||||
<div className="truncate font-medium">{artifact.title}</div>
|
},
|
||||||
<div className="truncate text-text-secondary">
|
);
|
||||||
{localize('com_ui_artifact_click')}
|
|
||||||
|
const actionLabel = isSelected
|
||||||
|
? localize('com_ui_click_to_close')
|
||||||
|
: localize('com_ui_artifact_click');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleClick} className={buttonClass}>
|
||||||
|
<div className="w-fit p-2">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<FilePreview fileType={fileType} className="relative" />
|
||||||
|
<div className="overflow-hidden text-left">
|
||||||
|
<div className="truncate font-medium">{artifact.title}</div>
|
||||||
|
<div className="truncate text-text-secondary">{actionLabel}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
);
|
||||||
</button>
|
})()}
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
import { KeyBinding } from '@codemirror/view';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
import {
|
import {
|
||||||
useSandpack,
|
useSandpack,
|
||||||
SandpackCodeEditor,
|
SandpackCodeEditor,
|
||||||
|
|
@ -10,116 +12,143 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||||
import type { ArtifactFiles, Artifact } from '~/common';
|
import type { ArtifactFiles, Artifact } from '~/common';
|
||||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
||||||
|
import { useArtifactsContext } from '~/Providers';
|
||||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||||
|
|
||||||
const createDebouncedMutation = (
|
const CodeEditor = memo(
|
||||||
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 [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
|
||||||
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
|
|
||||||
const editArtifact = useEditArtifact({
|
|
||||||
onMutate: (vars) => {
|
|
||||||
setIsMutating(true);
|
|
||||||
setCurrentUpdate(vars.updated);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsMutating(false);
|
|
||||||
setCurrentUpdate(null);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setIsMutating(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (artifact.index == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
|
||||||
const isNotOriginal =
|
|
||||||
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
|
|
||||||
const isNotRepeated =
|
|
||||||
currentUpdate == null
|
|
||||||
? true
|
|
||||||
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
|
|
||||||
|
|
||||||
if (artifact.content && isNotOriginal && isNotRepeated) {
|
|
||||||
setCurrentCode(currentCode);
|
|
||||||
debouncedMutation({
|
|
||||||
index: artifact.index,
|
|
||||||
messageId: artifact.messageId ?? '',
|
|
||||||
original: artifact.content,
|
|
||||||
updated: currentCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
debouncedMutation.cancel();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
fileKey,
|
fileKey,
|
||||||
artifact.index,
|
|
||||||
artifact.content,
|
|
||||||
artifact.messageId,
|
|
||||||
readOnly,
|
readOnly,
|
||||||
isMutating,
|
artifact,
|
||||||
currentUpdate,
|
editorRef,
|
||||||
setIsMutating,
|
}: {
|
||||||
sandpack.files,
|
fileKey: string;
|
||||||
setCurrentCode,
|
readOnly?: boolean;
|
||||||
debouncedMutation,
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
/**
|
||||||
<SandpackCodeEditor
|
* Create stable debounced mutation that doesn't depend on changing callbacks
|
||||||
ref={editorRef}
|
* Use refs to always access the latest values without recreating the debounce
|
||||||
showTabs={false}
|
*/
|
||||||
showRunButton={false}
|
const artifactRef = useRef(artifact);
|
||||||
showLineNumbers={true}
|
const isMutatingRef = useRef(isMutating);
|
||||||
showInlineErrors={true}
|
const currentUpdateRef = useRef(currentUpdate);
|
||||||
readOnly={readOnly === true}
|
const editArtifactRef = useRef(editArtifact);
|
||||||
className="hljs language-javascript bg-black"
|
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||||
/>
|
|
||||||
);
|
useEffect(() => {
|
||||||
};
|
artifactRef.current = artifact;
|
||||||
|
}, [artifact]);
|
||||||
|
|
||||||
|
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 ({
|
export const ArtifactCodeEditor = function ({
|
||||||
files,
|
files,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { memo, useMemo } from 'react';
|
import React, { memo, useMemo, type MutableRefObject } from 'react';
|
||||||
import {
|
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||||
SandpackPreview,
|
import type {
|
||||||
SandpackProvider,
|
|
||||||
SandpackProviderProps,
|
SandpackProviderProps,
|
||||||
|
SandpackPreviewRef,
|
||||||
} from '@codesandbox/sandpack-react/unstyled';
|
} from '@codesandbox/sandpack-react/unstyled';
|
||||||
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
|
|
||||||
import type { TStartupConfig } from 'librechat-data-provider';
|
import type { TStartupConfig } from 'librechat-data-provider';
|
||||||
import type { ArtifactFiles } from '~/common';
|
import type { ArtifactFiles } from '~/common';
|
||||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||||
|
|
@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
template: SandpackProviderProps['template'];
|
template: SandpackProviderProps['template'];
|
||||||
sharedProps: Partial<SandpackProviderProps>;
|
sharedProps: Partial<SandpackProviderProps>;
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: MutableRefObject<SandpackPreviewRef>;
|
||||||
currentCode?: string;
|
currentCode?: string;
|
||||||
startupConfig?: TStartupConfig;
|
startupConfig?: TStartupConfig;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...files,
|
...files,
|
||||||
[fileKey]: {
|
[fileKey]: { code },
|
||||||
code,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}, [currentCode, files, fileKey]);
|
}, [currentCode, files, fileKey]);
|
||||||
|
|
||||||
|
|
@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
|
||||||
if (!startupConfig) {
|
if (!startupConfig) {
|
||||||
return sharedOptions;
|
return sharedOptions;
|
||||||
}
|
}
|
||||||
const _options: typeof sharedOptions = {
|
return {
|
||||||
...sharedOptions,
|
...sharedOptions,
|
||||||
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _options;
|
|
||||||
}, [startupConfig, template]);
|
}, [startupConfig, template]);
|
||||||
|
|
||||||
if (Object.keys(artifactFiles).length === 0) {
|
if (Object.keys(artifactFiles).length === 0) {
|
||||||
|
|
@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SandpackProvider
|
<SandpackProvider
|
||||||
files={{
|
files={{ ...artifactFiles, ...sharedFiles }}
|
||||||
...artifactFiles,
|
|
||||||
...sharedFiles,
|
|
||||||
}}
|
|
||||||
options={options}
|
options={options}
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
template={template}
|
template={template}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||||
|
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
import { useCodeState } from '~/Providers/EditorContext';
|
||||||
|
import { useArtifactsContext } from '~/Providers';
|
||||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { ArtifactPreview } from './ArtifactPreview';
|
import { ArtifactPreview } from './ArtifactPreview';
|
||||||
import { cn } from '~/utils';
|
|
||||||
|
|
||||||
export default function ArtifactTabs({
|
export default function ArtifactTabs({
|
||||||
artifact,
|
artifact,
|
||||||
|
|
@ -20,9 +21,10 @@ export default function ArtifactTabs({
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
}) {
|
}) {
|
||||||
const { isSubmitting } = useArtifactsContext();
|
const { isSubmitting } = useArtifactsContext();
|
||||||
const { currentCode, setCurrentCode } = useEditorContext();
|
const { currentCode, setCurrentCode } = useCodeState();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const lastIdRef = useRef<string | null>(null);
|
const lastIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (artifact.id !== lastIdRef.current) {
|
if (artifact.id !== lastIdRef.current) {
|
||||||
setCurrentCode(undefined);
|
setCurrentCode(undefined);
|
||||||
|
|
@ -33,14 +35,16 @@ export default function ArtifactTabs({
|
||||||
const content = artifact.content ?? '';
|
const content = artifact.content ?? '';
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||||
|
|
||||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-full w-full flex-col">
|
||||||
<Tabs.Content
|
<Tabs.Content
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
value="code"
|
value="code"
|
||||||
id="artifacts-code"
|
id="artifacts-code"
|
||||||
className={cn('flex-grow overflow-auto')}
|
className="h-full w-full flex-grow overflow-auto"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<ArtifactCodeEditor
|
<ArtifactCodeEditor
|
||||||
|
|
@ -52,7 +56,8 @@ export default function ArtifactTabs({
|
||||||
sharedProps={sharedProps}
|
sharedProps={sharedProps}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
|
||||||
|
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
|
||||||
<ArtifactPreview
|
<ArtifactPreview
|
||||||
files={files}
|
files={files}
|
||||||
fileKey={fileKey}
|
fileKey={fileKey}
|
||||||
|
|
@ -63,6 +68,6 @@ export default function ArtifactTabs({
|
||||||
startupConfig={startupConfig}
|
startupConfig={startupConfig}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
client/src/components/Artifacts/ArtifactVersion.tsx
Normal file
79
client/src/components/Artifacts/ArtifactVersion.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { MenuButton } from '@ariakit/react';
|
||||||
|
import { History, Check } from 'lucide-react';
|
||||||
|
import { DropdownPopup, TooltipAnchor, Button, useMediaQuery } from '@librechat/client';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface ArtifactVersionProps {
|
||||||
|
currentIndex: number;
|
||||||
|
totalVersions: number;
|
||||||
|
onVersionChange: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArtifactVersion({
|
||||||
|
currentIndex,
|
||||||
|
totalVersions,
|
||||||
|
onVersionChange,
|
||||||
|
}: ArtifactVersionProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
const menuId = 'version-dropdown-menu';
|
||||||
|
|
||||||
|
const handleValueChange = (value: string) => {
|
||||||
|
const index = parseInt(value, 10);
|
||||||
|
onVersionChange(index);
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalVersions <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = Array.from({ length: totalVersions }, (_, index) => ({
|
||||||
|
value: index.toString(),
|
||||||
|
label: localize('com_ui_version_var', { 0: String(index + 1) }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dropdownItems = options.map((option) => {
|
||||||
|
const isSelected = option.value === String(currentIndex);
|
||||||
|
return {
|
||||||
|
label: option.label,
|
||||||
|
onClick: () => handleValueChange(option.value),
|
||||||
|
value: option.value,
|
||||||
|
icon: isSelected ? (
|
||||||
|
<Check size={16} className="text-text-primary" aria-hidden="true" />
|
||||||
|
) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownPopup
|
||||||
|
menuId={menuId}
|
||||||
|
portal
|
||||||
|
focusLoop
|
||||||
|
unmountOnHide
|
||||||
|
isOpen={isPopoverActive}
|
||||||
|
setIsOpen={setIsPopoverActive}
|
||||||
|
trigger={
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_change_version')}
|
||||||
|
render={
|
||||||
|
<Button size="icon" variant="ghost" asChild>
|
||||||
|
<MenuButton>
|
||||||
|
<History
|
||||||
|
size={18}
|
||||||
|
className="text-text-secondary"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
/>
|
||||||
|
</MenuButton>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
items={dropdownItems}
|
||||||
|
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,147 +1,332 @@
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useSetRecoilState } from 'recoil';
|
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
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, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||||
import DownloadArtifact from './DownloadArtifact';
|
import DownloadArtifact from './DownloadArtifact';
|
||||||
import { useEditorContext } from '~/Providers';
|
import ArtifactVersion from './ArtifactVersion';
|
||||||
|
import { useMutationState } from '~/Providers/EditorContext';
|
||||||
import ArtifactTabs from './ArtifactTabs';
|
import ArtifactTabs from './ArtifactTabs';
|
||||||
import { CopyCodeButton } from './Code';
|
import { CopyCodeButton } from './Code';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
const MAX_BLUR_AMOUNT = 32;
|
||||||
|
const MAX_BACKDROP_OPACITY = 0.3;
|
||||||
|
|
||||||
export default function Artifacts() {
|
export default function Artifacts() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { isMutating } = useEditorContext();
|
const { isMutating } = useMutationState();
|
||||||
|
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||||
const editorRef = useRef<CodeEditorRef>();
|
const editorRef = useRef<CodeEditorRef>();
|
||||||
const previewRef = useRef<SandpackPreviewRef>();
|
const previewRef = useRef<SandpackPreviewRef>();
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [height, setHeight] = useState(90);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [blurAmount, setBlurAmount] = useState(0);
|
||||||
|
const dragStartY = useRef(0);
|
||||||
|
const dragStartHeight = useRef(90);
|
||||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||||
|
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||||
|
|
||||||
|
const tabOptions = [
|
||||||
|
{
|
||||||
|
value: 'code',
|
||||||
|
label: localize('com_ui_code'),
|
||||||
|
icon: <Code className="size-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'preview',
|
||||||
|
label: localize('com_ui_preview'),
|
||||||
|
icon: <Play className="size-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsVisible(true);
|
setIsMounted(true);
|
||||||
}, []);
|
const delay = isMobile ? 50 : 30;
|
||||||
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
setIsMounted(false);
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
setBlurAmount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeightForBlur = 50;
|
||||||
|
const maxHeightForBlur = 100;
|
||||||
|
|
||||||
|
if (height <= minHeightForBlur) {
|
||||||
|
setBlurAmount(0);
|
||||||
|
} else if (height >= maxHeightForBlur) {
|
||||||
|
setBlurAmount(MAX_BLUR_AMOUNT);
|
||||||
|
} else {
|
||||||
|
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
|
||||||
|
setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT));
|
||||||
|
}
|
||||||
|
}, [height, isMobile]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
cycleArtifact,
|
|
||||||
currentArtifact,
|
currentArtifact,
|
||||||
orderedArtifactIds,
|
orderedArtifactIds,
|
||||||
|
setCurrentArtifactId,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
if (currentArtifact === null || currentArtifact === undefined) {
|
const handleDragStart = (e: React.PointerEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStartY.current = e.clientY;
|
||||||
|
dragStartHeight.current = height;
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragMove = (e: React.PointerEvent) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaY = dragStartY.current - e.clientY;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const deltaPercentage = (deltaY / viewportHeight) * 100;
|
||||||
|
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
|
||||||
|
|
||||||
|
setHeight(newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (e: React.PointerEvent) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
// Snap to positions based on final height
|
||||||
|
if (height < 30) {
|
||||||
|
closeArtifacts();
|
||||||
|
} else if (height > 95) {
|
||||||
|
setHeight(100);
|
||||||
|
} else if (height < 60) {
|
||||||
|
setHeight(50);
|
||||||
|
} else {
|
||||||
|
setHeight(90);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentArtifact || !isMounted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
const client = previewRef.current?.getClient();
|
const client = previewRef.current?.getClient();
|
||||||
if (client != null) {
|
if (client) {
|
||||||
client.dispatch({ type: 'refresh' });
|
client.dispatch({ type: 'refresh' });
|
||||||
}
|
}
|
||||||
setTimeout(() => setIsRefreshing(false), 750);
|
setTimeout(() => setIsRefreshing(false), 750);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeArtifacts = () => {
|
const closeArtifacts = () => {
|
||||||
setIsVisible(false);
|
if (isMobile) {
|
||||||
setTimeout(() => setArtifactsVisible(false), 300);
|
setIsClosing(true);
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setArtifactsVisible(false);
|
||||||
|
setIsClosing(false);
|
||||||
|
setHeight(90);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
resetCurrentArtifactId();
|
||||||
|
setArtifactsVisible(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backdropOpacity =
|
||||||
|
blurAmount > 0
|
||||||
|
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||||
{/* Main Parent */}
|
<div className="flex h-full w-full flex-col">
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
{/* Mobile backdrop with dynamic blur */}
|
||||||
{/* Main Container */}
|
{isMobile && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
|
||||||
|
isVisible && !isClosing
|
||||||
|
? 'transition-all duration-300'
|
||||||
|
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
|
||||||
|
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
opacity: isVisible && !isClosing ? backdropOpacity : 0,
|
||||||
|
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||||
|
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||||
|
}}
|
||||||
|
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
`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-500 ease-in-out`,
|
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
|
||||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
|
isMobile
|
||||||
|
? cn(
|
||||||
|
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
|
||||||
|
isVisible && !isClosing
|
||||||
|
? 'translate-y-0 opacity-100'
|
||||||
|
: 'duration-250 translate-y-full opacity-0 transition-all',
|
||||||
|
isDragging ? '' : 'transition-all duration-300',
|
||||||
|
)
|
||||||
|
: cn(
|
||||||
|
'h-full shadow-2xl',
|
||||||
|
isVisible && !isClosing
|
||||||
|
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||||
|
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
|
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{isMobile && (
|
||||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
<div
|
||||||
<div className="flex items-center">
|
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
onPointerDown={handleDragStart}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onPointerMove={handleDragMove}
|
||||||
</button>
|
onPointerUp={handleDragEnd}
|
||||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
onPointerCancel={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
)}
|
||||||
{/* Refresh button */}
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
|
||||||
|
isMobile ? 'justify-center' : 'overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isMobile && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center transition-all duration-500',
|
||||||
|
isVisible && !isClosing
|
||||||
|
? 'translate-x-0 opacity-100'
|
||||||
|
: '-translate-x-2 opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
options={tabOptions}
|
||||||
|
value={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
disabled={isMutating && activeTab !== 'code'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 transition-all duration-500',
|
||||||
|
isMobile ? 'min-w-max' : '',
|
||||||
|
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{activeTab === 'preview' && (
|
{activeTab === 'preview' && (
|
||||||
<button
|
<Button
|
||||||
className={cn(
|
size="icon"
|
||||||
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
|
variant="ghost"
|
||||||
isRefreshing ? 'rotate-180' : '',
|
|
||||||
)}
|
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
aria-label="Refresh"
|
aria-label={localize('com_ui_refresh')}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
{isRefreshing ? (
|
||||||
size={16}
|
<Spinner size={16} />
|
||||||
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
|
) : (
|
||||||
/>
|
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||||
</button>
|
)}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{activeTab !== 'preview' && isMutating && (
|
{activeTab !== 'preview' && isMutating && (
|
||||||
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
|
<RefreshCw size={16} className="animate-spin text-text-secondary" />
|
||||||
|
)}
|
||||||
|
{orderedArtifactIds.length > 1 && (
|
||||||
|
<ArtifactVersion
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
totalVersions={orderedArtifactIds.length}
|
||||||
|
onVersionChange={(index) => {
|
||||||
|
const target = orderedArtifactIds[index];
|
||||||
|
if (target) {
|
||||||
|
setCurrentArtifactId(target);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 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"
|
|
||||||
>
|
|
||||||
{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"
|
|
||||||
>
|
|
||||||
{localize('com_ui_code')}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
<button className="text-text-secondary" onClick={closeArtifacts}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Content */}
|
|
||||||
<ArtifactTabs
|
|
||||||
artifact={currentArtifact}
|
|
||||||
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">
|
|
||||||
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
|
||||||
orderedArtifactIds.length
|
|
||||||
}`}</span>
|
|
||||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||||
{/* Download Button */}
|
|
||||||
<DownloadArtifact artifact={currentArtifact} />
|
<DownloadArtifact artifact={currentArtifact} />
|
||||||
{/* Publish button */}
|
<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">
|
size="icon"
|
||||||
Publish
|
variant="ghost"
|
||||||
</button> */}
|
onClick={closeArtifacts}
|
||||||
|
aria-label={localize('com_ui_close')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||||
|
<div className="absolute inset-0 flex flex-col">
|
||||||
|
<ArtifactTabs
|
||||||
|
artifact={currentArtifact}
|
||||||
|
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||||
|
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||||
|
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||||
|
)}
|
||||||
|
aria-hidden={!isRefreshing}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-transform duration-300 ease-in-out',
|
||||||
|
isRefreshing ? 'scale-100' : 'scale-95',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Spinner size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
|
||||||
|
<Radio
|
||||||
|
fullWidth
|
||||||
|
options={tabOptions}
|
||||||
|
value={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
disabled={isMutating && activeTab !== 'code'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import React, { memo, useEffect, useRef, useState } from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Button } from '@librechat/client';
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import { Clipboard, CheckMark } from '@librechat/client';
|
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||||
import { handleDoubleClick, langSubset } from '~/utils';
|
import { handleDoubleClick, langSubset } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
|
@ -107,12 +108,13 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
className="mr-2 text-text-secondary"
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||||
>
|
>
|
||||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, CircleCheckBig } from 'lucide-react';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import { CheckMark } from '@librechat/client';
|
import { Button } from '@librechat/client';
|
||||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||||
import { useEditorContext } from '~/Providers';
|
import { useCodeState } from '~/Providers/EditorContext';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
const DownloadArtifact = ({
|
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
|
||||||
artifact,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
artifact: Artifact;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { currentCode } = useEditorContext();
|
const { currentCode } = useCodeState();
|
||||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||||
const { fileKey: fileName } = useArtifactProps({ artifact });
|
const { fileKey: fileName } = useArtifactProps({ artifact });
|
||||||
|
|
||||||
|
|
@ -41,13 +35,14 @@ const DownloadArtifact = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
className={`mr-2 text-text-secondary ${className}`}
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
aria-label={localize('com_ui_download_artifact')}
|
aria-label={localize('com_ui_download_artifact')}
|
||||||
>
|
>
|
||||||
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'items-between flex gap-2 pb-2',
|
'@container items-between flex gap-2 pb-2',
|
||||||
isRTL ? 'flex-row-reverse' : 'flex-row',
|
isRTL ? 'flex-row-reverse' : 'flex-row',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,23 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||||
}, []);
|
}, []);
|
||||||
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoize artifacts JSX to prevent recreating it on every render
|
||||||
|
* This is critical for performance - prevents entire artifact tree from re-rendering
|
||||||
|
*/
|
||||||
|
const artifactsElement = useMemo(() => {
|
||||||
|
if (artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0) {
|
||||||
|
return (
|
||||||
|
<ArtifactsProvider>
|
||||||
|
<EditorProvider>
|
||||||
|
<Artifacts />
|
||||||
|
</EditorProvider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [artifactsVisibility, artifacts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
||||||
<SidePanelProvider>
|
<SidePanelProvider>
|
||||||
|
|
@ -64,15 +81,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||||
defaultLayout={defaultLayout}
|
defaultLayout={defaultLayout}
|
||||||
fullPanelCollapse={fullCollapse}
|
fullPanelCollapse={fullCollapse}
|
||||||
defaultCollapsed={defaultCollapsed}
|
defaultCollapsed={defaultCollapsed}
|
||||||
artifacts={
|
artifacts={artifactsElement}
|
||||||
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
|
|
||||||
<ArtifactsProvider>
|
|
||||||
<EditorProvider>
|
|
||||||
<Artifacts />
|
|
||||||
</EditorProvider>
|
|
||||||
</ArtifactsProvider>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
82
client/src/components/SidePanel/ArtifactsPanel.tsx
Normal file
82
client/src/components/SidePanel/ArtifactsPanel.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useRef, useEffect, memo } from 'react';
|
||||||
|
import { ResizableHandleAlt, ResizablePanel } from '@librechat/client';
|
||||||
|
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
|
||||||
|
interface ArtifactsPanelProps {
|
||||||
|
artifacts: React.ReactNode | null;
|
||||||
|
currentLayout: number[];
|
||||||
|
minSizeMain: number;
|
||||||
|
shouldRender: boolean;
|
||||||
|
onRenderChange: (shouldRender: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArtifactsPanel component - memoized to prevent unnecessary re-renders
|
||||||
|
* Only re-renders when artifacts visibility or layout changes
|
||||||
|
*/
|
||||||
|
const ArtifactsPanel = memo(function ArtifactsPanel({
|
||||||
|
artifacts,
|
||||||
|
currentLayout,
|
||||||
|
minSizeMain,
|
||||||
|
shouldRender,
|
||||||
|
onRenderChange,
|
||||||
|
}: ArtifactsPanelProps) {
|
||||||
|
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (artifacts != null) {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
onRenderChange(true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
artifactsPanelRef.current?.expand();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (shouldRender) {
|
||||||
|
artifactsPanelRef.current?.collapse();
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
onRenderChange(false);
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [artifacts, shouldRender, onRenderChange]);
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{artifacts != null && (
|
||||||
|
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
|
||||||
|
)}
|
||||||
|
<ResizablePanel
|
||||||
|
ref={artifactsPanelRef}
|
||||||
|
defaultSize={artifacts != null ? currentLayout[1] : 0}
|
||||||
|
minSize={minSizeMain}
|
||||||
|
maxSize={70}
|
||||||
|
collapsible={true}
|
||||||
|
collapsedSize={0}
|
||||||
|
order={2}
|
||||||
|
id="artifacts-panel"
|
||||||
|
>
|
||||||
|
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArtifactsPanel.displayName = 'ArtifactsPanel';
|
||||||
|
|
||||||
|
export default ArtifactsPanel;
|
||||||
|
|
@ -2,14 +2,10 @@ import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { getConfigDefaults } from 'librechat-data-provider';
|
import { getConfigDefaults } from 'librechat-data-provider';
|
||||||
import {
|
import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/client';
|
||||||
ResizableHandleAlt,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
useMediaQuery,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
|
import ArtifactsPanel from './ArtifactsPanel';
|
||||||
import { normalizeLayout } from '~/utils';
|
import { normalizeLayout } from '~/utils';
|
||||||
import SidePanel from './SidePanel';
|
import SidePanel from './SidePanel';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
@ -46,6 +42,7 @@ const SidePanelGroup = memo(
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
|
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
|
||||||
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
|
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
|
||||||
|
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
|
||||||
|
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||||
|
|
@ -109,7 +106,7 @@ const SidePanelGroup = memo(
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
onLayout={(sizes) => throttledSaveLayout(sizes)}
|
onLayout={(sizes) => throttledSaveLayout(sizes)}
|
||||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
|
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
|
||||||
>
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={currentLayout[0]}
|
defaultSize={currentLayout[0]}
|
||||||
|
|
@ -119,19 +116,17 @@ const SidePanelGroup = memo(
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
{artifacts != null && (
|
|
||||||
<>
|
{!isSmallScreen && (
|
||||||
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
|
<ArtifactsPanel
|
||||||
<ResizablePanel
|
artifacts={artifacts}
|
||||||
defaultSize={currentLayout[1]}
|
currentLayout={currentLayout}
|
||||||
minSize={minSizeMain}
|
minSizeMain={minSizeMain}
|
||||||
order={2}
|
shouldRender={shouldRenderArtifacts}
|
||||||
id="artifacts-panel"
|
onRenderChange={setShouldRenderArtifacts}
|
||||||
>
|
/>
|
||||||
{artifacts}
|
|
||||||
</ResizablePanel>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hideSidePanel && interfaceConfig.sidePanel === true && (
|
{!hideSidePanel && interfaceConfig.sidePanel === true && (
|
||||||
<SidePanel
|
<SidePanel
|
||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
|
|
@ -149,6 +144,9 @@ const SidePanelGroup = memo(
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
{artifacts != null && isSmallScreen && (
|
||||||
|
<div className="fixed inset-0 z-[100]">{artifacts}</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label="Close right side panel"
|
aria-label="Close right side panel"
|
||||||
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
|
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,52 @@
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { dataService, QueryKeys, Constants } from 'librechat-data-provider';
|
import { dataService, QueryKeys, Constants } from 'librechat-data-provider';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult, UseMutationOptions } from '@tanstack/react-query';
|
||||||
import type * as t from 'librechat-data-provider';
|
import type * as t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
type EditArtifactContext = {
|
||||||
|
previousMessages: Record<string, t.TMessage[] | undefined>;
|
||||||
|
updatedConversationId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const useEditArtifact = (
|
export const useEditArtifact = (
|
||||||
_options?: t.EditArtifactOptions,
|
_options?: t.EditArtifactOptions,
|
||||||
): UseMutationResult<t.TEditArtifactResponse, Error, t.TEditArtifactRequest> => {
|
): UseMutationResult<
|
||||||
|
t.TEditArtifactResponse,
|
||||||
|
Error,
|
||||||
|
t.TEditArtifactRequest,
|
||||||
|
EditArtifactContext
|
||||||
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { onSuccess, ...options } = _options ?? {};
|
const { onSuccess, onError, onMutate: userOnMutate, ...options } = _options ?? {};
|
||||||
return useMutation({
|
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
t.TEditArtifactResponse,
|
||||||
|
Error,
|
||||||
|
t.TEditArtifactRequest,
|
||||||
|
EditArtifactContext
|
||||||
|
> = {
|
||||||
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
|
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
|
||||||
|
/**
|
||||||
|
* onMutate: No optimistic updates for artifact editing
|
||||||
|
* The code editor shows changes instantly via local Sandpack state
|
||||||
|
* Optimistic updates cause "original content not found" errors because:
|
||||||
|
* 1. First edit optimistically updates cache
|
||||||
|
* 2. Artifact.content reflects the updated cache
|
||||||
|
* 3. Next edit sends updated content as "original" → doesn't match DB → error
|
||||||
|
*/
|
||||||
|
onMutate: async (vars) => {
|
||||||
|
// Call user's onMutate if provided
|
||||||
|
if (userOnMutate) {
|
||||||
|
await userOnMutate(vars);
|
||||||
|
}
|
||||||
|
return { previousMessages: {}, updatedConversationId: null };
|
||||||
|
},
|
||||||
|
onError: (error, vars, context) => {
|
||||||
|
onError?.(error, vars, context);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* On success: Update with server response to ensure consistency
|
||||||
|
*/
|
||||||
onSuccess: (data, vars, context) => {
|
onSuccess: (data, vars, context) => {
|
||||||
let targetNotFound = true;
|
let targetNotFound = true;
|
||||||
const setMessageData = (conversationId?: string | null) => {
|
const setMessageData = (conversationId?: string | null) => {
|
||||||
|
|
@ -50,11 +87,13 @@ export const useEditArtifact = (
|
||||||
console.warn(
|
console.warn(
|
||||||
'Edited Artifact Message not found in cache, trying `new` as `conversationId`',
|
'Edited Artifact Message not found in cache, trying `new` as `conversationId`',
|
||||||
);
|
);
|
||||||
setMessageData(Constants.NEW_CONVO);
|
setMessageData(Constants.NEW_CONVO as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess?.(data, vars, context);
|
onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return useMutation(mutationOptions);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
|
|
@ -0,0 +1,693 @@
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { Constants } from 'librechat-data-provider';
|
||||||
|
import type { Artifact } from '~/common';
|
||||||
|
|
||||||
|
/** Mock dependencies */
|
||||||
|
jest.mock('~/Providers', () => ({
|
||||||
|
useArtifactsContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
logger: {
|
||||||
|
log: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Mock store before importing */
|
||||||
|
jest.mock('~/store', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
artifactsState: { key: 'artifactsState' },
|
||||||
|
currentArtifactId: { key: 'currentArtifactId' },
|
||||||
|
artifactsVisibility: { key: 'artifactsVisibility' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('recoil', () => {
|
||||||
|
const actualRecoil = jest.requireActual('recoil');
|
||||||
|
return {
|
||||||
|
...actualRecoil,
|
||||||
|
useRecoilValue: jest.fn(),
|
||||||
|
useRecoilState: jest.fn(),
|
||||||
|
useResetRecoilState: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Import mocked functions after mocking */
|
||||||
|
import { useArtifactsContext } from '~/Providers';
|
||||||
|
import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
import useArtifacts from '../useArtifacts';
|
||||||
|
|
||||||
|
describe('useArtifacts', () => {
|
||||||
|
const mockResetArtifacts = jest.fn();
|
||||||
|
const mockResetCurrentArtifactId = jest.fn();
|
||||||
|
const mockSetCurrentArtifactId = jest.fn();
|
||||||
|
|
||||||
|
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
|
||||||
|
id: 'artifact-1',
|
||||||
|
title: 'Test Artifact',
|
||||||
|
type: 'application/vnd.react',
|
||||||
|
content: 'const App = () => <div>Test</div>',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
lastUpdateTime: Date.now(),
|
||||||
|
...partial,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultContext = {
|
||||||
|
isSubmitting: false,
|
||||||
|
latestMessageId: 'msg-1',
|
||||||
|
latestMessageText: '',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue(defaultContext);
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||||
|
(useResetRecoilState as jest.Mock).mockImplementation((atom) => {
|
||||||
|
if (atom?.key === 'artifactsState') {
|
||||||
|
return mockResetArtifacts;
|
||||||
|
}
|
||||||
|
if (atom?.key === 'currentArtifactId') {
|
||||||
|
return mockResetCurrentArtifactId;
|
||||||
|
}
|
||||||
|
return jest.fn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should initialize with preview tab active', () => {
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null currentArtifact when no artifacts exist', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
expect(result.current.currentArtifact).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty orderedArtifactIds when no artifacts exist', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('artifact ordering', () => {
|
||||||
|
it('should order artifacts by lastUpdateTime', () => {
|
||||||
|
const artifacts = {
|
||||||
|
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||||
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||||
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.orderedArtifactIds).toEqual(['artifact-1', 'artifact-2', 'artifact-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically select latest artifact', () => {
|
||||||
|
const artifacts = {
|
||||||
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||||
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||||
|
|
||||||
|
renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tab switching - enclosed artifacts', () => {
|
||||||
|
it('should switch to preview when enclosed artifact is detected during generation', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: false,
|
||||||
|
latestMessageText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
/** Generation starts with enclosed artifact */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
/** Should switch to preview when enclosed detected */
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not switch to preview if artifact is not enclosed', () => {
|
||||||
|
const artifact = createArtifact({
|
||||||
|
content: 'const App = () => <div>Test</div>',
|
||||||
|
});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
/** Update with non-enclosed artifact */
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
/** Should switch to code since artifact content is in message and not enclosed */
|
||||||
|
expect(result.current.activeTab).toBe('code');
|
||||||
|
expect(logger.log).not.toHaveBeenCalledWith(
|
||||||
|
'artifacts',
|
||||||
|
expect.stringContaining('Enclosed artifact'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only switch to preview once per artifact', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
|
||||||
|
const { rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"}\ncode\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
const firstCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||||
|
call[1]?.includes('Enclosed artifact'),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"}\ncode\n:::\nMore text',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
const secondCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||||
|
call[1]?.includes('Enclosed artifact'),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
expect(secondCallCount).toBe(firstCallCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tab switching - non-enclosed artifacts', () => {
|
||||||
|
it('should switch to code when non-enclosed artifact content appears', () => {
|
||||||
|
const artifact = createArtifact({
|
||||||
|
content: 'const App = () => <div>Test Component</div>',
|
||||||
|
});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: 'Here is the code: const App = () => <div>Test Component</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not switch to code if artifact content is not in message text', () => {
|
||||||
|
const artifact = createArtifact({
|
||||||
|
content: 'const App = () => <div>Test</div>',
|
||||||
|
});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: 'Some other text here',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('conversation changes', () => {
|
||||||
|
it('should reset artifacts when conversation changes', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
conversationId: 'conv-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||||
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset artifacts when navigating to new conversation from another conversation', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
|
||||||
|
/** Start with existing conversation (NOT Constants.NEW_CONVO) */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
conversationId: 'existing-conv',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
/** Navigate to NEW_CONVO - this should trigger the else if branch */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
conversationId: Constants.NEW_CONVO,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||||
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reset artifacts on initial render', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(mockResetArtifacts).not.toHaveBeenCalled();
|
||||||
|
expect(mockResetCurrentArtifactId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset when transitioning from null to NEW_CONVO', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
|
||||||
|
/** Start with null conversationId */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
conversationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
/** Transition to NEW_CONVO - triggers the else if branch (line 44) */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
conversationId: Constants.NEW_CONVO,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
/** Should reset because we're now on NEW_CONVO */
|
||||||
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||||
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset state flags when message ID changes', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||||
|
latestMessageId: 'msg-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
// First artifact becomes enclosed
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
|
||||||
|
// New message starts
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: 'New message',
|
||||||
|
latestMessageId: 'msg-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
// Should allow switching again for the new message
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\nnew code\n:::',
|
||||||
|
latestMessageId: 'msg-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup on unmount', () => {
|
||||||
|
it('should reset artifacts when unmounting', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||||
|
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||||
|
expect(logger.log).toHaveBeenCalledWith('artifacts_visibility', 'Unmounting artifacts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('manual tab switching', () => {
|
||||||
|
it('should allow manually switching tabs', () => {
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setActiveTab('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow switching back to preview after manual switch to code', () => {
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setActiveTab('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('code');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setActiveTab('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('currentIndex calculation', () => {
|
||||||
|
it('should return correct index for current artifact', () => {
|
||||||
|
const artifacts = {
|
||||||
|
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||||
|
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||||
|
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.currentIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -1 for non-existent artifact', () => {
|
||||||
|
const artifacts = {
|
||||||
|
'artifact-1': createArtifact({ id: 'artifact-1' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['non-existent', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.currentIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex scenarios', () => {
|
||||||
|
it('should detect and handle enclosed artifacts during generation', async () => {
|
||||||
|
/** Start fresh with enclosed artifact already present */
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Component"}\nconst App = () => <div>Test</div>\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
/** Should detect enclosed pattern and be on preview */
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple artifacts in sequence', () => {
|
||||||
|
const artifact1 = createArtifact({ id: 'artifact-1', messageId: 'msg-1' });
|
||||||
|
const artifact2 = createArtifact({ id: 'artifact-2', messageId: 'msg-2' });
|
||||||
|
|
||||||
|
/** First artifact */
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\ncode1\n:::',
|
||||||
|
latestMessageId: 'msg-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
|
||||||
|
/** Second artifact starts (new message) */
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({
|
||||||
|
'artifact-1': artifact1,
|
||||||
|
'artifact-2': artifact2,
|
||||||
|
});
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: 'Here is another one',
|
||||||
|
latestMessageId: 'msg-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
/** Second artifact becomes enclosed */
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\ncode2\n:::',
|
||||||
|
latestMessageId: 'msg-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null artifacts gracefully', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||||
|
expect(result.current.currentArtifact).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined artifacts gracefully', () => {
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||||
|
expect(result.current.currentArtifact).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty latestMessageText', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed artifact syntax', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact\ncode but no closing',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle artifact with only opening tag', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('artifact content comparison', () => {
|
||||||
|
it('should not switch tabs when artifact content does not change', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: 'Some text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
const initialTab = result.current.activeTab;
|
||||||
|
|
||||||
|
/** Same content, just rerender */
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe(initialTab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSubmitting state handling', () => {
|
||||||
|
it('should process when isSubmitting is true', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(mockSetCurrentArtifactId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still select latest artifact even when idle (via orderedArtifactIds effect)', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: false,
|
||||||
|
latestMessageText: 'Some text',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
/** The orderedArtifactIds effect always runs when artifacts change */
|
||||||
|
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not process when latestMessageId is null', () => {
|
||||||
|
const artifact = createArtifact({});
|
||||||
|
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||||
|
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||||
|
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageId: null,
|
||||||
|
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
/** Main effect should exit early and not switch tabs */
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regex pattern matching', () => {
|
||||||
|
it('should match artifact with title attribute', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="My Component"}\ncode\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match artifact with multiple attributes', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test" type="react" identifier="comp-1"}\ncode\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match artifact with code blocks inside', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{}\n```typescript\nconst x = 1;\n```\n:::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match artifact with whitespace variations', () => {
|
||||||
|
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||||
|
...defaultContext,
|
||||||
|
isSubmitting: true,
|
||||||
|
latestMessageText: ':::artifact{title="Test"} \n\n code here \n\n :::',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useArtifacts());
|
||||||
|
|
||||||
|
expect(result.current.activeTab).toBe('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useMemo, useState, useEffect, useRef } from 'react';
|
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
|
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||||
import { logger } from '~/utils';
|
|
||||||
import { useArtifactsContext } from '~/Providers';
|
import { useArtifactsContext } from '~/Providers';
|
||||||
import { getKey } from '~/utils/artifacts';
|
import { logger } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function useArtifacts() {
|
export default function useArtifacts() {
|
||||||
|
|
@ -22,6 +21,7 @@ export default function useArtifacts() {
|
||||||
);
|
);
|
||||||
}, [artifacts]);
|
}, [artifacts]);
|
||||||
|
|
||||||
|
const prevIsSubmittingRef = useRef<boolean>(false);
|
||||||
const lastContentRef = useRef<string | null>(null);
|
const lastContentRef = useRef<string | null>(null);
|
||||||
const hasEnclosedArtifactRef = useRef<boolean>(false);
|
const hasEnclosedArtifactRef = useRef<boolean>(false);
|
||||||
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
|
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
|
||||||
|
|
@ -36,6 +36,7 @@ export default function useArtifacts() {
|
||||||
lastRunMessageIdRef.current = null;
|
lastRunMessageIdRef.current = null;
|
||||||
lastContentRef.current = null;
|
lastContentRef.current = null;
|
||||||
hasEnclosedArtifactRef.current = false;
|
hasEnclosedArtifactRef.current = false;
|
||||||
|
hasAutoSwitchedToCodeRef.current = false;
|
||||||
};
|
};
|
||||||
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
|
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
|
||||||
resetState();
|
resetState();
|
||||||
|
|
@ -57,8 +58,17 @@ export default function useArtifacts() {
|
||||||
}
|
}
|
||||||
}, [setCurrentArtifactId, orderedArtifactIds]);
|
}, [setCurrentArtifactId, orderedArtifactIds]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage artifact selection and code tab switching for non-enclosed artifacts
|
||||||
|
* Runs when artifact content changes
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSubmitting) {
|
// Check if we just finished submitting (transition from true to false)
|
||||||
|
const justFinishedSubmitting = prevIsSubmittingRef.current && !isSubmitting;
|
||||||
|
prevIsSubmittingRef.current = isSubmitting;
|
||||||
|
|
||||||
|
// Only process during submission OR when just finished
|
||||||
|
if (!isSubmitting && !justFinishedSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (orderedArtifactIds.length === 0) {
|
if (orderedArtifactIds.length === 0) {
|
||||||
|
|
@ -69,23 +79,15 @@ export default function useArtifacts() {
|
||||||
}
|
}
|
||||||
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
|
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
|
||||||
const latestArtifact = artifacts?.[latestArtifactId];
|
const latestArtifact = artifacts?.[latestArtifactId];
|
||||||
if (latestArtifact?.content === lastContentRef.current) {
|
if (latestArtifact?.content === lastContentRef.current && !justFinishedSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentArtifactId(latestArtifactId);
|
setCurrentArtifactId(latestArtifactId);
|
||||||
lastContentRef.current = latestArtifact?.content ?? null;
|
lastContentRef.current = latestArtifact?.content ?? null;
|
||||||
|
|
||||||
const hasEnclosedArtifact =
|
// Only switch to code tab if we haven't detected an enclosed artifact yet
|
||||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
||||||
latestMessageText.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) {
|
|
||||||
setActiveTab('preview');
|
|
||||||
hasEnclosedArtifactRef.current = true;
|
|
||||||
hasAutoSwitchedToCodeRef.current = false;
|
|
||||||
} else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
|
||||||
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
|
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
|
||||||
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
|
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
|
||||||
setActiveTab('code');
|
setActiveTab('code');
|
||||||
|
|
@ -101,6 +103,28 @@ export default function useArtifacts() {
|
||||||
setCurrentArtifactId,
|
setCurrentArtifactId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for enclosed artifact pattern during message generation
|
||||||
|
* Optimized: Exits early if already detected, only checks during streaming
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSubmitting || hasEnclosedArtifactRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEnclosedArtifact =
|
||||||
|
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||||
|
latestMessageText.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasEnclosedArtifact) {
|
||||||
|
logger.log('artifacts', 'Enclosed artifact detected during generation, switching to preview');
|
||||||
|
setActiveTab('preview');
|
||||||
|
hasEnclosedArtifactRef.current = true;
|
||||||
|
hasAutoSwitchedToCodeRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isSubmitting, latestMessageText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (latestMessageId !== lastRunMessageIdRef.current) {
|
if (latestMessageId !== lastRunMessageIdRef.current) {
|
||||||
lastRunMessageIdRef.current = latestMessageId;
|
lastRunMessageIdRef.current = latestMessageId;
|
||||||
|
|
@ -112,22 +136,13 @@ export default function useArtifacts() {
|
||||||
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
|
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
|
||||||
|
|
||||||
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
|
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
|
||||||
const cycleArtifact = (direction: 'next' | 'prev') => {
|
|
||||||
let newIndex: number;
|
|
||||||
if (direction === 'next') {
|
|
||||||
newIndex = (currentIndex + 1) % orderedArtifactIds.length;
|
|
||||||
} else {
|
|
||||||
newIndex = (currentIndex - 1 + orderedArtifactIds.length) % orderedArtifactIds.length;
|
|
||||||
}
|
|
||||||
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
cycleArtifact,
|
|
||||||
currentArtifact,
|
currentArtifact,
|
||||||
orderedArtifactIds,
|
orderedArtifactIds,
|
||||||
|
setCurrentArtifactId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,7 @@
|
||||||
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
||||||
"com_ui_archive_error": "Failed to archive conversation",
|
"com_ui_archive_error": "Failed to archive conversation",
|
||||||
"com_ui_artifact_click": "Click to open",
|
"com_ui_artifact_click": "Click to open",
|
||||||
|
"com_ui_click_to_close": "Click to close",
|
||||||
"com_ui_artifacts": "Artifacts",
|
"com_ui_artifacts": "Artifacts",
|
||||||
"com_ui_artifacts_options": "Artifacts Options",
|
"com_ui_artifacts_options": "Artifacts Options",
|
||||||
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
|
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
|
||||||
|
|
@ -1292,6 +1293,7 @@
|
||||||
"com_ui_verify": "Verify",
|
"com_ui_verify": "Verify",
|
||||||
"com_ui_version_var": "Version {{0}}",
|
"com_ui_version_var": "Version {{0}}",
|
||||||
"com_ui_versions": "Versions",
|
"com_ui_versions": "Versions",
|
||||||
|
"com_ui_change_version": "Change Version",
|
||||||
"com_ui_view_memory": "View Memory",
|
"com_ui_view_memory": "View Memory",
|
||||||
"com_ui_view_source": "View source chat",
|
"com_ui_view_source": "View source chat",
|
||||||
"com_ui_web_search": "Web Search",
|
"com_ui_web_search": "Web Search",
|
||||||
|
|
|
||||||
|
|
@ -2715,6 +2715,7 @@ html {
|
||||||
.animate-pulse-slow {
|
.animate-pulse-slow {
|
||||||
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -2730,10 +2731,6 @@ html {
|
||||||
animation: fadeIn 0.5s ease-out forwards;
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-98 {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Badges Animation */
|
/* Chat Badges Animation */
|
||||||
|
|
||||||
@keyframes ios-wiggle {
|
@keyframes ios-wiggle {
|
||||||
|
|
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -524,7 +524,7 @@
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-lazy-load-image-component": "^1.6.0",
|
"react-lazy-load-image-component": "^1.6.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.11.2",
|
"react-router-dom": "^6.11.2",
|
||||||
"react-speech-recognition": "^3.10.0",
|
"react-speech-recognition": "^3.10.0",
|
||||||
"react-textarea-autosize": "^8.4.0",
|
"react-textarea-autosize": "^8.4.0",
|
||||||
|
|
@ -40762,9 +40762,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-resizable-panels": {
|
"node_modules/react-resizable-panels": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
|
||||||
"integrity": "sha512-7HA8THVBHTzhDK4ON0tvlGXyMAJN1zBeRpuyyremSikgYh2ku6ltD7tsGQOcXx4NKPrZtYCm/5CBr+dkruTGQw==",
|
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -46591,7 +46591,7 @@
|
||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-alias": "^5.1.0",
|
"@rollup/plugin-alias": "^5.1.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.2",
|
"@rollup/plugin-commonjs": "^25.0.2",
|
||||||
|
|
@ -46660,7 +46660,7 @@
|
||||||
"react-dom": "^18.2.0 || ^19.1.0",
|
"react-dom": "^18.2.0 || ^19.1.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-i18next": "^15.4.0 || ^15.6.0",
|
"react-i18next": "^15.4.0 || ^15.6.0",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-textarea-autosize": "^8.4.0",
|
"react-textarea-autosize": "^8.4.0",
|
||||||
"tailwind-merge": "^1.9.1"
|
"tailwind-merge": "^1.9.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "React components for LibreChat",
|
"description": "React components for LibreChat",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.es.js",
|
"module": "dist/index.es.js",
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
"react-dom": "^18.2.0 || ^19.1.0",
|
"react-dom": "^18.2.0 || ^19.1.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-i18next": "^15.4.0 || ^15.6.0",
|
"react-i18next": "^15.4.0 || ^15.6.0",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-textarea-autosize": "^8.4.0",
|
"react-textarea-autosize": "^8.4.0",
|
||||||
"tailwind-merge": "^1.9.1"
|
"tailwind-merge": "^1.9.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,23 @@ export default function Badge({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWhileTapScale = () => {
|
||||||
|
if (isDragging) {
|
||||||
|
return 1.1;
|
||||||
|
}
|
||||||
|
if (isDisabled) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0.97;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
|
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
|
||||||
'border border-border-medium text-sm font-medium transition-shadow md:w-full',
|
'border border-border-medium text-sm font-medium transition-shadow',
|
||||||
'size-9 p-2 md:p-3',
|
'@container-[600px]:w-full size-9 p-2',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-surface-active shadow-md'
|
? 'bg-surface-active shadow-md'
|
||||||
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
|
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||||
|
|
@ -72,16 +82,23 @@ export default function Badge({
|
||||||
scale: isDragging ? 1.1 : 1,
|
scale: isDragging ? 1.1 : 1,
|
||||||
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
|
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
|
||||||
}}
|
}}
|
||||||
whileTap={{ scale: isDragging ? 1.1 : isDisabled ? 1 : 0.97 }}
|
whileTap={{ scale: getWhileTapScale() }}
|
||||||
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
||||||
{...(props as React.ComponentProps<typeof motion.button>)}
|
{...(props as React.ComponentProps<typeof motion.button>)}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className={cn('relative h-5 w-5 md:h-4 md:w-4', !label && 'mx-auto')} />}
|
{Icon && (
|
||||||
<span className="relative hidden md:inline">{label}</span>
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'@container-[600px]:h-4 @container-[600px]:w-4 relative h-5 w-5',
|
||||||
|
!label && 'mx-auto',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="@container-[600px]:inline relative hidden">{label}</span>
|
||||||
|
|
||||||
{isEditing && !isDragging && (
|
{isEditing && !isDragging && (
|
||||||
<motion.button
|
<motion.button
|
||||||
className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm md:h-5 md:w-5"
|
className="@container-[600px]:h-5 @container-[600px]:w-5 absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useLocalize } from '~/hooks';
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RadioProps {
|
interface RadioProps {
|
||||||
|
|
@ -11,9 +12,18 @@ interface RadioProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Radio = memo(function Radio({ options, value, onChange, disabled = false }: RadioProps) {
|
const Radio = memo(function Radio({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
|
fullWidth = false,
|
||||||
|
}: RadioProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
|
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
|
||||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
@ -67,7 +77,10 @@ const Radio = memo(function Radio({ options, value, onChange, disabled = false }
|
||||||
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
|
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-flex items-center rounded-lg bg-muted p-1" role="radiogroup">
|
<div
|
||||||
|
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted p-1 ${className}`}
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
{selectedIndex >= 0 && (
|
{selectedIndex >= 0 && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
|
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
|
||||||
|
|
@ -85,10 +98,11 @@ const Radio = memo(function Radio({ options, value, onChange, disabled = false }
|
||||||
aria-checked={currentValue === option.value}
|
aria-checked={currentValue === option.value}
|
||||||
onClick={() => handleChange(option.value)}
|
onClick={() => handleChange(option.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative z-10 flex h-[34px] items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
className={`relative z-10 flex h-[34px] items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
currentValue === option.value ? 'text-foreground' : 'text-foreground'
|
currentValue === option.value ? 'text-foreground' : 'text-foreground'
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${fullWidth ? 'flex-1' : ''}`}
|
||||||
>
|
>
|
||||||
|
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
|
||||||
<span className="whitespace-nowrap">{option.label}</span>
|
<span className="whitespace-nowrap">{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ const ResizableHandleAlt = ({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{withHandle && (
|
{withHandle && (
|
||||||
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible">
|
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible group-data-[resize-handle-active]:visible">
|
||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const messages = (params: q.MessagesListParams) => {
|
||||||
return `${messagesRoot}${buildQuery(rest)}`;
|
return `${messagesRoot}${buildQuery(rest)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifacts/${messageId}`;
|
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifact/${messageId}`;
|
||||||
|
|
||||||
const shareRoot = `${BASE_URL}/api/share`;
|
const shareRoot = `${BASE_URL}/api/share`;
|
||||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue