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 { getLatestText } from '~/utils';
|
||||
|
||||
interface ArtifactsContextValue {
|
||||
export interface ArtifactsContextValue {
|
||||
isSubmitting: boolean;
|
||||
latestMessageId: string | null;
|
||||
latestMessageText: string;
|
||||
|
|
@ -12,10 +12,15 @@ interface ArtifactsContextValue {
|
|||
|
||||
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 latestMessageText = useMemo(() => {
|
||||
const chatLatestMessageText = useMemo(() => {
|
||||
return getLatestText({
|
||||
messageId: latestMessage?.messageId ?? null,
|
||||
text: latestMessage?.text ?? null,
|
||||
|
|
@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
|||
} as TMessage);
|
||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
||||
() => ({
|
||||
isSubmitting,
|
||||
latestMessageText,
|
||||
latestMessageText: chatLatestMessageText,
|
||||
latestMessageId: latestMessage?.messageId ?? 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>;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
|
|||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||
import { logger, extractContent, isArtifactRoute } from '~/utils';
|
||||
import { artifactsState } from '~/store/artifacts';
|
||||
import { logger, extractContent } from '~/utils';
|
||||
import ArtifactButton from './ArtifactButton';
|
||||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
|
|
@ -88,7 +88,7 @@ export function Artifact({
|
|||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
return setArtifact(currentArtifact);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { cn, getFileType, logger } from '~/utils';
|
||||
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -57,8 +57,6 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
|||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||
{(() => {
|
||||
const handleClick = () => {
|
||||
if (!location.pathname.includes('/c/')) return;
|
||||
|
||||
if (isSelected) {
|
||||
resetCurrentArtifactId();
|
||||
setVisible(false);
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export const ArtifactCodeEditor = function ({
|
|||
artifact,
|
||||
editorRef,
|
||||
sharedProps,
|
||||
readOnly: externalReadOnly,
|
||||
}: {
|
||||
fileKey: string;
|
||||
artifact: Artifact;
|
||||
|
|
@ -164,6 +165,7 @@ export const ArtifactCodeEditor = function ({
|
|||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
|
|
@ -177,10 +179,10 @@ export const ArtifactCodeEditor = function ({
|
|||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config, template, fileKey]);
|
||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
||||
const [readOnly, setReadOnly] = useState(externalReadOnly ?? isSubmitting ?? false);
|
||||
useEffect(() => {
|
||||
setReadOnly(isSubmitting ?? false);
|
||||
}, [isSubmitting]);
|
||||
setReadOnly(externalReadOnly ?? isSubmitting ?? false);
|
||||
}, [isSubmitting, externalReadOnly]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ export default function ArtifactTabs({
|
|||
artifact,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSharedConvo,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isSharedConvo?: boolean;
|
||||
}) {
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const { currentCode, setCurrentCode } = useCodeState();
|
||||
|
|
@ -54,6 +56,7 @@ export default function ArtifactTabs({
|
|||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
readOnly={isSharedConvo}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ 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 { useShareContext, useMutationState } from '~/Providers';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
import ArtifactVersion from './ArtifactVersion';
|
||||
import { useMutationState } from '~/Providers/EditorContext';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { CopyCodeButton } from './Code';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -20,6 +20,7 @@ const MAX_BACKDROP_OPACITY = 0.3;
|
|||
export default function Artifacts() {
|
||||
const localize = useLocalize();
|
||||
const { isMutating } = useMutationState();
|
||||
const { isSharedConvo } = useShareContext();
|
||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||
const editorRef = useRef<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
|
|
@ -294,6 +295,7 @@ export default function Artifacts() {
|
|||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
isSharedConvo={isSharedConvo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<div className="sticky top-0 z-10 mb-2 pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
|
|
@ -90,6 +91,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,13 @@ export const ThinkingButton = memo(
|
|||
onClick,
|
||||
label,
|
||||
content,
|
||||
showCopyButton = true,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
showCopyButton?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
|
@ -59,7 +61,7 @@ export const ThinkingButton = memo(
|
|||
);
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
@ -79,7 +81,7 @@ export const ThinkingButton = memo(
|
|||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
{content && showCopyButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
|
|
@ -90,8 +92,11 @@ export const ThinkingButton = memo(
|
|||
}
|
||||
className={cn(
|
||||
'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',
|
||||
'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" />}
|
||||
|
|
@ -142,7 +147,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group/thinking-container">
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
|
|
@ -161,7 +166,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,16 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
|
|||
});
|
||||
|
||||
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
|
||||
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)}
|
||||
type="button"
|
||||
title={
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,9 +68,11 @@ export const ThemeSelector = ({
|
|||
export const LangSelector = ({
|
||||
langcode,
|
||||
onChange,
|
||||
portal = true,
|
||||
}: {
|
||||
langcode: string;
|
||||
onChange: (value: string) => void;
|
||||
portal?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
|
|
@ -124,10 +126,11 @@ export const LangSelector = ({
|
|||
<Dropdown
|
||||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px]"
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
|
|||
import type { TMessageProps } from '~/common';
|
||||
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { getIconEndpoint } from '~/utils';
|
||||
import { getIconEndpoint, logger } from '~/utils';
|
||||
|
||||
export default function MessageIcon(
|
||||
props: Pick<TMessageProps, 'message' | 'conversation'> & {
|
||||
|
|
@ -41,7 +41,7 @@ export default function MessageIcon(
|
|||
}
|
||||
return result;
|
||||
}, [assistant, agent, assistantAvatar, agentAvatar]);
|
||||
console.log('MessageIcon', {
|
||||
logger.log('MessageIcon', {
|
||||
endpoint,
|
||||
iconURL,
|
||||
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 { Spinner } from '@librechat/client';
|
||||
import { memo, useState, useCallback, useContext } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { buildTree } from 'librechat-data-provider';
|
||||
import { CalendarDays, Settings } from 'lucide-react';
|
||||
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 { useGetStartupConfig } from '~/data-provider';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import MessagesView from './MessagesView';
|
||||
import Footer from '../Chat/Footer';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function SharedView() {
|
||||
const localize = useLocalize();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { shareId } = useParams();
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||
|
||||
const [langcode, setLangcode] = useRecoilState(store.lang);
|
||||
|
||||
// configure document title
|
||||
let docTitle = '';
|
||||
if (config?.appTitle != null && data?.title != null) {
|
||||
|
|
@ -27,6 +47,48 @@ function SharedView() {
|
|||
|
||||
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;
|
||||
if (isLoading) {
|
||||
content = (
|
||||
|
|
@ -37,17 +99,15 @@ function SharedView() {
|
|||
} else if (data && messagesTree && messagesTree.length !== 0) {
|
||||
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">
|
||||
<h1 className="text-4xl font-bold">{data.title}</h1>
|
||||
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
|
||||
{new Date(data.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareHeader
|
||||
title={data.title}
|
||||
formattedDate={formattedDate}
|
||||
theme={theme}
|
||||
langcode={langcode}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLangChange={handleLangChange}
|
||||
settingsLabel={localize('com_nav_settings')}
|
||||
/>
|
||||
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
|
||||
</>
|
||||
);
|
||||
|
|
@ -59,23 +119,124 @@ function SharedView() {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||
<main
|
||||
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
|
||||
style={{ paddingBottom: '50px' }}
|
||||
>
|
||||
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}
|
||||
<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>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const artifactsContainer =
|
||||
data && data.messages ? (
|
||||
<ShareArtifactsContainer
|
||||
messages={data.messages}
|
||||
conversationId={data.conversationId}
|
||||
mainContent={mainContent}
|
||||
/>
|
||||
) : (
|
||||
mainContent
|
||||
);
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||
<div className="relative flex min-h-screen w-full dark:bg-surface-secondary">
|
||||
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
</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);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export * from './forms';
|
|||
export * from './agents';
|
||||
export * from './drafts';
|
||||
export * from './convos';
|
||||
export * from './routes';
|
||||
export * from './presets';
|
||||
export * from './prompts';
|
||||
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