Compare commits
14 Commits
main
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc585ddaa | ||
|
|
7d46a4fd08 | ||
|
|
ede95e9e70 | ||
|
|
75f1b4cfff | ||
|
|
fe41a996e7 | ||
|
|
3c02a7b2e8 | ||
|
|
6fb76c7d60 | ||
|
|
cf9de4d4a5 | ||
|
|
a7dc109856 | ||
|
|
7e1d02bcc3 | ||
|
|
44fa479bd4 | ||
|
|
29b8314870 | ||
|
|
2a5a3fe508 | ||
|
|
0ee5712df1 |
@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType, logger } from '~/utils';
|
||||
import { cn, getFileType, logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const location = useLocation();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const isSelected = artifact?.id === currentArtifactId;
|
||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||
|
||||
const debouncedSetVisibleRef = useRef(
|
||||
@@ -54,35 +55,54 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
{(() => {
|
||||
const handleClick = () => {
|
||||
if (!location.pathname.includes('/c/')) return;
|
||||
|
||||
if (isSelected) {
|
||||
resetCurrentArtifactId();
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resetCurrentArtifactId();
|
||||
setVisible(true);
|
||||
|
||||
if (artifacts?.[artifact.id] == null) {
|
||||
setArtifacts(visibleArtifacts);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentArtifactId(artifact.id);
|
||||
}, 15);
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
||||
>
|
||||
<div className="w-fit bg-surface-tertiary 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">
|
||||
{localize('com_ui_artifact_click')}
|
||||
};
|
||||
|
||||
const buttonClass = cn(
|
||||
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
|
||||
{
|
||||
'border-border-medium bg-surface-hover shadow-lg': isSelected,
|
||||
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
|
||||
},
|
||||
);
|
||||
|
||||
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>
|
||||
</button>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { KeyBinding } from '@codemirror/view';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import {
|
||||
useSandpack,
|
||||
SandpackCodeEditor,
|
||||
@@ -116,6 +118,8 @@ const CodeEditor = ({
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
extensions={[autocompletion()]}
|
||||
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
import React, { memo, useMemo, type MutableRefObject } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type {
|
||||
SandpackProviderProps,
|
||||
SandpackPreviewRef,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
@@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
|
||||
fileKey: string;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
previewRef: MutableRefObject<SandpackPreviewRef>;
|
||||
currentCode?: string;
|
||||
startupConfig?: TStartupConfig;
|
||||
}) {
|
||||
@@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
|
||||
}
|
||||
return {
|
||||
...files,
|
||||
[fileKey]: {
|
||||
code,
|
||||
},
|
||||
[fileKey]: { code },
|
||||
};
|
||||
}, [currentCode, files, fileKey]);
|
||||
|
||||
@@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
|
||||
if (!startupConfig) {
|
||||
return sharedOptions;
|
||||
}
|
||||
const _options: typeof sharedOptions = {
|
||||
return {
|
||||
...sharedOptions,
|
||||
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
||||
};
|
||||
|
||||
return _options;
|
||||
}, [startupConfig, template]);
|
||||
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
@@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
|
||||
|
||||
return (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
...artifactFiles,
|
||||
...sharedFiles,
|
||||
}}
|
||||
files={{ ...artifactFiles, ...sharedFiles }}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
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 { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
@@ -23,6 +24,7 @@ export default function ArtifactTabs({
|
||||
const { currentCode, setCurrentCode } = useEditorContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.id !== lastIdRef.current) {
|
||||
setCurrentCode(undefined);
|
||||
@@ -33,14 +35,20 @@ export default function ArtifactTabs({
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
|
||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Tabs.Content
|
||||
ref={contentRef}
|
||||
value="code"
|
||||
id="artifacts-code"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
className={cn(
|
||||
'h-full w-full flex-grow overflow-auto',
|
||||
'data-[state=active]:duration-200 data-[state=active]:animate-in data-[state=active]:fade-in-0',
|
||||
'data-[state=inactive]:duration-150 data-[state=inactive]:animate-out data-[state=inactive]:fade-out-0',
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactCodeEditor
|
||||
@@ -52,7 +60,16 @@ export default function ArtifactTabs({
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||
|
||||
<Tabs.Content
|
||||
value="preview"
|
||||
className={cn(
|
||||
'h-full w-full flex-grow overflow-auto',
|
||||
'data-[state=active]:duration-200 data-[state=active]:animate-in data-[state=active]:fade-in-0',
|
||||
'data-[state=inactive]:duration-150 data-[state=inactive]:animate-out data-[state=inactive]:fade-out-0',
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
@@ -63,6 +80,6 @@ export default function ArtifactTabs({
|
||||
startupConfig={startupConfig}
|
||||
/>
|
||||
</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,149 +1,91 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
||||
import { Code, Play } from 'lucide-react';
|
||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
import MobileArtifacts from './MobileArtifacts';
|
||||
import DesktopArtifacts from './DesktopArtifacts';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { CopyCodeButton } from './Code';
|
||||
import type { TabOption } from './ArtifactsTypes';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Artifacts() {
|
||||
const localize = useLocalize();
|
||||
const { isMutating } = useEditorContext();
|
||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||
const editorRef = useRef<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
const tabOptions: TabOption[] = [
|
||||
{
|
||||
value: 'code',
|
||||
label: localize('com_ui_code'),
|
||||
icon: <Code className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: 'preview',
|
||||
label: localize('com_ui_preview'),
|
||||
icon: <Play className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
} = useArtifacts();
|
||||
|
||||
if (currentArtifact === null || currentArtifact === undefined) {
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!currentArtifact || !isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const client = previewRef.current?.getClient();
|
||||
if (client != null) {
|
||||
if (client) {
|
||||
client.dispatch({ type: 'refresh' });
|
||||
}
|
||||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
const closeArtifacts = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
const handleClose = () => {
|
||||
if (isMobile) {
|
||||
setArtifactsVisible(false);
|
||||
} else {
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
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`,
|
||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
||||
<div className="flex items-center">
|
||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Refresh button */}
|
||||
{activeTab === 'preview' && (
|
||||
<button
|
||||
className={cn(
|
||||
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
|
||||
isRefreshing ? 'rotate-180' : '',
|
||||
)}
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
disabled={isMutating}
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
{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 ?? ''} />
|
||||
{/* Download Button */}
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
{/* Publish button */}
|
||||
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
|
||||
Publish
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
const sharedProps = {
|
||||
currentArtifact,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
editorRef: editorRef as React.MutableRefObject<CodeEditorRef>,
|
||||
previewRef: previewRef as React.MutableRefObject<SandpackPreviewRef>,
|
||||
isMutating,
|
||||
onClose: handleClose,
|
||||
onRefresh: handleRefresh,
|
||||
isRefreshing,
|
||||
tabOptions,
|
||||
};
|
||||
|
||||
return isMobile ? <MobileArtifacts {...sharedProps} /> : <DesktopArtifacts {...sharedProps} />;
|
||||
}
|
||||
|
||||
104
client/src/components/Artifacts/ArtifactsHeader.tsx
Normal file
104
client/src/components/Artifacts/ArtifactsHeader.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { Button, Spinner, Radio } from '@librechat/client';
|
||||
import type { TabOption } from './ArtifactsTypes';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
import ArtifactVersion from './ArtifactVersion';
|
||||
import { CopyCodeButton } from './Code';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
interface ArtifactsHeaderProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
currentIndex: number;
|
||||
orderedArtifactIds: string[];
|
||||
setCurrentArtifactId: (id: string) => void;
|
||||
currentArtifact: Artifact;
|
||||
isMutating: boolean;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onClose: () => void;
|
||||
isMobile?: boolean;
|
||||
tabOptions: TabOption[];
|
||||
}
|
||||
|
||||
export default function ArtifactsHeader({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
currentArtifact,
|
||||
isMutating,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
tabOptions,
|
||||
}: ArtifactsHeaderProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<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' : 'w-full overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="flex items-center transition-all duration-500">
|
||||
<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' : '',
|
||||
)}
|
||||
>
|
||||
{activeTab === 'preview' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={localize('com_ui_refresh')}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label={localize('com_ui_close')}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
client/src/components/Artifacts/ArtifactsTypes.ts
Normal file
23
client/src/components/Artifacts/ArtifactsTypes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
export interface ArtifactsSharedProps {
|
||||
currentArtifact: Artifact;
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
currentIndex: number;
|
||||
orderedArtifactIds: string[];
|
||||
setCurrentArtifactId: (id: string) => void;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isMutating: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export interface TabOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Button } from '@librechat/client';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||
import { handleDoubleClick, langSubset } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -107,12 +108,13 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
106
client/src/components/Artifacts/DesktopArtifacts.tsx
Normal file
106
client/src/components/Artifacts/DesktopArtifacts.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import type { ArtifactsSharedProps, TabOption } from './ArtifactsTypes';
|
||||
import ArtifactsHeader from './ArtifactsHeader';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DesktopArtifactsProps extends ArtifactsSharedProps {
|
||||
tabOptions: TabOption[];
|
||||
}
|
||||
|
||||
export default function DesktopArtifacts({
|
||||
currentArtifact,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isMutating,
|
||||
onClose,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
tabOptions,
|
||||
}: DesktopArtifactsProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 30);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col bg-surface-primary text-xl text-text-primary shadow-2xl',
|
||||
isVisible && !isClosing
|
||||
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||
)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn('flex items-center transition-all duration-500')}>
|
||||
<ArtifactsHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
currentIndex={currentIndex}
|
||||
orderedArtifactIds={orderedArtifactIds}
|
||||
setCurrentArtifactId={setCurrentArtifactId}
|
||||
currentArtifact={currentArtifact}
|
||||
isMutating={isMutating}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onClose={handleClose}
|
||||
isMobile={false}
|
||||
tabOptions={tabOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
previewRef={previewRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Refresh overlay */}
|
||||
<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>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, CircleCheckBig } from 'lucide-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { CheckMark } from '@librechat/client';
|
||||
import { Button } from '@librechat/client';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DownloadArtifact = ({
|
||||
artifact,
|
||||
className = '',
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
className?: string;
|
||||
}) => {
|
||||
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
|
||||
const localize = useLocalize();
|
||||
const { currentCode } = useEditorContext();
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
@@ -41,13 +35,14 @@ const DownloadArtifact = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`mr-2 text-text-secondary ${className}`}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleDownload}
|
||||
aria-label={localize('com_ui_download_artifact')}
|
||||
>
|
||||
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
</button>
|
||||
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
215
client/src/components/Artifacts/MobileArtifacts.tsx
Normal file
215
client/src/components/Artifacts/MobileArtifacts.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Spinner, Radio } from '@librechat/client';
|
||||
import type { ArtifactsSharedProps, TabOption } from './ArtifactsTypes';
|
||||
import ArtifactsHeader from './ArtifactsHeader';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const MAX_BLUR_AMOUNT = 32;
|
||||
const MAX_BACKDROP_OPACITY = 0.3;
|
||||
|
||||
interface MobileArtifactsProps extends ArtifactsSharedProps {
|
||||
tabOptions: TabOption[];
|
||||
}
|
||||
|
||||
export default function MobileArtifacts({
|
||||
currentArtifact,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isMutating,
|
||||
onClose,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
tabOptions,
|
||||
}: MobileArtifactsProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = 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);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
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) {
|
||||
handleClose();
|
||||
} else if (height > 95) {
|
||||
setHeight(100);
|
||||
} else if (height < 60) {
|
||||
setHeight(50);
|
||||
} else {
|
||||
setHeight(90);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
setHeight(90);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
blurAmount > 0
|
||||
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Mobile backdrop with dynamic blur */}
|
||||
<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 ? handleClose : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-[100] flex w-full flex-col rounded-t-[20px] bg-surface-primary text-xl text-text-primary 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',
|
||||
)}
|
||||
style={{ height: `${height}vh` }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<ArtifactsHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
currentIndex={currentIndex}
|
||||
orderedArtifactIds={orderedArtifactIds}
|
||||
setCurrentArtifactId={setCurrentArtifactId}
|
||||
currentArtifact={currentArtifact}
|
||||
isMutating={isMutating}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onClose={handleClose}
|
||||
isMobile={true}
|
||||
tabOptions={tabOptions}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
previewRef={previewRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Refresh overlay */}
|
||||
<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>
|
||||
|
||||
{/* Bottom tabs */}
|
||||
<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>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { normalizeLayout } from '~/utils';
|
||||
import SidePanel from './SidePanel';
|
||||
import store from '~/store';
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
|
||||
interface SidePanelProps {
|
||||
defaultLayout?: number[] | undefined;
|
||||
defaultCollapsed?: boolean;
|
||||
@@ -42,14 +44,43 @@ const SidePanelGroup = memo(
|
||||
);
|
||||
|
||||
const panelRef = useRef<ImperativePanelHandle>(null);
|
||||
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const [minSize, setMinSize] = useState(defaultMinSize);
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
|
||||
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
|
||||
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
|
||||
const artifactsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifacts != null) {
|
||||
if (artifactsTimeoutRef.current) {
|
||||
clearTimeout(artifactsTimeoutRef.current);
|
||||
artifactsTimeoutRef.current = null;
|
||||
}
|
||||
setShouldRenderArtifacts(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
artifactsPanelRef.current?.expand();
|
||||
});
|
||||
});
|
||||
} else if (shouldRenderArtifacts) {
|
||||
artifactsPanelRef.current?.collapse();
|
||||
artifactsTimeoutRef.current = setTimeout(() => {
|
||||
setShouldRenderArtifacts(false);
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (artifactsTimeoutRef.current) {
|
||||
clearTimeout(artifactsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [artifacts, shouldRenderArtifacts]);
|
||||
|
||||
const calculateLayout = useCallback(() => {
|
||||
if (artifacts == null) {
|
||||
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
|
||||
@@ -109,26 +140,35 @@ const SidePanelGroup = memo(
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
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
|
||||
defaultSize={currentLayout[0]}
|
||||
minSize={minSizeMain}
|
||||
order={1}
|
||||
id="messages-view"
|
||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
|
||||
>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
{artifacts != null && (
|
||||
{shouldRenderArtifacts && !isSmallScreen && (
|
||||
<>
|
||||
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
|
||||
{artifacts != null && (
|
||||
<ResizableHandleAlt
|
||||
withHandle
|
||||
className="ml-3 bg-border-medium text-text-primary"
|
||||
/>
|
||||
)}
|
||||
<ResizablePanel
|
||||
defaultSize={currentLayout[1]}
|
||||
ref={artifactsPanelRef}
|
||||
defaultSize={artifacts != null ? currentLayout[1] : 0}
|
||||
minSize={minSizeMain}
|
||||
collapsible={true}
|
||||
collapsedSize={0}
|
||||
order={2}
|
||||
id="artifacts-panel"
|
||||
>
|
||||
{artifacts}
|
||||
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
@@ -149,6 +189,9 @@ const SidePanelGroup = memo(
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
{artifacts != null && isSmallScreen && (
|
||||
<div className="fixed inset-0 z-[100]">{artifacts}</div>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close right side panel"
|
||||
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
|
||||
|
||||
@@ -129,5 +129,6 @@ export default function useArtifacts() {
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -689,6 +689,7 @@
|
||||
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
||||
"com_ui_archive_error": "Failed to archive conversation",
|
||||
"com_ui_artifact_click": "Click to open",
|
||||
"com_ui_click_to_close": "Click to close",
|
||||
"com_ui_artifacts": "Artifacts",
|
||||
"com_ui_artifacts_options": "Artifacts Options",
|
||||
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
|
||||
@@ -1263,6 +1264,7 @@
|
||||
"com_ui_verify": "Verify",
|
||||
"com_ui_version_var": "Version {{0}}",
|
||||
"com_ui_versions": "Versions",
|
||||
"com_ui_change_version": "Change Version",
|
||||
"com_ui_view_memory": "View Memory",
|
||||
"com_ui_view_source": "View source chat",
|
||||
"com_ui_web_search": "Web Search",
|
||||
|
||||
@@ -2715,6 +2715,8 @@ html {
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* iOS-inspired smooth animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -2730,8 +2732,29 @@ html {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.scale-98 {
|
||||
transform: scale(0.98);
|
||||
|
||||
/* Prevent content flash and layout shifts in artifacts */
|
||||
[data-radix-scroll-area-viewport] {
|
||||
/* Prevent scrollbar from causing layout shifts */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Ensure smooth tab content transitions */
|
||||
[role="tabpanel"] {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
|
||||
/* Prevent flash of content during mounting */
|
||||
[role="tabpanel"][data-state="inactive"] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[role="tabpanel"][data-state="active"] {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Chat Badges Animation */
|
||||
|
||||
@@ -47,6 +47,16 @@ module.exports = {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
'thinking-appear': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'scale(0.9) translateY(4px)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'scale(1) translateY(0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
@@ -56,6 +66,12 @@ module.exports = {
|
||||
'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'thinking-appear': 'thinking-appear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
ios: 'cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'ios-spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'ios-decelerate': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -51458,7 +51458,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "React components for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocalize } from '~/hooks';
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface RadioProps {
|
||||
@@ -11,9 +12,18 @@ interface RadioProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
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 [currentValue, setCurrentValue] = useState<string>(value ?? '');
|
||||
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);
|
||||
|
||||
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 && (
|
||||
<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"
|
||||
@@ -85,10 +98,11 @@ const Radio = memo(function Radio({ options, value, onChange, disabled = false }
|
||||
aria-checked={currentValue === option.value}
|
||||
onClick={() => handleChange(option.value)}
|
||||
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'
|
||||
} ${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>
|
||||
</button>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user