mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🤝 feat: View Artifacts in Shared Conversations (#10477)
* feat: Integrate logger for MessageIcon component * feat: Enhance artifact sharing functionality with updated path checks and read-only state management * feat: Refactor Thinking and Reasoning components for improved structure and styling * feat: Enhance artifact sharing with context value management and responsive layout * feat: Enhance ShareView with theme and language management features * feat: Improve ThinkingButton accessibility and styling for better user interaction * feat: Introduce isArtifactRoute utility for route validation in Artifact components * feat: Add latest message text extraction in SharedView for improved message display * feat: Update locale handling in SharedView for dynamic date formatting * feat: Refactor ArtifactsContext and SharedView for improved context handling and styling adjustments * feat: Enhance artifact panel size management with local storage integration * chore: imports * refactor: move ShareArtifactsContainer out of ShareView --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
cabc8afeac
commit
c2505d2bc9
15 changed files with 443 additions and 73 deletions
|
|
@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useChatContext } from './ChatContext';
|
import { useChatContext } from './ChatContext';
|
||||||
import { getLatestText } from '~/utils';
|
import { getLatestText } from '~/utils';
|
||||||
|
|
||||||
interface ArtifactsContextValue {
|
export interface ArtifactsContextValue {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
latestMessageId: string | null;
|
latestMessageId: string | null;
|
||||||
latestMessageText: string;
|
latestMessageText: string;
|
||||||
|
|
@ -12,10 +12,15 @@ interface ArtifactsContextValue {
|
||||||
|
|
||||||
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
|
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
interface ArtifactsProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value?: Partial<ArtifactsContextValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
||||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
||||||
|
|
||||||
const latestMessageText = useMemo(() => {
|
const chatLatestMessageText = useMemo(() => {
|
||||||
return getLatestText({
|
return getLatestText({
|
||||||
messageId: latestMessage?.messageId ?? null,
|
messageId: latestMessage?.messageId ?? null,
|
||||||
text: latestMessage?.text ?? null,
|
text: latestMessage?.text ?? null,
|
||||||
|
|
@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
||||||
} as TMessage);
|
} as TMessage);
|
||||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||||
|
|
||||||
/** Context value only created when relevant values change */
|
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
||||||
const contextValue = useMemo<ArtifactsContextValue>(
|
|
||||||
() => ({
|
() => ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
latestMessageText,
|
latestMessageText: chatLatestMessageText,
|
||||||
latestMessageId: latestMessage?.messageId ?? null,
|
latestMessageId: latestMessage?.messageId ?? null,
|
||||||
conversationId: conversation?.conversationId ?? null,
|
conversationId: conversation?.conversationId ?? null,
|
||||||
}),
|
}),
|
||||||
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
|
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Context value only created when relevant values change */
|
||||||
|
const contextValue = useMemo<ArtifactsContextValue>(
|
||||||
|
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
|
||||||
|
[defaultContextValue, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
|
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
|
||||||
import type { Pluggable } from 'unified';
|
import type { Pluggable } from 'unified';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||||
|
import { logger, extractContent, isArtifactRoute } from '~/utils';
|
||||||
import { artifactsState } from '~/store/artifacts';
|
import { artifactsState } from '~/store/artifacts';
|
||||||
import { logger, extractContent } from '~/utils';
|
|
||||||
import ArtifactButton from './ArtifactButton';
|
import ArtifactButton from './ArtifactButton';
|
||||||
|
|
||||||
export const artifactPlugin: Pluggable = () => {
|
export const artifactPlugin: Pluggable = () => {
|
||||||
|
|
@ -88,7 +88,7 @@ export function Artifact({
|
||||||
lastUpdateTime: now,
|
lastUpdateTime: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!location.pathname.includes('/c/')) {
|
if (!isArtifactRoute(location.pathname)) {
|
||||||
return setArtifact(currentArtifact);
|
return setArtifact(currentArtifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { cn, getFileType, logger } from '~/utils';
|
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!location.pathname.includes('/c/')) {
|
if (!isArtifactRoute(location.pathname)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,8 +57,6 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||||
<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">
|
||||||
{(() => {
|
{(() => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!location.pathname.includes('/c/')) return;
|
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
resetCurrentArtifactId();
|
resetCurrentArtifactId();
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export const ArtifactCodeEditor = function ({
|
||||||
artifact,
|
artifact,
|
||||||
editorRef,
|
editorRef,
|
||||||
sharedProps,
|
sharedProps,
|
||||||
|
readOnly: externalReadOnly,
|
||||||
}: {
|
}: {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
|
|
@ -164,6 +165,7 @@ export const ArtifactCodeEditor = function ({
|
||||||
template: SandpackProviderProps['template'];
|
template: SandpackProviderProps['template'];
|
||||||
sharedProps: Partial<SandpackProviderProps>;
|
sharedProps: Partial<SandpackProviderProps>;
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data: config } = useGetStartupConfig();
|
const { data: config } = useGetStartupConfig();
|
||||||
const { isSubmitting } = useArtifactsContext();
|
const { isSubmitting } = useArtifactsContext();
|
||||||
|
|
@ -177,10 +179,10 @@ export const ArtifactCodeEditor = function ({
|
||||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||||
};
|
};
|
||||||
}, [config, template, fileKey]);
|
}, [config, template, fileKey]);
|
||||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
const [readOnly, setReadOnly] = useState(externalReadOnly ?? isSubmitting ?? false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadOnly(isSubmitting ?? false);
|
setReadOnly(externalReadOnly ?? isSubmitting ?? false);
|
||||||
}, [isSubmitting]);
|
}, [isSubmitting, externalReadOnly]);
|
||||||
|
|
||||||
if (Object.keys(files).length === 0) {
|
if (Object.keys(files).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ export default function ArtifactTabs({
|
||||||
artifact,
|
artifact,
|
||||||
editorRef,
|
editorRef,
|
||||||
previewRef,
|
previewRef,
|
||||||
|
isSharedConvo,
|
||||||
}: {
|
}: {
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
|
isSharedConvo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { isSubmitting } = useArtifactsContext();
|
const { isSubmitting } = useArtifactsContext();
|
||||||
const { currentCode, setCurrentCode } = useCodeState();
|
const { currentCode, setCurrentCode } = useCodeState();
|
||||||
|
|
@ -54,6 +56,7 @@ export default function ArtifactTabs({
|
||||||
artifact={artifact}
|
artifact={artifact}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
sharedProps={sharedProps}
|
sharedProps={sharedProps}
|
||||||
|
readOnly={isSharedConvo}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import { Code, Play, RefreshCw, X } from 'lucide-react';
|
||||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||||
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
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 { useShareContext, useMutationState } from '~/Providers';
|
||||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||||
import DownloadArtifact from './DownloadArtifact';
|
import DownloadArtifact from './DownloadArtifact';
|
||||||
import ArtifactVersion from './ArtifactVersion';
|
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';
|
||||||
|
|
@ -20,6 +20,7 @@ const MAX_BACKDROP_OPACITY = 0.3;
|
||||||
export default function Artifacts() {
|
export default function Artifacts() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { isMutating } = useMutationState();
|
const { isMutating } = useMutationState();
|
||||||
|
const { isSharedConvo } = useShareContext();
|
||||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||||
const editorRef = useRef<CodeEditorRef>();
|
const editorRef = useRef<CodeEditorRef>();
|
||||||
const previewRef = useRef<SandpackPreviewRef>();
|
const previewRef = useRef<SandpackPreviewRef>();
|
||||||
|
|
@ -294,6 +295,7 @@ export default function Artifacts() {
|
||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||||
|
isSharedConvo={isSharedConvo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,25 +68,27 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/reasoning">
|
<div className="group/reasoning">
|
||||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
<div className="group/thinking-container">
|
||||||
<ThinkingButton
|
<div className="sticky top-0 z-10 mb-2 pb-2 pt-2">
|
||||||
isExpanded={isExpanded}
|
<ThinkingButton
|
||||||
onClick={handleClick}
|
isExpanded={isExpanded}
|
||||||
label={label}
|
onClick={handleClick}
|
||||||
content={reasoningText}
|
label={label}
|
||||||
/>
|
content={reasoningText}
|
||||||
</div>
|
/>
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
<div
|
||||||
'grid transition-all duration-300 ease-out',
|
className={cn(
|
||||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
'grid transition-all duration-300 ease-out',
|
||||||
)}
|
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||||
style={{
|
)}
|
||||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
style={{
|
||||||
}}
|
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||||
>
|
}}
|
||||||
<div className="overflow-hidden">
|
>
|
||||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
<div className="overflow-hidden">
|
||||||
|
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,13 @@ export const ThinkingButton = memo(
|
||||||
onClick,
|
onClick,
|
||||||
label,
|
label,
|
||||||
content,
|
content,
|
||||||
|
showCopyButton = true,
|
||||||
}: {
|
}: {
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
label: string;
|
label: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
showCopyButton?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const fontSize = useAtomValue(fontSizeAtom);
|
const fontSize = useAtomValue(fontSizeAtom);
|
||||||
|
|
@ -59,7 +61,7 @@ export const ThinkingButton = memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
<div className="group/thinking flex w-full items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -79,7 +81,7 @@ export const ThinkingButton = memo(
|
||||||
</span>
|
</span>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{content && (
|
{content && showCopyButton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
|
@ -90,8 +92,11 @@ export const ThinkingButton = memo(
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||||
|
isExpanded
|
||||||
|
? 'opacity-0 group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100'
|
||||||
|
: 'opacity-0',
|
||||||
'hover:bg-surface-hover hover:text-text-primary',
|
'hover:bg-surface-hover hover:text-text-primary',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||||
|
|
@ -142,7 +147,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="group/thinking-container">
|
||||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||||
<ThinkingButton
|
<ThinkingButton
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
|
@ -161,7 +166,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
||||||
<ThinkingContent>{children}</ThinkingContent>
|
<ThinkingContent>{children}</ThinkingContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,16 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
<div className="visible mt-1 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||||
<button
|
<button
|
||||||
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
|
className="ml-0 flex items-center gap-1.5 rounded-lg p-1.5 text-xs text-text-secondary-alt transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
|
||||||
onClick={() => copyToClipboard(setIsCopied)}
|
onClick={() => copyToClipboard(setIsCopied)}
|
||||||
type="button"
|
type="button"
|
||||||
title={
|
title={
|
||||||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,11 @@ export const ThemeSelector = ({
|
||||||
export const LangSelector = ({
|
export const LangSelector = ({
|
||||||
langcode,
|
langcode,
|
||||||
onChange,
|
onChange,
|
||||||
|
portal = true,
|
||||||
}: {
|
}: {
|
||||||
langcode: string;
|
langcode: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
portal?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
|
@ -124,10 +126,11 @@ export const LangSelector = ({
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={langcode}
|
value={langcode}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
sizeClasses="[--anchor-max-height:256px]"
|
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||||
options={languageOptions}
|
options={languageOptions}
|
||||||
className="z-50"
|
className="z-50"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
|
portal={portal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
|
||||||
import type { TMessageProps } from '~/common';
|
import type { TMessageProps } from '~/common';
|
||||||
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
|
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
|
||||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||||
import { getIconEndpoint } from '~/utils';
|
import { getIconEndpoint, logger } from '~/utils';
|
||||||
|
|
||||||
export default function MessageIcon(
|
export default function MessageIcon(
|
||||||
props: Pick<TMessageProps, 'message' | 'conversation'> & {
|
props: Pick<TMessageProps, 'message' | 'conversation'> & {
|
||||||
|
|
@ -41,7 +41,7 @@ export default function MessageIcon(
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [assistant, agent, assistantAvatar, agentAvatar]);
|
}, [assistant, agent, assistantAvatar, agentAvatar]);
|
||||||
console.log('MessageIcon', {
|
logger.log('MessageIcon', {
|
||||||
endpoint,
|
endpoint,
|
||||||
iconURL,
|
iconURL,
|
||||||
assistantName,
|
assistantName,
|
||||||
|
|
|
||||||
176
client/src/components/Share/ShareArtifacts.tsx
Normal file
176
client/src/components/Share/ShareArtifacts.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import {
|
||||||
|
useMediaQuery,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandleAlt,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
|
import type { ArtifactsContextValue } from '~/Providers';
|
||||||
|
import { ArtifactsProvider, EditorProvider } from '~/Providers';
|
||||||
|
import Artifacts from '~/components/Artifacts/Artifacts';
|
||||||
|
import { getLatestText } from '~/utils';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
const DEFAULT_ARTIFACT_PANEL_SIZE = 40;
|
||||||
|
const SHARE_ARTIFACT_PANEL_STORAGE_KEY = 'share:artifacts-panel-size';
|
||||||
|
const SHARE_ARTIFACT_PANEL_DEFAULT_KEY = 'share:artifacts-panel-size-default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the initial artifact panel size from localStorage or returns default
|
||||||
|
*/
|
||||||
|
const getInitialArtifactPanelSize = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSizeString = String(DEFAULT_ARTIFACT_PANEL_SIZE);
|
||||||
|
const storedDefault = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY);
|
||||||
|
|
||||||
|
if (storedDefault !== defaultSizeString) {
|
||||||
|
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY, defaultSizeString);
|
||||||
|
window.localStorage.removeItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
|
||||||
|
return DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
|
||||||
|
const parsed = Number(stored);
|
||||||
|
return Number.isFinite(parsed) ? parsed : DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShareArtifactsContainerProps {
|
||||||
|
messages: TMessage[];
|
||||||
|
conversationId: string;
|
||||||
|
mainContent: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container component that manages artifact visibility and layout for shared conversations
|
||||||
|
*/
|
||||||
|
export function ShareArtifactsContainer({
|
||||||
|
messages,
|
||||||
|
conversationId,
|
||||||
|
mainContent,
|
||||||
|
}: ShareArtifactsContainerProps) {
|
||||||
|
const artifacts = useRecoilValue(store.artifactsState);
|
||||||
|
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 1023px)');
|
||||||
|
const [artifactPanelSize, setArtifactPanelSize] = useState(getInitialArtifactPanelSize);
|
||||||
|
|
||||||
|
const artifactsContextValue = useMemo<ArtifactsContextValue | null>(() => {
|
||||||
|
const latestMessage =
|
||||||
|
Array.isArray(messages) && messages.length > 0 ? messages[messages.length - 1] : null;
|
||||||
|
|
||||||
|
if (!latestMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestMessageText = getLatestText(latestMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSubmitting: false,
|
||||||
|
latestMessageId: latestMessage.messageId ?? null,
|
||||||
|
latestMessageText,
|
||||||
|
conversationId: conversationId ?? null,
|
||||||
|
};
|
||||||
|
}, [messages, conversationId]);
|
||||||
|
|
||||||
|
const shouldRenderArtifacts =
|
||||||
|
artifactsVisibility === true &&
|
||||||
|
artifactsContextValue != null &&
|
||||||
|
Object.keys(artifacts ?? {}).length > 0;
|
||||||
|
|
||||||
|
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles artifact panel resize and persists size to localStorage
|
||||||
|
*/
|
||||||
|
const handleLayoutChange = (sizes: number[]) => {
|
||||||
|
if (sizes.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSize = sizes[1];
|
||||||
|
if (!Number.isFinite(newSize)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setArtifactPanelSize(newSize);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY, newSize.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shouldRenderArtifacts || !artifactsContextValue) {
|
||||||
|
return <>{mainContent}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mainContent}
|
||||||
|
<ShareArtifactsOverlay contextValue={artifactsContextValue} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="flex h-full w-full"
|
||||||
|
onLayout={handleLayoutChange}
|
||||||
|
>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={100 - normalizedArtifactSize}
|
||||||
|
minSize={35}
|
||||||
|
order={1}
|
||||||
|
id="share-content"
|
||||||
|
>
|
||||||
|
{mainContent}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={normalizedArtifactSize}
|
||||||
|
minSize={20}
|
||||||
|
maxSize={60}
|
||||||
|
order={2}
|
||||||
|
id="share-artifacts"
|
||||||
|
>
|
||||||
|
<ShareArtifactsPanel contextValue={artifactsContextValue} />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareArtifactsPanelProps {
|
||||||
|
contextValue: ArtifactsContextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel that renders the artifacts UI within a resizable container
|
||||||
|
*/
|
||||||
|
function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) {
|
||||||
|
return (
|
||||||
|
<ArtifactsProvider value={contextValue}>
|
||||||
|
<EditorProvider>
|
||||||
|
<div className="flex h-full w-full border-l border-border-light bg-surface-primary shadow-2xl">
|
||||||
|
<Artifacts />
|
||||||
|
</div>
|
||||||
|
</EditorProvider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile overlay that displays artifacts in a fixed position
|
||||||
|
*/
|
||||||
|
function ShareArtifactsOverlay({ contextValue }: ShareArtifactsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 z-40 flex w-full max-w-full sm:max-w-[420px]"
|
||||||
|
role="complementary"
|
||||||
|
aria-label="Artifacts panel"
|
||||||
|
>
|
||||||
|
<ShareArtifactsPanel contextValue={contextValue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,42 @@
|
||||||
import { memo } from 'react';
|
import { memo, useState, useCallback, useContext } from 'react';
|
||||||
import { Spinner } from '@librechat/client';
|
import Cookies from 'js-cookie';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { buildTree } from 'librechat-data-provider';
|
import { buildTree } from 'librechat-data-provider';
|
||||||
|
import { CalendarDays, Settings } from 'lucide-react';
|
||||||
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
||||||
|
import {
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
OGDialog,
|
||||||
|
ThemeContext,
|
||||||
|
OGDialogTitle,
|
||||||
|
useMediaQuery,
|
||||||
|
OGDialogHeader,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogTrigger,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
|
||||||
|
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { ShareContext } from '~/Providers';
|
import { ShareContext } from '~/Providers';
|
||||||
import MessagesView from './MessagesView';
|
import MessagesView from './MessagesView';
|
||||||
import Footer from '../Chat/Footer';
|
import Footer from '../Chat/Footer';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
function SharedView() {
|
function SharedView() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { data: config } = useGetStartupConfig();
|
const { data: config } = useGetStartupConfig();
|
||||||
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||||
const dataTree = data && buildTree({ messages: data.messages });
|
const dataTree = data && buildTree({ messages: data.messages });
|
||||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||||
|
|
||||||
|
const [langcode, setLangcode] = useRecoilState(store.lang);
|
||||||
|
|
||||||
// configure document title
|
// configure document title
|
||||||
let docTitle = '';
|
let docTitle = '';
|
||||||
if (config?.appTitle != null && data?.title != null) {
|
if (config?.appTitle != null && data?.title != null) {
|
||||||
|
|
@ -27,6 +47,48 @@ function SharedView() {
|
||||||
|
|
||||||
useDocumentTitle(docTitle);
|
useDocumentTitle(docTitle);
|
||||||
|
|
||||||
|
const locale =
|
||||||
|
langcode ||
|
||||||
|
(typeof navigator !== 'undefined'
|
||||||
|
? navigator.language || navigator.languages?.[0] || 'en-US'
|
||||||
|
: 'en-US');
|
||||||
|
|
||||||
|
const formattedDate =
|
||||||
|
data?.createdAt != null
|
||||||
|
? new Date(data.createdAt).toLocaleDateString(locale, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleThemeChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTheme(value);
|
||||||
|
},
|
||||||
|
[setTheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLangChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
let userLang = value;
|
||||||
|
if (value === 'auto') {
|
||||||
|
userLang =
|
||||||
|
(typeof navigator !== 'undefined'
|
||||||
|
? navigator.language || navigator.languages?.[0]
|
||||||
|
: null) ?? 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.documentElement.lang = userLang;
|
||||||
|
});
|
||||||
|
|
||||||
|
setLangcode(userLang);
|
||||||
|
Cookies.set('lang', userLang, { expires: 365 });
|
||||||
|
},
|
||||||
|
[setLangcode],
|
||||||
|
);
|
||||||
|
|
||||||
let content: JSX.Element;
|
let content: JSX.Element;
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
content = (
|
content = (
|
||||||
|
|
@ -37,17 +99,15 @@ function SharedView() {
|
||||||
} else if (data && messagesTree && messagesTree.length !== 0) {
|
} else if (data && messagesTree && messagesTree.length !== 0) {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5">
|
<ShareHeader
|
||||||
<h1 className="text-4xl font-bold">{data.title}</h1>
|
title={data.title}
|
||||||
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
|
formattedDate={formattedDate}
|
||||||
{new Date(data.createdAt).toLocaleDateString('en-US', {
|
theme={theme}
|
||||||
month: 'long',
|
langcode={langcode}
|
||||||
day: 'numeric',
|
onThemeChange={handleThemeChange}
|
||||||
year: 'numeric',
|
onLangChange={handleLangChange}
|
||||||
})}
|
settingsLabel={localize('com_nav_settings')}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
|
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -59,23 +119,124 @@ function SharedView() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||||
|
<Footer className="relative mx-auto mt-4 flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-2 text-center text-xs text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainContent = (
|
||||||
|
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
||||||
|
<div className="flex h-full flex-col text-text-primary" role="presentation">
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifactsContainer =
|
||||||
|
data && data.messages ? (
|
||||||
|
<ShareArtifactsContainer
|
||||||
|
messages={data.messages}
|
||||||
|
conversationId={data.conversationId}
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
mainContent
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||||
<main
|
<div className="relative flex min-h-screen w-full dark:bg-surface-secondary">
|
||||||
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
|
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||||
style={{ paddingBottom: '50px' }}
|
{artifactsContainer}
|
||||||
>
|
</main>
|
||||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
</div>
|
||||||
<div className="flex h-full flex-col text-text-primary" role="presentation">
|
|
||||||
{content}
|
|
||||||
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
|
||||||
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</ShareContext.Provider>
|
</ShareContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ShareHeaderProps {
|
||||||
|
title?: string;
|
||||||
|
formattedDate: string | null;
|
||||||
|
theme: string;
|
||||||
|
langcode: string;
|
||||||
|
settingsLabel: string;
|
||||||
|
onThemeChange: (value: string) => void;
|
||||||
|
onLangChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareHeader({
|
||||||
|
title,
|
||||||
|
formattedDate,
|
||||||
|
theme,
|
||||||
|
langcode,
|
||||||
|
settingsLabel,
|
||||||
|
onThemeChange,
|
||||||
|
onLangChange,
|
||||||
|
}: ShareHeaderProps) {
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||||
|
|
||||||
|
const handleDialogOutside = useCallback((event: Event) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-dialog-ignore="true"]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full px-3 pb-4 pt-6 md:px-5">
|
||||||
|
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-4 rounded-3xl border border-border-light px-6 py-5 shadow-xl backdrop-blur">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-4xl font-semibold text-text-primary">{title}</h1>
|
||||||
|
{formattedDate && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<CalendarDays className="size-4" aria-hidden="true" />
|
||||||
|
<span>{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OGDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
|
<OGDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size={isMobile ? 'icon' : 'default'}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={settingsLabel}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border-border-medium text-sm text-text-primary transition-colors',
|
||||||
|
isMobile
|
||||||
|
? 'absolute bottom-4 right-4 justify-center p-0 shadow-lg'
|
||||||
|
: 'gap-2 self-start px-4 py-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" aria-hidden="true" />
|
||||||
|
<span className="hidden md:inline">{settingsLabel}</span>
|
||||||
|
</Button>
|
||||||
|
</OGDialogTrigger>
|
||||||
|
<OGDialogContent
|
||||||
|
className="w-11/12 max-w-lg"
|
||||||
|
showCloseButton={true}
|
||||||
|
onPointerDownOutside={handleDialogOutside}
|
||||||
|
onInteractOutside={handleDialogOutside}
|
||||||
|
>
|
||||||
|
<OGDialogHeader className="text-left">
|
||||||
|
<OGDialogTitle>{settingsLabel}</OGDialogTitle>
|
||||||
|
</OGDialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-2 text-sm">
|
||||||
|
<ThemeSelector theme={theme} onChange={onThemeChange} />
|
||||||
|
<div className="bg-border-medium/60 h-px w-full" />
|
||||||
|
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
|
||||||
|
</div>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(SharedView);
|
export default memo(SharedView);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export * from './forms';
|
||||||
export * from './agents';
|
export * from './agents';
|
||||||
export * from './drafts';
|
export * from './drafts';
|
||||||
export * from './convos';
|
export * from './convos';
|
||||||
|
export * from './routes';
|
||||||
export * from './presets';
|
export * from './presets';
|
||||||
export * from './prompts';
|
export * from './prompts';
|
||||||
export * from './textarea';
|
export * from './textarea';
|
||||||
|
|
|
||||||
7
client/src/utils/routes.ts
Normal file
7
client/src/utils/routes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { matchPath } from 'react-router-dom';
|
||||||
|
|
||||||
|
const matchesRouteStart = (pathname: string, pattern: string) =>
|
||||||
|
matchPath({ path: pattern, end: false }, pathname) != null;
|
||||||
|
|
||||||
|
export const isArtifactRoute = (pathname: string) =>
|
||||||
|
matchesRouteStart(pathname, '/c/*') || matchesRouteStart(pathname, '/share/*');
|
||||||
Loading…
Add table
Add a link
Reference in a new issue