diff --git a/client/src/Providers/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx index 967b3584af..139f679003 100644 --- a/client/src/Providers/ArtifactsContext.tsx +++ b/client/src/Providers/ArtifactsContext.tsx @@ -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(undefined); -export function ArtifactsProvider({ children }: { children: React.ReactNode }) { +interface ArtifactsProviderProps { + children: React.ReactNode; + value?: Partial; +} + +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( + const defaultContextValue = useMemo( () => ({ 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( + () => (value ? { ...defaultContextValue, ...value } : defaultContextValue), + [defaultContextValue, value], ); return {children}; diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index 902ac9191a..d2e98be5f1 100644 --- a/client/src/components/Artifacts/Artifact.tsx +++ b/client/src/components/Artifacts/Artifact.tsx @@ -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); } diff --git a/client/src/components/Artifacts/ArtifactButton.tsx b/client/src/components/Artifacts/ArtifactButton.tsx index aef9c148c4..c42d75ebc0 100644 --- a/client/src/components/Artifacts/ArtifactButton.tsx +++ b/client/src/components/Artifacts/ArtifactButton.tsx @@ -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 }) => {
{(() => { const handleClick = () => { - if (!location.pathname.includes('/c/')) return; - if (isSelected) { resetCurrentArtifactId(); setVisible(false); diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index e0dc9b4ef4..82ed3d9da4 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -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; editorRef: React.MutableRefObject; + 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; diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 6173e383fe..8e2a92eb9c 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -15,10 +15,12 @@ export default function ArtifactTabs({ artifact, editorRef, previewRef, + isSharedConvo, }: { artifact: Artifact; editorRef: React.MutableRefObject; previewRef: React.MutableRefObject; + 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} /> diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index 97b138bfc6..a3b1a015c3 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -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(); const previewRef = useRef(); @@ -294,6 +295,7 @@ export default function Artifacts() { artifact={currentArtifact} editorRef={editorRef as React.MutableRefObject} previewRef={previewRef as React.MutableRefObject} + isSharedConvo={isSharedConvo} />
diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 0c1d0cc944..f512f2dcbd 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -68,25 +68,27 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => { return (
-
- -
-
-
- {reasoningText} +
+
+ +
+
+
+ {reasoningText} +
diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index f84f4edc9d..17d9eac752 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -35,11 +35,13 @@ export const ThinkingButton = memo( onClick, label, content, + showCopyButton = true, }: { isExpanded: boolean; onClick: (e: MouseEvent) => void; label: string; content?: string; + showCopyButton?: boolean; }) => { const localize = useLocalize(); const fontSize = useAtomValue(fontSizeAtom); @@ -59,7 +61,7 @@ export const ThinkingButton = memo( ); return ( -
+
- {content && ( + {content && showCopyButton && (
); }); diff --git a/client/src/components/Chat/Messages/MinimalHoverButtons.tsx b/client/src/components/Chat/Messages/MinimalHoverButtons.tsx index 3f4e1711a6..00bca638b7 100644 --- a/client/src/components/Chat/Messages/MinimalHoverButtons.tsx +++ b/client/src/components/Chat/Messages/MinimalHoverButtons.tsx @@ -18,16 +18,16 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu }); return ( -
+
); diff --git a/client/src/components/Nav/SettingsTabs/General/General.tsx b/client/src/components/Nav/SettingsTabs/General/General.tsx index ebde274b30..0cb565dad7 100644 --- a/client/src/components/Nav/SettingsTabs/General/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General/General.tsx @@ -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 = ({
); diff --git a/client/src/components/Share/MessageIcon.tsx b/client/src/components/Share/MessageIcon.tsx index 2e7d6989be..b5c2585ab1 100644 --- a/client/src/components/Share/MessageIcon.tsx +++ b/client/src/components/Share/MessageIcon.tsx @@ -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 & { @@ -41,7 +41,7 @@ export default function MessageIcon( } return result; }, [assistant, agent, assistantAvatar, agentAvatar]); - console.log('MessageIcon', { + logger.log('MessageIcon', { endpoint, iconURL, assistantName, diff --git a/client/src/components/Share/ShareArtifacts.tsx b/client/src/components/Share/ShareArtifacts.tsx new file mode 100644 index 0000000000..6114ce9659 --- /dev/null +++ b/client/src/components/Share/ShareArtifacts.tsx @@ -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(() => { + 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} + + + ); + } + + return ( + + + {mainContent} + + + + + + + ); +} + +interface ShareArtifactsPanelProps { + contextValue: ArtifactsContextValue; +} + +/** + * Panel that renders the artifacts UI within a resizable container + */ +function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) { + return ( + + +
+ +
+
+
+ ); +} + +/** + * Mobile overlay that displays artifacts in a fixed position + */ +function ShareArtifactsOverlay({ contextValue }: ShareArtifactsPanelProps) { + return ( +
+ +
+ ); +} diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index b353c46c69..a404796757 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -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 = ( <> -
-

{data.title}

-
- {new Date(data.createdAt).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -
-
- + ); @@ -59,23 +119,124 @@ function SharedView() { ); } + const footer = ( +
+
+
+ ); + + const mainContent = ( +
+
+ {content} + {footer} +
+
+ ); + + const artifactsContainer = + data && data.messages ? ( + + ) : ( + mainContent + ); + return ( -
-
-
- {content} -
-
-
-
-
-
+
+
+ {artifactsContainer} +
+
); } +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 ( +
+
+
+
+

{title}

+ {formattedDate && ( +
+
+ )} +
+ + + + + + + + {settingsLabel} + +
+ +
+ +
+ + +
+
+
+ ); +} + export default memo(SharedView); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 961f8537fa..7eea034ceb 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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'; diff --git a/client/src/utils/routes.ts b/client/src/utils/routes.ts new file mode 100644 index 0000000000..df668544f5 --- /dev/null +++ b/client/src/utils/routes.ts @@ -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/*');