LibreChat/client/src/components/Share/ShareView.tsx
Samuel Path 304bba853c
💻 feat: Deeper MCP UI integration in the Chat UI (#9669)
* 💻 feat: deeper MCP UI integration in the chat UI using plugins

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>

* 💻 refactor: Migrate MCP UI resources from index-based to ID-based referencing

- Replace index-based resource markers with stable resource IDs
- Update plugin to parse \ui{resourceId} format instead of \ui0
- Refactor components to use useMessagesOperations instead of useSubmitMessage
- Add ShareMessagesProvider for UI resources in share view
- Add useConversationUIResources hook for cross-turn resource lookups
- Update parsers to generate resource IDs from content hashes
- Update all tests to use resource IDs instead of indices
- Add sandbox permissions for iframe popups
- Remove deprecated MCP tool context instructions

---------

Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>
2025-12-11 16:41:11 -05:00

249 lines
8.1 KiB
TypeScript

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 { ShareMessagesProvider } from './ShareMessagesProvider';
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) {
docTitle = `${data.title} | ${config.appTitle}`;
} else {
docTitle = data?.title ?? config?.appTitle ?? document.title;
}
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 = (
<div className="flex h-screen items-center justify-center">
<Spinner className="" />
</div>
);
} else if (data && messagesTree && messagesTree.length !== 0) {
content = (
<>
<ShareHeader
title={data.title}
formattedDate={formattedDate}
theme={theme}
langcode={langcode}
onThemeChange={handleThemeChange}
onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')}
/>
<ShareMessagesProvider messages={data.messages}>
<MessagesView messagesTree={messagesTree} conversationId="shared-conversation" />
</ShareMessagesProvider>
</>
);
} else {
content = (
<div className="flex h-screen items-center justify-center">
{localize('com_ui_shared_link_not_found')}
</div>
);
}
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 (
<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">
<div className="relative focus-within:z-[100]">
<ThemeSelector theme={theme} onChange={onThemeChange} portal={false} />
</div>
<div className="bg-border-medium/60 h-px w-full" />
<div className="relative focus-within:z-[100]">
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
</div>
</div>
</OGDialogContent>
</OGDialog>
</div>
</div>
</section>
);
}
export default memo(SharedView);