📁 feat: Integrate SharePoint File Picker and Download Workflow (#8651)

* feat(sharepoint): integrate SharePoint file picker and download workflow
Introduces end‑to‑end SharePoint import support:
* Token exchange with Microsoft Graph and scope management (`useSharePointToken`)
* Re‑usable hooks: `useSharePointPicker`, `useSharePointDownload`,
  `useSharePointFileHandling`
* FileSearch dropdown now offers **From Local Machine** / **From SharePoint**
  sources and gracefully falls back when SharePoint is disabled
* Agent upload model, `AttachFileMenu`, and `DropdownPopup` extended for
  SharePoint files and sub‑menus
* Blurry overlay with progress indicator and `maxSelectionCount` limit during
  downloads
* Cache‑flush utility (`config/flush-cache.js`) supporting Redis & filesystem,
  with dry‑run and npm script
* Updated `SharePointIcon` (uses `currentColor`) and new i18n keys
* Bug fixes: placeholder syntax in progress message, picker event‑listener
  cleanup
* Misc style and performance optimizations

* Fix ESLint warnings

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
This commit is contained in:
Danny Avila 2025-07-25 00:03:23 -04:00
parent 052e61b735
commit e49b49af6c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
40 changed files with 2500 additions and 123 deletions

View file

@ -465,6 +465,21 @@ OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for
# Set to true to use the OpenID Connect end session endpoint for logout # Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT= OPENID_USE_END_SESSION_ENDPOINT=
#========================#
# SharePoint Integration #
#========================#
# Requires Entra ID (OpenID) authentication to be configured
# Enable SharePoint file picker in chat and agent panels
# ENABLE_SHAREPOINT_FILEPICKER=true
# SharePoint tenant base URL (e.g., https://yourtenant.sharepoint.com)
# SHAREPOINT_BASE_URL=https://yourtenant.sharepoint.com
# Microsoft Graph API And SharePoint scopes for file picker
# SHAREPOINT_PICKER_SHAREPOINT_SCOPE==https://yourtenant.sharepoint.com/AllSites.Read
# SHAREPOINT_PICKER_GRAPH_SCOPE=Files.Read.All
#========================#
# SAML # SAML
# Note: If OpenID is enabled, SAML authentication will be automatically disabled. # Note: If OpenID is enabled, SAML authentication will be automatically disabled.

View file

@ -12,6 +12,7 @@ const {
} = require('~/server/services/AuthService'); } = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models'); const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies'); const { getOpenIdConfig } = require('~/strategies');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const registrationController = async (req, res) => { const registrationController = async (req, res) => {
try { try {
@ -118,9 +119,54 @@ const refreshController = async (req, res) => {
} }
}; };
const graphTokenController = async (req, res) => {
try {
// Validate user is authenticated via Entra ID
if (!req.user.openidId || req.user.provider !== 'openid') {
return res.status(403).json({
message: 'Microsoft Graph access requires Entra ID authentication',
});
}
// Check if OpenID token reuse is active (required for on-behalf-of flow)
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return res.status(403).json({
message: 'SharePoint integration requires OpenID token reuse to be enabled',
});
}
// Extract access token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
message: 'Valid authorization token required',
});
}
// Get scopes from query parameters
const scopes = req.query.scopes;
if (!scopes) {
return res.status(400).json({
message: 'Graph API scopes are required as query parameter',
});
}
const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
res.json(tokenResponse);
} catch (error) {
logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
res.status(500).json({
message: 'Failed to obtain Microsoft Graph token',
});
}
};
module.exports = { module.exports = {
refreshController, refreshController,
registrationController, registrationController,
resetPasswordController, resetPasswordController,
resetPasswordRequestController, resetPasswordRequestController,
graphTokenController,
}; };

View file

@ -4,6 +4,7 @@ const {
registrationController, registrationController,
resetPasswordController, resetPasswordController,
resetPasswordRequestController, resetPasswordRequestController,
graphTokenController,
} = require('~/server/controllers/AuthController'); } = require('~/server/controllers/AuthController');
const { loginController } = require('~/server/controllers/auth/LoginController'); const { loginController } = require('~/server/controllers/auth/LoginController');
const { logoutController } = require('~/server/controllers/auth/LogoutController'); const { logoutController } = require('~/server/controllers/auth/LogoutController');
@ -69,4 +70,6 @@ router.post('/2fa/confirm', requireJwtAuth, confirm2FA);
router.post('/2fa/disable', requireJwtAuth, disable2FA); router.post('/2fa/disable', requireJwtAuth, disable2FA);
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodes); router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodes);
router.get('/graph-token', requireJwtAuth, graphTokenController);
module.exports = router; module.exports = router;

View file

@ -21,6 +21,9 @@ const publicSharedLinksEnabled =
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined || (process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC)); isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
router.get('/', async function (req, res) { router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
@ -98,6 +101,11 @@ router.get('/', async function (req, res) {
instanceProjectId: instanceProject._id.toString(), instanceProjectId: instanceProject._id.toString(),
bundlerURL: process.env.SANDPACK_BUNDLER_URL, bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL, staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
sharePointFilePickerEnabled,
sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens,
}; };
payload.mcpServers = {}; payload.mcpServers = {};

View file

@ -0,0 +1,86 @@
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const { logger } = require('~/config');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const client = require('openid-client');
/**
* Get Microsoft Graph API token using existing token exchange mechanism
* @param {Object} user - User object with OpenID information
* @param {string} accessToken - Current access token from Authorization header
* @param {string} scopes - Graph API scopes for the token
* @param {boolean} fromCache - Whether to try getting token from cache first
* @returns {Promise<Object>} Graph API token response with access_token and expires_in
*/
async function getGraphApiToken(user, accessToken, scopes, fromCache = true) {
try {
if (!user.openidId) {
throw new Error('User must be authenticated via Entra ID to access Microsoft Graph');
}
if (!accessToken) {
throw new Error('Access token is required for token exchange');
}
if (!scopes) {
throw new Error('Graph API scopes are required for token exchange');
}
const config = getOpenIdConfig();
if (!config) {
throw new Error('OpenID configuration not available');
}
const cacheKey = `${user.openidId}:${scopes}`;
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
if (fromCache) {
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
logger.debug(`[GraphTokenService] Using cached Graph API token for user: ${user.openidId}`);
return cachedToken;
}
}
logger.debug(`[GraphTokenService] Requesting new Graph API token for user: ${user.openidId}`);
logger.debug(`[GraphTokenService] Requested scopes: ${scopes}`);
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopes,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
const tokenResponse = {
access_token: grantResponse.access_token,
token_type: 'Bearer',
expires_in: grantResponse.expires_in || 3600,
scope: scopes,
};
await tokensCache.set(
cacheKey,
tokenResponse,
(grantResponse.expires_in || 3600) * 1000, // Convert to milliseconds
);
logger.debug(
`[GraphTokenService] Successfully obtained and cached Graph API token for user: ${user.openidId}`,
);
return tokenResponse;
} catch (error) {
logger.error(
`[GraphTokenService] Failed to acquire Graph API token for user ${user.openidId}:`,
error,
);
throw new Error(`Graph token acquisition failed: ${error.message}`);
}
}
module.exports = {
getGraphApiToken,
};

View file

@ -20,4 +20,5 @@ export interface MenuItemProps {
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }> | RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
| React.ReactElement<any, string | React.JSXElementConstructor<any>> | React.ReactElement<any, string | React.JSXElementConstructor<any>>
| undefined; | undefined;
subItems?: MenuItemProps[];
} }

View file

@ -2,11 +2,21 @@ import React, { useRef, useState, useMemo } from 'react';
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '@librechat/client';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider'; import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import {
FileUpload,
TooltipAnchor,
DropdownPopup,
AttachmentIcon,
SharePointIcon,
} from '@librechat/client';
import type { EndpointFileConfig } from 'librechat-data-provider'; import type { EndpointFileConfig } from 'librechat-data-provider';
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks'; import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
import { SharePointPickerDialog } from '~/components/SharePoint';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store'; import { ephemeralAgentByConvoId } from '~/store';
import { MenuItemProps } from '~/common';
import { cn } from '~/utils'; import { cn } from '~/utils';
interface AttachFileMenuProps { interface AttachFileMenuProps {
@ -26,7 +36,15 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
overrideEndpoint: EModelEndpoint.agents, overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig, overrideEndpointFileConfig: endpointFileConfig,
}); });
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
toolResource,
});
const { data: startupConfig } = useGetStartupConfig();
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
const { agentsConfig } = useGetAgentsConfig(); const { agentsConfig } = useGetAgentsConfig();
/** TODO: Ephemeral Agent Capabilities /** TODO: Ephemeral Agent Capabilities
* Allow defining agent capabilities on a per-endpoint basis * Allow defining agent capabilities on a per-endpoint basis
@ -45,57 +63,83 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
}; };
const dropdownItems = useMemo(() => { const dropdownItems = useMemo(() => {
const items = [ const createMenuItems = (onAction: (isImage?: boolean) => void) => {
{ const items: MenuItemProps[] = [
label: localize('com_ui_upload_image_input'), {
onClick: () => { label: localize('com_ui_upload_image_input'),
setToolResource(undefined); onClick: () => {
handleUploadClick(true); setToolResource(undefined);
onAction(true);
},
icon: <ImageUpIcon className="icon-md" />,
}, },
icon: <ImageUpIcon className="icon-md" />, ];
},
];
if (capabilities.ocrEnabled) { if (capabilities.ocrEnabled) {
items.push({ items.push({
label: localize('com_ui_upload_ocr_text'), label: localize('com_ui_upload_ocr_text'),
onClick: () => { onClick: () => {
setToolResource(EToolResources.ocr); setToolResource(EToolResources.ocr);
handleUploadClick(); onAction();
}, },
icon: <FileType2Icon className="icon-md" />, icon: <FileType2Icon className="icon-md" />,
});
}
if (capabilities.fileSearchEnabled) {
items.push({
label: localize('com_ui_upload_file_search'),
onClick: () => {
setToolResource(EToolResources.file_search);
onAction();
},
icon: <FileSearch className="icon-md" />,
});
}
if (capabilities.codeEnabled) {
items.push({
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource(EToolResources.execute_code);
setEphemeralAgent((prev) => ({
...prev,
[EToolResources.execute_code]: true,
}));
onAction();
},
icon: <TerminalSquareIcon className="icon-md" />,
});
}
return items;
};
const localItems = createMenuItems(handleUploadClick);
if (sharePointEnabled) {
const sharePointItems = createMenuItems(() => {
setIsSharePointDialogOpen(true);
// Note: toolResource will be set by the specific item clicked
}); });
localItems.push({
label: localize('com_files_upload_sharepoint'),
onClick: () => {},
icon: <SharePointIcon className="icon-md" />,
subItems: sharePointItems,
});
return localItems;
} }
if (capabilities.fileSearchEnabled) { return localItems;
items.push({ }, [
label: localize('com_ui_upload_file_search'), capabilities,
onClick: () => { localize,
setToolResource(EToolResources.file_search); setToolResource,
/** File search is not automatically enabled to simulate legacy behavior */ setEphemeralAgent,
handleUploadClick(); sharePointEnabled,
}, setIsSharePointDialogOpen,
icon: <FileSearch className="icon-md" />, ]);
});
}
if (capabilities.codeEnabled) {
items.push({
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource(EToolResources.execute_code);
setEphemeralAgent((prev) => ({
...prev,
[EToolResources.execute_code]: true,
}));
handleUploadClick();
},
icon: <TerminalSquareIcon className="icon-md" />,
});
}
return items;
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
const menuTrigger = ( const menuTrigger = (
<TooltipAnchor <TooltipAnchor
@ -118,25 +162,44 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
disabled={isUploadDisabled} disabled={isUploadDisabled}
/> />
); );
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {
await handleSharePointFiles(sharePointFiles);
setIsSharePointDialogOpen(false);
} catch (error) {
console.error('SharePoint file processing error:', error);
}
};
return ( return (
<FileUpload <>
ref={inputRef} <FileUpload
handleFileChange={(e) => { ref={inputRef}
handleFileChange(e, toolResource); handleFileChange={(e) => {
}} handleFileChange(e, toolResource);
> }}
<DropdownPopup >
menuId="attach-file-menu" <DropdownPopup
isOpen={isPopoverActive} menuId="attach-file-menu"
setIsOpen={setIsPopoverActive} className="overflow-visible"
modal={true} isOpen={isPopoverActive}
unmountOnHide={true} setIsOpen={setIsPopoverActive}
trigger={menuTrigger} modal={true}
items={dropdownItems} unmountOnHide={true}
iconClassName="mr-0" trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</FileUpload>
<SharePointPickerDialog
isOpen={isSharePointDialogOpen}
onOpenChange={setIsSharePointDialogOpen}
onFilesSelected={handleSharePointFilesSelected}
isDownloading={isProcessing}
downloadProgress={downloadProgress}
maxSelectionCount={endpointFileConfig?.fileLimit}
/> />
</FileUpload> </>
); );
}; };

View file

@ -1,7 +1,6 @@
import { Spinner } from '@librechat/client'; import { Spinner, FileIcon } from '@librechat/client';
import type { TFile } from 'librechat-data-provider'; import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import { FileIcon } from '~/components/svg';
import SourceIcon from './SourceIcon'; import SourceIcon from './SourceIcon';
import { cn } from '~/utils'; import { cn } from '~/utils';

View file

@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react';
import {
OGDialog,
OGDialogTitle,
OGDialogPortal,
OGDialogOverlay,
OGDialogContent,
} from '@librechat/client';
import type { SharePointBatchProgress } from '~/data-provider/Files/sharepoint';
import { useSharePointPicker, useLocalize } from '~/hooks';
interface SharePointPickerDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onFilesSelected?: (files: any[]) => void;
disabled?: boolean;
isDownloading?: boolean;
downloadProgress?: SharePointBatchProgress | null;
maxSelectionCount?: number;
}
export default function SharePointPickerDialog({
isOpen,
onOpenChange,
onFilesSelected,
disabled = false,
isDownloading = false,
downloadProgress = null,
maxSelectionCount,
}: SharePointPickerDialogProps) {
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null);
const localize = useLocalize();
const { openSharePointPicker, closeSharePointPicker, cleanup } = useSharePointPicker({
containerNode,
onFilesSelected,
disabled,
onClose: () => handleOpenChange(false),
maxSelectionCount,
});
const handleOpenChange = (open: boolean) => {
if (!open) {
closeSharePointPicker();
}
onOpenChange(open);
};
// Use callback ref to trigger SharePoint picker when container is attached
const containerCallbackRef = React.useCallback((node: HTMLDivElement | null) => {
setContainerNode(node);
}, []);
useEffect(() => {
if (containerNode && isOpen) {
openSharePointPicker();
}
return () => {
if (!isOpen) {
cleanup();
}
};
// we need to run this effect only when the containerNode or isOpen changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerNode, isOpen]);
return (
<OGDialog open={isOpen} onOpenChange={handleOpenChange}>
<OGDialogPortal>
<OGDialogOverlay className="bg-black/50" />
<OGDialogContent
className="bg-#F5F5F5 sharepoint-picker-bg fixed left-1/2 top-1/2 z-50 h-[680px] max-h-[90vh] max-w-[90vw] -translate-x-1/2 -translate-y-1/2 rounded-lg border p-2 shadow-lg focus:outline-none"
showCloseButton={true}
>
<OGDialogTitle className="sr-only">
{localize('com_files_sharepoint_picker_title')}
</OGDialogTitle>
<div ref={containerCallbackRef} className="sharepoint-picker-bg relative flex p-2">
{/* SharePoint iframe will be injected here by the hook */}
{isDownloading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/30 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-lg bg-white p-6 shadow-lg">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
{localize('com_files_downloading')}
</h3>
{downloadProgress && (
<div className="space-y-2">
<p className="text-sm text-gray-600">
{localize('com_files_download_progress', {
0: downloadProgress.completed,
1: downloadProgress.total,
})}
</p>
{downloadProgress.currentFile && (
<p className="truncate text-xs text-gray-500">
{downloadProgress.currentFile}
</p>
)}
<div className="h-2 w-full rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
style={{
width: `${Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%`,
}}
></div>
</div>
<p className="text-xs text-gray-500">
{localize('com_files_download_percent_complete', {
0: Math.round(
(downloadProgress.completed / downloadProgress.total) * 100,
),
})}
</p>
{downloadProgress.failed.length > 0 && (
<p className="text-xs text-red-500">
{localize('com_files_download_failed', {
0: downloadProgress.failed.length,
})}
</p>
)}
</div>
)}
{!downloadProgress && (
<p className="text-sm text-gray-600">
{localize('com_files_preparing_download')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</OGDialogContent>
</OGDialogPortal>
</OGDialog>
);
}

View file

@ -0,0 +1 @@
export { default as SharePointPickerDialog } from './SharePointPickerDialog';

View file

@ -1,4 +1,6 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Folder } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { import {
EModelEndpoint, EModelEndpoint,
EToolResources, EToolResources,
@ -7,16 +9,21 @@ import {
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { import {
HoverCard, HoverCard,
HoverCardContent, DropdownPopup,
HoverCardPortal,
HoverCardTrigger,
AttachmentIcon, AttachmentIcon,
CircleHelpIcon, CircleHelpIcon,
AttachmentIcon,
CircleHelpIcon,
SharePointIcon,
HoverCardPortal,
HoverCardContent,
HoverCardTrigger,
} from '@librechat/client'; } from '@librechat/client';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks'; import { useFileHandling, useLocalize, useLazyEffect, useSharePointFileHandling } from '~/hooks';
import { useGetFileConfig, useGetStartupConfig } from '~/data-provider';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import { ESide } from '~/common'; import { ESide } from '~/common';
@ -31,6 +38,10 @@ export default function FileContext({
const { setFilesLoading } = useChatContext(); const { setFilesLoading } = useChatContext();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map()); const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
const { data: startupConfig } = useGetStartupConfig();
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
@ -41,7 +52,11 @@ export default function FileContext({
additionalMetadata: { agent_id, tool_resource: EToolResources.ocr }, additionalMetadata: { agent_id, tool_resource: EToolResources.ocr },
fileSetter: setFiles, fileSetter: setFiles,
}); });
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
fileSetter: setFiles,
});
useLazyEffect( useLazyEffect(
() => { () => {
if (_files) { if (_files) {
@ -54,19 +69,45 @@ export default function FileContext({
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
const isUploadDisabled = endpointFileConfig.disabled ?? false; const isUploadDisabled = endpointFileConfig.disabled ?? false;
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {
await handleSharePointFiles(sharePointFiles);
setIsSharePointDialogOpen(false);
} catch (error) {
console.error('SharePoint file processing error:', error);
}
};
if (isUploadDisabled) { if (isUploadDisabled) {
return null; return null;
} }
const handleButtonClick = () => { const handleLocalFileClick = () => {
// necessary to reset the input // necessary to reset the input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const dropdownItems = [
{
label: localize('com_files_upload_local_machine'),
onClick: handleLocalFileClick,
icon: <Folder className="icon-md" />,
},
{
label: localize('com_files_upload_sharepoint'),
onClick: () => setIsSharePointDialogOpen(true),
icon: <SharePointIcon className="icon-md" />,
},
];
const menuTrigger = (
<Ariakit.MenuButton className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium">
<div className="flex w-full items-center justify-center gap-1">
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_file_context')}
</div>
</Ariakit.MenuButton>
);
return ( return (
<div className="w-full"> <div className="w-full">
<HoverCard openDelay={50}> <HoverCard openDelay={50}>
@ -101,26 +142,42 @@ export default function FileContext({
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>} Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
/> />
<div> <div>
<button {sharePointEnabled ? (
type="button" <>
disabled={!agent_id} <DropdownPopup
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium" gutter={2}
onClick={handleButtonClick} menuId="file-search-upload-menu"
> isOpen={isPopoverActive}
<div className="flex w-full items-center justify-center gap-1"> setIsOpen={setIsPopoverActive}
<AttachmentIcon className="text-token-text-primary h-4 w-4" /> trigger={menuTrigger}
<input items={dropdownItems}
multiple={true} modal={true}
type="file" unmountOnHide={true}
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={!agent_id}
onChange={handleFileChange}
/> />
{localize('com_ui_upload_file_context')} </>
</div> ) : (
</button> <button
type="button"
disabled={!agent_id}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleLocalFileClick}
>
<div className="flex w-full items-center justify-center gap-1">
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_file_context')}
</div>
</button>
)}
<input
multiple={true}
type="file"
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={!agent_id}
onChange={handleFileChange}
/>
</div> </div>
{/* Disabled Message */} {/* Disabled Message */}
{agent_id ? null : ( {agent_id ? null : (
@ -129,6 +186,14 @@ export default function FileContext({
</div> </div>
)} )}
</div> </div>
<SharePointPickerDialog
isOpen={isSharePointDialogOpen}
onOpenChange={setIsSharePointDialogOpen}
onFilesSelected={handleSharePointFilesSelected}
isDownloading={isProcessing}
downloadProgress={downloadProgress}
maxSelectionCount={endpointFileConfig?.fileLimit}
/>
</div> </div>
); );
} }

View file

@ -1,6 +1,8 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Folder } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { AttachmentIcon } from '@librechat/client'; import { SharePointIcon, AttachmentIcon, DropdownPopup } from '@librechat/client';
import { import {
EModelEndpoint, EModelEndpoint,
EToolResources, EToolResources,
@ -9,10 +11,12 @@ import {
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common'; import type { ExtendedFile, AgentForm } from '~/common';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
import { useGetFileConfig, useGetStartupConfig } from '~/data-provider';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks'; import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import FileSearchCheckbox from './FileSearchCheckbox'; import FileSearchCheckbox from './FileSearchCheckbox';
import { useGetFileConfig } from '~/data-provider';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
export default function FileSearch({ export default function FileSearch({
@ -27,6 +31,11 @@ export default function FileSearch({
const { watch } = useFormContext<AgentForm>(); const { watch } = useFormContext<AgentForm>();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map()); const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
// Get startup configuration for SharePoint feature flag
const { data: startupConfig } = useGetStartupConfig();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
@ -38,6 +47,12 @@ export default function FileSearch({
fileSetter: setFiles, fileSetter: setFiles,
}); });
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
fileSetter: setFiles,
});
useLazyEffect( useLazyEffect(
() => { () => {
if (_files) { if (_files) {
@ -53,6 +68,17 @@ export default function FileSearch({
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
const isUploadDisabled = endpointFileConfig.disabled ?? false; const isUploadDisabled = endpointFileConfig.disabled ?? false;
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const disabledUploadButton = !agent_id || fileSearchChecked === false;
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {
await handleSharePointFiles(sharePointFiles);
setIsSharePointDialogOpen(false);
} catch (error) {
console.error('SharePoint file processing error:', error);
}
};
if (isUploadDisabled) { if (isUploadDisabled) {
return null; return null;
} }
@ -65,6 +91,38 @@ export default function FileSearch({
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const handleLocalFileClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
fileInputRef.current?.click();
};
const dropdownItems = [
{
label: localize('com_files_upload_local_machine'),
onClick: handleLocalFileClick,
icon: <Folder className="icon-md" />,
},
{
label: localize('com_files_upload_sharepoint'),
onClick: () => setIsSharePointDialogOpen(true),
icon: <SharePointIcon className="icon-md" />,
},
];
const menuTrigger = (
<Ariakit.MenuButton
disabled={disabledUploadButton}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
>
<div className="flex w-full items-center justify-center gap-1">
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_file_search')}
</div>
</Ariakit.MenuButton>
);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="mb-1.5 flex items-center gap-2"> <div className="mb-1.5 flex items-center gap-2">
@ -86,26 +144,39 @@ export default function FileSearch({
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>} Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
/> />
<div> <div>
<button {sharePointEnabled ? (
type="button" <DropdownPopup
disabled={!agent_id || fileSearchChecked === false} gutter={2}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium" menuId="file-search-upload-menu"
onClick={handleButtonClick} isOpen={isPopoverActive}
> setIsOpen={setIsPopoverActive}
<div className="flex w-full items-center justify-center gap-1"> trigger={menuTrigger}
<AttachmentIcon className="text-token-text-primary h-4 w-4" /> items={dropdownItems}
<input modal={true}
multiple={true} unmountOnHide={true}
type="file" />
style={{ display: 'none' }} ) : (
tabIndex={-1} <button
ref={fileInputRef} type="button"
disabled={!agent_id || fileSearchChecked === false} disabled={disabledUploadButton}
onChange={handleFileChange} className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
/> onClick={handleButtonClick}
{localize('com_ui_upload_file_search')} >
</div> <div className="flex w-full items-center justify-center gap-1">
</button> <AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_file_search')}
</div>
</button>
)}
<input
multiple={true}
type="file"
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={disabledUploadButton}
onChange={handleFileChange}
/>
</div> </div>
{/* Disabled Message */} {/* Disabled Message */}
{agent_id ? null : ( {agent_id ? null : (
@ -114,6 +185,16 @@ export default function FileSearch({
</div> </div>
)} )}
</div> </div>
<SharePointPickerDialog
isOpen={isSharePointDialogOpen}
onOpenChange={setIsSharePointDialogOpen}
onFilesSelected={handleSharePointFilesSelected}
disabled={disabledUploadButton}
isDownloading={isProcessing}
downloadProgress={downloadProgress}
maxSelectionCount={endpointFileConfig?.fileLimit}
/>
</div> </div>
); );
} }

View file

@ -0,0 +1,640 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
export type ExtFilters =
| 'folder'
| 'site'
| 'documentLibrary'
| 'list'
| 'onenote'
| 'file'
| 'media'
| 'photo'
| 'video'
| 'audio'
| 'document'
| 'listItem'
| 'playlist'
| 'syntexTemplate'
| 'syntexSnippet'
| 'syntexField'
| `.${string}`;
//NOTE: IItem type references the following docs: https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0#properties
export type IItem = Record<string, any>;
export type SPPickerConfig = {
sdk: '8.0';
/**
* Establishes the messaging parameters used to setup the post message communications between
* picker and host application
*/
messaging: {
/**
* A unique id assigned by the host app to this File Picker instance.
* This should ideally be a new GUID generated by the host.
*/
channelId: string;
/**
* The host app's authority, used as the target origin for post-messaging.
*/
origin: string;
/**
* Whether or not the host app window will need to identify itself.
*/
identifyParent?: boolean;
/**
* Whether or not the client app must wait for a 'configure' command to be sent by the host before rendering.
*/
waitForConfiguration?: boolean;
/**
* Override timeout for acknowledgement messages.
*/
acknowledgeTimeout?: number;
/**
* Override timeout for the initialization handshake.
*/
initializeTimeout?: number;
/**
* Override timeout for command responses.
*/
resultTimeout?: number;
};
/**
* Configuration for the entry location to which the File Picker will navigate on load.
* The File Picker app will prioritize path-based navigation if provided, falling back to other address forms
* on error (in case of Site redirection or content rename) or if path information is not provided.
*/
entry: {
sharePoint?: {
/**
* Specify an exact SharePoint content location by path segments.
*/
byPath?: {
/**
* Full URL to the root of a Web, or server-relative URL.
* @example
* 'https://contoso-my.sharepoint.com/personal/user_contoso_com'
* @example
* '/path/to/web'
* @example
* 'subweb'
*/
web?: string;
/**
* Full URL or path segement to identity a List.
* If not preceded with a `/` or a URL scheme, this is assumed to be a list in the specified web.
* @example
* 'Shared Documents'
* @example
* '/path/to/web/Shared Documents'
* @example
* 'https://contoso.sharepoint.com/path/to/web/Shared Documents'
*/
list?: string;
/**
* Path segment to a folder within a list, or a server-relative URL to a folder.
* @example
* 'General'
* @example
* 'foo/bar'
* @example
* '/path/to/web/Shared Documents/General'
*/
folder?: string;
/**
* Auto fallback to root folder if the specified entry sub folder doesn't exist.
*/
fallbackToRoot?: boolean;
};
};
/**
* Indicates that File Picker should start in the Site Pivot
* This pivot is only supported in OneDrive for Business
*/
site?: {};
/**
* Indicates that File Picker should start in the OAL (My Organization) Pivot
* This pivot is only supported in OneDrive for Business
*/
myOrganization?: {};
/**
* Indicates that the File Picker should start in the user's OneDrive.
*/
oneDrive?: {
/**
* Specifies that File Picker should start in the user's Files tab.
*/
files?: {
/**
* Path segment for sub-folder within the user's OneDrive for Business.
* @example
* 'Pictures'
* @example
* '/personal/user_contoso_com/Documents/Attachments'
*/
folder?: string;
/**
* Auto fallback to root folder if the specified entry sub folder doesn't exist.
*/
fallbackToRoot?: boolean;
};
/**
* Indicates that File Picker should start in the user's recent files.
*/
recent?: {};
/**
* Indicates that File Picker should start in the files shared with the user.
*/
sharedWithMe?: {};
/**
* Indicates that File Picker should start in the user's photos.
* This pivot is only available in OneDrive for Consumer
*/
photos?: {};
};
sortBy?: {
/**
* Name of the field *in SharePoint* on which to sort.
*/
fieldName: string;
/**
* Whether or not to sort in ascending order. Default is `true`.
*/
isAscending?: boolean;
};
filterBy?: {
/**
* Name of the field *in SharePoint* on which to filter on.
*/
fieldName: string;
/**
* Filter value
*/
value: string;
};
};
/**
* Specifies how to enable a Search behavior.
*/
search?: {
enabled: boolean;
};
/**
* Configuration for handling authentication requests from the embedded app.
* Presence of this object (even if empty) indicates that the host will handle authentication.
* Omitting this will make the embedded content attempt to rely on cookies.
*/
authentication?: {
/**
* @default true
*/
enabled?: boolean;
/**
* Indicates support for individual token types.
*/
tokens?: {
/**
* @defaultValue true
*/
graph?: boolean;
/**
* @defaultValue true
*/
sharePoint?: boolean;
/**
* @defaultValue false
*/
substrate?: boolean;
};
/**
* Indicates that the host app can handle 'claims' challenges.
*/
claimsChallenge?: {
/**
* @default false
*/
enabled?: boolean;
};
};
/**
* Configures what types of items are allowed to be picked within the experience.
* Note that the default configuration accounts for the expected authentication capabilities of the host app.
* Depending on what else is enabled by the host, the host may be expected to provide tokens for more services and scopes.
*/
typesAndSources?: {
/**
* Specifies the general category of items picked. Switches between 'file' vs. 'folder' picker mode,
* or a general-purpose picker.
* @default 'all'
*/
mode?: 'files' | 'folders' | 'all';
/**
* `filters` options: file extension, i.e. .xlsx, .docx, .ppt, etc.
* `filters` options: 'photo', 'folder', 'video', 'documentLibrary'
*/
filters?: ExtFilters[];
/**
* Specifies a filter for *where* the item may come from.
*/
locations?: {
/**
* Items may only come from the user's OneDrive.
*/
oneDrive?: {};
/**
* Items may only come from a specific location within SharePoint.
*/
sharePoint?: {
byPath?: {
web?: string;
list?: string;
folder?: string;
};
};
};
/**
* Specifies filtering based on user access level.
*/
access?: {
/**
* Filter for requires user access level for picked items. Default is `'read'`.
*/
mode?: 'read' | 'read-write';
};
/**
* Specifies which pivots the user may access while browsing files and lists.
* Note that if a pivot is disabled here but still targeted in `entry`, it will still be visible in the nav.
*/
pivots?: {
/**
* Show "My files".
*/
oneDrive?: boolean;
/**
* Show "Recent".
*/
recent?: boolean;
/**
* Show "Shared"
*/
shared?: boolean;
/**
* Show "Quick access".
*/
sharedLibraries?: boolean;
/**
* Show "My organization".
* This pivot is only supported in OneDrive for Business
*/
myOrganization?: boolean;
/**
* Show the site pivot
* This pivot is only supported in OneDrive for Business
*/
site?: boolean;
};
};
/**
* Configuration for what item types may be selected within the picker and returned to the host.
*/
selection?: {
/**
* Controls how selection works within the list.
* @default 'single' for the Picker.
*/
mode?: 'single' | 'multiple' | 'pick';
/**
* Whether or not to allow the user to maintain a selection across folders and pivots.
*/
enablePersistence?: boolean;
/**
* Whether or not the host expects to be notified whenever selection changes.
*/
enableNotifications?: boolean;
/**
* The maximum number of items which may be selected.
*/
maximumCount?: number;
/**
* A set of items to pre-select.
*/
sourceItems?: IItem[];
};
/**
* Configures how commands behave within the experience.
*/
commands?: {
/**
* Specifies the behavior for file-picking.
*/
pick?: {
/**
* A special action to perform when picking the file, before handing the result
* back to the host app.
*/
action?: 'select' | 'share' | 'download' | 'move';
/**
* A custom label to apply to the button which picks files.
* This must be localized by the host app if supplied.
*/
label?: string;
/**
* Configures the 'move' action for picking files.
*/
move?: {
sourceItems?: IItem[];
};
/**
* Configures the 'copy' action for picking files.
*/
copy?: {
sourceItems?: IItem[];
};
/**
* Configures the 'select' action for picking files.
*/
select?: {
/**
* Specify if we want download urls to be returned when items are selected.
*/
urls?: {
download?: boolean;
};
};
};
/**
* Specifies the behavior for closing the experience.
*/
close?: {
/**
* A custom label to apply to the 'cancel' button.
* This must be localized by the host app if supplied.
*/
label?: string;
};
/**
* Behavior for a "Browse this device" command to pick local files.
*/
browseThisDevice?: {
enabled?: boolean;
label?: string;
mode?: 'upload' | 'pick';
};
/**
* Behavior for a "From a link" command to pick from a link.
*/
fromALink?: {
enabled?: boolean;
mode?: 'nav' | 'pivot';
};
/**
* Behavior for a "Switch account" command.
*/
switchAccount?: {
mode?: 'host' | 'none';
};
/**
* Behavior for a "Manage accounts" command.
*/
manageAccounts?: {
mode?: 'host' | 'none';
label?: string;
};
/**
* Behavior for "Upload"
*/
upload?: {
enabled?: boolean;
};
/**
* Behavior for "Create folder"
*/
createFolder?: {
enabled?: boolean;
};
/**
* Behavior for "Filter by" in the column headers.
*/
filterByColumn?: {
mode?: 'panel' | 'menu';
};
/**
* How to handle actions defined by custom formatters.
*/
customFormatter?: {
actions?: {
key: string;
mode?: 'host' | 'none';
}[];
};
/**
* How to handle specified values for `key` in custom commands
* in the tray, nav, or command bar.
*/
custom?: {
actions?: {
key: string;
/**
* Filters defining what types of items the action operates on.
* If specified, the action will only be available for items which match the given filters.
*/
filters?: ExtFilters[];
/**
* How the action is invoked.
* 'host': Invokes a `custom` command message against the host app.
* 'none': Disables the action.
*/
mode?: 'host' | 'none';
/**
* Selection criteria to which the item applies.
*/
selection?: 'single' | 'multiple' | 'current' | 'none';
}[];
};
};
/**
* Specifies accessibility cues such as auto-focus behaviors.
*/
accessibility?: {
/**
* Whether or not to 'trap focus' within the component. If this is enabled, tab-stops will loop from the last element back to the left navigation automatically.
* This is useful if the components's frame is hosted as the only content of a modal overlay and focus should not jump to the outside content.
*
* @default false
*/
enableFocusTrap?: boolean;
/**
* Whether or not the component should immediately grab focus once the content has loaded.
*
* @default true
*/
trapFocusOnLoad?: boolean;
/**
* Whether or not to force the currently-focused element within the component to be highlighted.
* By default, the focused element is highlighted if the user navigates elements with the keyboard but not when the user interacts via the mouse.
* However, if a host application launches the component due to keyboard input it should set this flag to `true` to ensure continuity of behavior.
*
* @default false
*/
showFocusOnLoad?: boolean;
};
tray?: {
/**
* Configures the commands normally used to pick files or close the picker.
*/
commands?: {
/**
* A key to differentiate the command from others.
*/
key: string;
/**
* A custom string for the command.
* Must be localized by the host.
*/
label?: string;
/**
* The action to perform when the button is clicked.
*/
action: 'pick' | 'close' | 'custom';
/**
* If `'pick'` is specified, which pick behavior to use.
*/
pick?: {
action: 'select' | 'share' | 'download' | 'move';
};
/**
* Whether the button should show as the primary button.
*/
primary?: boolean;
/**
* Whether the button should remain visible at all times even if unavailable.
*/
permanent?: boolean;
}[];
/**
* Whether or not the picker tray might be provided by the host instead.
* @defaultValue 'default'
*/
mode?: 'host' | 'default';
/**
* Configures a component to render in the picker tray to the left of the commands.
* @default 'selection-summary'
*/
prompt?: 'keep-sharing' | 'selection-summary' | 'selection-editor' | 'save-as' | 'none';
/**
* Configures use of the 'save-as' prompt.
*/
saveAs?: {
/**
* Default file name to show in 'save-as' prompt.
*/
fileName?: string;
};
/**
* Settings for handling conflicts with existing file names.
*/
conflicts?: {
/**
* How to handle when a file name matches an existing file.
* `'warn'` - Show a prompt to ask the user to confirm the choice.
* `'block'` - Block the choice as an error.
* `'accept'` - Accept the choice automatically.
* `'none'` - Do not try to match with existing items.
*/
mode?: 'warn' | 'block' | 'accept' | 'none';
};
/**
* Configures use of the 'keep-sharing' prompt.
*/
keepSharing?: {
active?: boolean;
};
};
leftNav?: {
/**
* Whether or not a Left Nav should be rendered by the embedded content.
*/
enabled?: boolean;
/**
* Mode of presentation of the nav.
* If the nav is enabled but this is set to `host`, the embedded app
* will show a button to ask the host app to show a nav.
*/
mode?: 'host' | 'default';
/**
* Indicates whether the left nav will be initially modal.
*/
initialModality?: 'modal' | 'hidden';
/**
* Type of left nav
*/
preset?: 'oneDrive' | 'current-site';
/**
* Custom commands to insert at the end of the left nav. Will appear before the default set.
*/
commands?: {
/**
* Name to use when notifying the host that the command is being invoked.
*/
key: string;
/**
* Localized string to use for the button text.
*/
label: string;
/**
* Type of action which will be performed when the command is clicked.
* 'custom': Configured via `commands.custom`.
*/
action: 'custom' | 'pick' | 'close' | 'browse-this-device';
/**
* Name of a Fluent icon to use for the command button.
*/
icon?: string;
}[];
};
/**
* The theme to use for the file-picker. Will change the coloring.
* Note: custom theme objects are expected in addition to the strings below
* @default 'default': Light theme
*/
theme?: 'default' | 'dark' | 'lists';
list?: {
/**
* A custom override for the initial list layout.
*/
layout?: {
/**
* Sets the preferred starting layout for the initial content.
*/
type?: 'details' | 'compact-details' | 'tiles';
};
/**
* Configures scrolling behavior within the Picker.
*/
scrolling?: {
enableStickyHeaders?: boolean;
};
};
/**
* Provides a header title for the Picker.
*/
title?: string;
/**
* Specifies customizations for specific pivots
*/
pivots?: {
/**
* Customize the site pivot
*/
site?: {
byPath?: {
/**
* Chose the site url to use for this pivot
* Required to show the site pivot, if undefined
* the site pivot will not be shown
*/
web?: string;
};
};
};
};

View file

@ -1,5 +0,0 @@
export { default as CodePaths } from './CodePaths';
export { default as FileIcon } from './FileIcon';
export { default as FilePaths } from './FilePaths';
export { default as SheetPaths } from './SheetPaths';
export { default as TextPaths } from './TextPaths';

View file

@ -18,3 +18,27 @@ export const useGetUserQuery = (
enabled: (config?.enabled ?? true) === true && queriesEnabled, enabled: (config?.enabled ?? true) === true && queriesEnabled,
}); });
}; };
export interface UseGraphTokenQueryOptions {
scopes?: string;
enabled?: boolean;
}
export const useGraphTokenQuery = (
options: UseGraphTokenQueryOptions = {},
config?: UseQueryOptions<any>,
): QueryObserverResult<any> => {
const { scopes, enabled = false } = options;
return useQuery({
queryKey: [QueryKeys.graphToken, scopes],
queryFn: () => dataService.getGraphApiToken({ scopes }),
enabled,
staleTime: 50 * 60 * 1000, // 50 minutes (tokens expire in 60 minutes)
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
});
};

View file

@ -1,2 +1,3 @@
export * from './mutations'; export * from './mutations';
export * from './queries'; export * from './queries';
export * from './sharepoint';

View file

@ -0,0 +1,232 @@
import { useMutation } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
export interface SharePointFile {
id: string;
name: string;
size: number;
webUrl: string;
downloadUrl: string;
driveId: string;
itemId: string;
sharePointItem: any;
}
export interface SharePointDownloadProgress {
fileId: string;
fileName: string;
loaded: number;
total: number;
progress: number;
}
export interface SharePointBatchProgress {
completed: number;
total: number;
currentFile?: string;
failed: string[];
}
export const useSharePointFileDownload = (): UseMutationResult<
File,
unknown,
{
file: SharePointFile;
accessToken: string;
onProgress?: (progress: SharePointDownloadProgress) => void;
}
> => {
return useMutation({
mutationFn: async ({ file, accessToken, onProgress }) => {
const downloadUrl =
file.downloadUrl ||
`https://graph.microsoft.com/v1.0/drives/${file.driveId}/items/${file.itemId}/content`;
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
const contentLength = parseInt(response.headers.get('content-length') || '0');
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
if (onProgress) {
onProgress({
fileId: file.id,
fileName: file.name,
loaded: receivedLength,
total: contentLength || file.size,
progress: Math.round((receivedLength / (contentLength || file.size)) * 100),
});
}
}
const allChunks = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}
const contentType =
response.headers.get('content-type') || getMimeTypeFromFileName(file.name);
const blob = new Blob([allChunks], { type: contentType });
const downloadedFile = new File([blob], file.name, {
type: contentType,
lastModified: Date.now(),
});
return downloadedFile;
},
retry: 2,
});
};
export const useSharePointBatchDownload = (): UseMutationResult<
File[],
unknown,
{
files: SharePointFile[];
accessToken: string;
onProgress?: (progress: SharePointBatchProgress) => void;
},
unknown
> => {
return useMutation({
mutationFn: async ({ files, accessToken, onProgress }) => {
const downloadedFiles: File[] = [];
const failed: string[] = [];
let completed = 0;
const concurrencyLimit = 3;
const chunks: SharePointFile[][] = [];
for (let i = 0; i < files.length; i += concurrencyLimit) {
chunks.push(files.slice(i, i + concurrencyLimit));
}
for (const chunk of chunks) {
const chunkPromises = chunk.map(async (file) => {
try {
const downloadUrl =
file.downloadUrl ||
`https://graph.microsoft.com/v1.0/drives/${file.driveId}/items/${file.itemId}/content`;
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const contentType =
response.headers.get('content-type') || getMimeTypeFromFileName(file.name);
const downloadedFile = new File([blob], file.name, {
type: contentType,
lastModified: Date.now(),
});
completed++;
onProgress?.({
completed,
total: files.length,
currentFile: file.name,
failed,
});
return downloadedFile;
} catch (error) {
console.error(`Failed to download ${file.name}:`, error);
failed.push(file.name);
completed++;
onProgress?.({
completed,
total: files.length,
currentFile: `Error: ${file.name}`,
failed,
});
throw error;
}
});
const chunkResults = await Promise.allSettled(chunkPromises);
chunkResults.forEach((result) => {
if (result.status === 'fulfilled') {
downloadedFiles.push(result.value);
}
});
}
if (failed.length > 0) {
console.warn(`Failed to download ${failed.length} files:`, failed);
}
return downloadedFiles;
},
retry: 1,
});
};
function getMimeTypeFromFileName(fileName: string): string {
const extension = fileName.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
// Documents
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain',
csv: 'text/csv',
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
webp: 'image/webp',
// Archives
zip: 'application/zip',
rar: 'application/x-rar-compressed',
// Media
mp4: 'video/mp4',
mp3: 'audio/mpeg',
wav: 'audio/wav',
};
return mimeTypes[extension || ''] || 'application/octet-stream';
}
export { getMimeTypeFromFileName };

View file

@ -5,3 +5,7 @@ export { default as useFileDeletion } from './useFileDeletion';
export { default as useUpdateFiles } from './useUpdateFiles'; export { default as useUpdateFiles } from './useUpdateFiles';
export { default as useDragHelpers } from './useDragHelpers'; export { default as useDragHelpers } from './useDragHelpers';
export { default as useFileMap } from './useFileMap'; export { default as useFileMap } from './useFileMap';
export { default as useSharePointPicker } from './useSharePointPicker';
export { default as useSharePointDownload } from './useSharePointDownload';
export { default as useSharePointFileHandling } from './useSharePointFileHandling';
export { default as useSharePointToken } from './useSharePointToken';

View file

@ -0,0 +1,129 @@
import { useCallback, useState } from 'react';
import { useToastContext } from '@librechat/client';
import type { SharePointFile, SharePointBatchProgress } from '~/data-provider/Files';
import { useSharePointBatchDownload } from '~/data-provider/Files';
import useSharePointToken from './useSharePointToken';
interface UseSharePointDownloadProps {
onFilesDownloaded?: (files: File[]) => void | Promise<void>;
onError?: (error: Error) => void;
}
interface UseSharePointDownloadReturn {
downloadSharePointFiles: (files: SharePointFile[]) => Promise<File[]>;
isDownloading: boolean;
downloadProgress: SharePointBatchProgress | null;
error: string | null;
}
export default function useSharePointDownload({
onFilesDownloaded,
onError,
}: UseSharePointDownloadProps = {}): UseSharePointDownloadReturn {
const { showToast } = useToastContext();
const [downloadProgress, setDownloadProgress] = useState<SharePointBatchProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const { token, refetch: refetchToken } = useSharePointToken({
enabled: false,
purpose: 'Download',
});
const batchDownloadMutation = useSharePointBatchDownload();
const downloadSharePointFiles = useCallback(
async (files: SharePointFile[]): Promise<File[]> => {
if (!files || files.length === 0) {
throw new Error('No files provided for download');
}
setError(null);
setDownloadProgress({ completed: 0, total: files.length, failed: [] });
try {
let accessToken = token?.access_token;
if (!accessToken) {
showToast({
message: 'Getting SharePoint access token...',
status: 'info',
duration: 2000,
});
const tokenResult = await refetchToken();
accessToken = tokenResult.data?.access_token;
if (!accessToken) {
throw new Error('Failed to obtain SharePoint access token');
}
}
showToast({
message: `Downloading ${files.length} file(s) from SharePoint...`,
status: 'info',
duration: 3000,
});
const downloadedFiles = await batchDownloadMutation.mutateAsync({
files,
accessToken,
onProgress: (progress) => {
setDownloadProgress(progress);
if (files.length > 5 && progress.completed % 3 === 0) {
showToast({
message: `Downloaded ${progress.completed}/${progress.total} files...`,
status: 'info',
duration: 1000,
});
}
},
});
if (downloadedFiles.length > 0) {
const failedCount = files.length - downloadedFiles.length;
const successMessage =
failedCount > 0
? `Downloaded ${downloadedFiles.length}/${files.length} files from SharePoint (${failedCount} failed)`
: `Successfully downloaded ${downloadedFiles.length} file(s) from SharePoint`;
showToast({
message: successMessage,
status: failedCount > 0 ? 'warning' : 'success',
duration: 4000,
});
if (onFilesDownloaded) {
await onFilesDownloaded(downloadedFiles);
}
}
setDownloadProgress(null);
return downloadedFiles;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown download error';
setError(errorMessage);
showToast({
message: `SharePoint download failed: ${errorMessage}`,
status: 'error',
duration: 5000,
});
if (onError) {
onError(error instanceof Error ? error : new Error(errorMessage));
}
setDownloadProgress(null);
throw error;
}
},
[token, showToast, batchDownloadMutation, onFilesDownloaded, onError, refetchToken],
);
return {
downloadSharePointFiles,
isDownloading: batchDownloadMutation.isLoading,
downloadProgress,
error,
};
}

View file

@ -0,0 +1,57 @@
import { useCallback } from 'react';
import useFileHandling from './useFileHandling';
import useSharePointDownload from './useSharePointDownload';
import type { SharePointFile } from '~/data-provider/Files/sharepoint';
interface UseSharePointFileHandlingProps {
fileSetter?: any;
fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>;
overrideEndpoint?: any;
overrideEndpointFileConfig?: any;
toolResource?: string;
}
interface UseSharePointFileHandlingReturn {
handleSharePointFiles: (files: SharePointFile[]) => Promise<void>;
isProcessing: boolean;
downloadProgress: any;
error: string | null;
}
export default function useSharePointFileHandling(
props?: UseSharePointFileHandlingProps,
): UseSharePointFileHandlingReturn {
const { handleFiles } = useFileHandling(props);
const { downloadSharePointFiles, isDownloading, downloadProgress, error } = useSharePointDownload(
{
onFilesDownloaded: async (downloadedFiles: File[]) => {
const fileArray = Array.from(downloadedFiles);
await handleFiles(fileArray, props?.toolResource);
},
onError: (error) => {
console.error('SharePoint download failed:', error);
},
},
);
const handleSharePointFiles = useCallback(
async (sharePointFiles: SharePointFile[]) => {
try {
await downloadSharePointFiles(sharePointFiles);
} catch (error) {
console.error('SharePoint file handling error:', error);
throw error;
}
},
[downloadSharePointFiles],
);
return {
handleSharePointFiles,
isProcessing: isDownloading,
downloadProgress,
error,
};
}

View file

@ -0,0 +1,383 @@
import { useRef, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useToastContext } from '@librechat/client';
import type { SPPickerConfig } from '~/components/SidePanel/Agents/config';
import { useLocalize, useAuthContext } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import useSharePointToken from './useSharePointToken';
import store from '~/store';
interface UseSharePointPickerProps {
containerNode: HTMLDivElement | null;
onFilesSelected?: (files: any[]) => void;
onClose?: () => void;
disabled?: boolean;
maxSelectionCount?: number;
}
interface UseSharePointPickerReturn {
openSharePointPicker: () => void;
closeSharePointPicker: () => void;
error: string | null;
cleanup: () => void;
isTokenLoading: boolean;
}
export default function useSharePointPicker({
containerNode,
onFilesSelected,
onClose,
disabled = false,
maxSelectionCount = 10,
}: UseSharePointPickerProps): UseSharePointPickerReturn {
const [langcode] = useRecoilState(store.lang);
const { user } = useAuthContext();
const { showToast } = useToastContext();
const localize = useLocalize();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const portRef = useRef<MessagePort | null>(null);
const channelIdRef = useRef<string>('');
const { data: startupConfig } = useGetStartupConfig();
const sharePointBaseUrl = startupConfig?.sharePointBaseUrl;
const isEntraIdUser = user?.provider === 'openid';
const {
token,
isLoading: isTokenLoading,
error: tokenError,
} = useSharePointToken({
enabled: isEntraIdUser && !disabled && !!sharePointBaseUrl,
purpose: 'Pick',
});
const generateChannelId = useCallback(() => {
return `sharepoint-picker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}, []);
const portMessageHandler = useCallback(
async (message: MessageEvent) => {
const port = portRef.current;
if (!port) {
console.error('No port available for communication');
return;
}
try {
switch (message.data.type) {
case 'notification':
console.log('SharePoint picker notification:', message.data);
break;
case 'command': {
// Always acknowledge the command first
port.postMessage({
type: 'acknowledge',
id: message.data.id,
});
const command = message.data.data;
console.log('SharePoint picker command:', command);
switch (command.command) {
case 'authenticate':
console.log('Authentication requested, providing token');
console.log('Command details:', command); // Add this line
console.log('Token available:', !!token?.access_token); // Add this line
if (token?.access_token) {
port.postMessage({
type: 'result',
id: message.data.id,
data: {
result: 'token',
token: token.access_token,
},
});
} else {
console.error('No token available for authentication');
port.postMessage({
type: 'result',
id: message.data.id,
data: {
result: 'error',
error: {
code: 'noToken',
message: 'No authentication token available',
},
},
});
}
break;
case 'close':
console.log('Close command received');
port.postMessage({
type: 'result',
id: message.data.id,
data: {
result: 'success',
},
});
onClose?.();
break;
case 'pick': {
console.log('Files picked from SharePoint:', command);
const items = command.items || command.files || [];
console.log('Extracted items:', items);
if (items && items.length > 0) {
const selectedFiles = items.map((item: any) => ({
id: item.id || item.shareId || item.driveItem?.id,
name: item.name || item.driveItem?.name,
size: item.size || item.driveItem?.size,
webUrl: item.webUrl || item.driveItem?.webUrl,
downloadUrl:
item.downloadUrl || item.driveItem?.['@microsoft.graph.downloadUrl'],
driveId:
item.driveId ||
item.parentReference?.driveId ||
item.driveItem?.parentReference?.driveId,
itemId: item.id || item.driveItem?.id,
sharePointItem: item,
}));
console.log('Processed SharePoint files:', selectedFiles);
if (onFilesSelected) {
onFilesSelected(selectedFiles);
}
showToast({
message: `Selected ${selectedFiles.length} file(s) from SharePoint`,
status: 'success',
});
}
port.postMessage({
type: 'result',
id: message.data.id,
data: {
result: 'success',
},
});
break;
}
default:
console.warn(`Unsupported command: ${command.command}`);
port.postMessage({
type: 'result',
id: message.data.id,
data: {
result: 'error',
error: {
code: 'unsupportedCommand',
message: command.command,
},
},
});
break;
}
break;
}
default:
console.log('Unknown message type:', message.data.type);
break;
}
} catch (error) {
console.error('Error processing port message:', error);
}
},
[token, onFilesSelected, showToast, onClose],
);
// Initialization message handler - establishes MessagePort communication
const initMessageHandler = useCallback(
(event: MessageEvent) => {
console.log('=== SharePoint picker init message received ===');
console.log('Event source:', event.source);
console.log('Event data:', event.data);
console.log('Expected channelId:', channelIdRef.current);
// Check if this message is from our iframe
if (event.source && event.source === iframeRef.current?.contentWindow) {
const message = event.data;
if (message.type === 'initialize' && message.channelId === channelIdRef.current) {
console.log('Establishing MessagePort communication');
// Get the MessagePort from the event
portRef.current = event.ports[0];
if (portRef.current) {
// Set up the port message listener
portRef.current.addEventListener('message', portMessageHandler);
portRef.current.start();
// Send activate message to start the picker
portRef.current.postMessage({
type: 'activate',
});
console.log('MessagePort established and activated');
} else {
console.error('No MessagePort found in initialize event');
}
}
}
},
[portMessageHandler],
);
const openSharePointPicker = async () => {
if (!token) {
showToast({
message: 'Unable to access SharePoint. Please ensure you are logged in with Microsoft.',
status: 'error',
});
return;
}
if (!containerNode) {
console.error('No container ref provided for SharePoint picker');
return;
}
try {
const channelId = generateChannelId();
channelIdRef.current = channelId;
console.log('=== SharePoint File Picker v8 (MessagePort) ===');
console.log('Token available:', {
hasToken: !!token.access_token,
tokenType: token.token_type,
expiresIn: token.expires_in,
scopes: token.scope,
});
console.log('Channel ID:', channelId);
const pickerOptions: SPPickerConfig = {
sdk: '8.0',
entry: {
sharePoint: {},
},
messaging: {
origin: window.location.origin,
channelId: channelId,
},
authentication: {
enabled: false, // Host app handles authentication
},
typesAndSources: {
mode: 'files',
pivots: {
oneDrive: true,
recent: true,
shared: true,
sharedLibraries: true,
myOrganization: true,
site: true,
},
},
selection: {
mode: 'multiple',
maximumCount: maxSelectionCount,
},
title: localize('com_files_sharepoint_picker_title'),
commands: {
upload: {
enabled: false,
},
createFolder: {
enabled: false,
},
},
search: { enabled: true },
};
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.background = '#F5F5F5';
iframe.style.border = 'none';
iframe.title = 'SharePoint File Picker';
iframe.setAttribute(
'sandbox',
'allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
);
iframeRef.current = iframe;
containerNode.innerHTML = '';
containerNode.appendChild(iframe);
activeEventListenerRef.current = initMessageHandler;
window.addEventListener('message', initMessageHandler);
iframe.src = 'about:blank';
iframe.onload = () => {
const win = iframe.contentWindow;
if (!win) return;
const queryString = new URLSearchParams({
filePicker: JSON.stringify(pickerOptions),
locale: langcode || 'en-US',
});
const url = sharePointBaseUrl + `/_layouts/15/FilePicker.aspx?${queryString}`;
const form = win.document.createElement('form');
form.setAttribute('action', url);
form.setAttribute('method', 'POST');
const tokenInput = win.document.createElement('input');
tokenInput.setAttribute('type', 'hidden');
tokenInput.setAttribute('name', 'access_token');
tokenInput.setAttribute('value', token.access_token);
form.appendChild(tokenInput);
win.document.body.appendChild(form);
form.submit();
};
} catch (error) {
console.error('SharePoint file picker error:', error);
showToast({
message: 'Failed to open SharePoint file picker.',
status: 'error',
});
}
};
const activeEventListenerRef = useRef<((event: MessageEvent) => void) | null>(null);
const cleanup = useCallback(() => {
if (activeEventListenerRef.current) {
window.removeEventListener('message', activeEventListenerRef.current);
activeEventListenerRef.current = null;
}
if (portRef.current) {
portRef.current.close();
portRef.current = null;
}
if (containerNode) {
containerNode.innerHTML = '';
}
channelIdRef.current = '';
}, [containerNode]);
const handleDialogClose = useCallback(() => {
cleanup();
}, [cleanup]);
const isAvailable = startupConfig?.sharePointFilePickerEnabled && isEntraIdUser && !tokenError;
return {
openSharePointPicker: isAvailable ? openSharePointPicker : () => {},
closeSharePointPicker: handleDialogClose,
error: tokenError ? 'Failed to authenticate with SharePoint' : null,
cleanup,
isTokenLoading,
};
}

View file

@ -0,0 +1,46 @@
import { useAuthContext } from '~/hooks/AuthContext';
import { useGraphTokenQuery, useGetStartupConfig } from '~/data-provider';
interface UseSharePointTokenProps {
enabled?: boolean;
purpose: 'Pick' | 'Download';
}
interface UseSharePointTokenReturn {
token: any;
isLoading: boolean;
error: any;
refetch: () => Promise<any>;
}
export default function useSharePointToken({
enabled = true,
purpose,
}: UseSharePointTokenProps): UseSharePointTokenReturn {
const { user } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const sharePointBaseUrl = startupConfig?.sharePointBaseUrl;
const sharePointPickerGraphScope = startupConfig?.sharePointPickerGraphScope;
const sharePointPickerSharePointScope = startupConfig?.sharePointPickerSharePointScope;
const isEntraIdUser = user?.provider === 'openid';
const graphScopes =
purpose === 'Pick' ? sharePointPickerSharePointScope : sharePointPickerGraphScope;
const {
data: token,
isLoading,
error,
refetch,
} = useGraphTokenQuery({
scopes: graphScopes,
enabled: enabled && isEntraIdUser && !!sharePointBaseUrl,
});
return {
token,
isLoading,
error,
refetch,
};
}

View file

@ -1070,6 +1070,14 @@
"com_ui_upload_success": "Successfully uploaded file", "com_ui_upload_success": "Successfully uploaded file",
"com_ui_upload_type": "Select Upload Type", "com_ui_upload_type": "Select Upload Type",
"com_ui_usage": "Usage", "com_ui_usage": "Usage",
"com_files_upload_local_machine": "From Local Computer",
"com_files_upload_sharepoint": "From SharePoint",
"com_files_sharepoint_picker_title": "Pick Files",
"com_files_downloading": "Downloading Files",
"com_files_download_progress": "{{0}} of {{1}} files",
"com_files_download_percent_complete": "{{0}}% complete",
"com_files_download_failed": "{{0}} files failed",
"com_files_preparing_download": "Preparing download...",
"com_ui_use_2fa_code": "Use 2FA Code Instead", "com_ui_use_2fa_code": "Use 2FA Code Instead",
"com_ui_use_backup_code": "Use Backup Code Instead", "com_ui_use_backup_code": "Use Backup Code Instead",
"com_ui_use_memory": "Use memory", "com_ui_use_memory": "Use memory",

View file

@ -2799,3 +2799,7 @@ html {
.custom-style-2 { .custom-style-2 {
padding: 12px; padding: 12px;
} }
.sharepoint-picker-bg{
background-color: #F5F5F5;
}

View file

@ -1,4 +1,4 @@
import { SheetPaths, TextPaths, FilePaths, CodePaths } from '~/components/svg'; import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client';
import { import {
megabyte, megabyte,
QueryKeys, QueryKeys,

264
config/flush-cache.js Normal file
View file

@ -0,0 +1,264 @@
#!/usr/bin/env node
/**
* LibreChat Cache Flush Utility
*
* This script flushes the cache store used by LibreChat, whether it's
* Redis (if configured) or file-based cache.
*
* Usage:
* npm run flush-cache
* node config/flush-cache.js
* node config/flush-cache.js --help
*/
const path = require('path');
const fs = require('fs');
// Set up environment
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const { USE_REDIS, REDIS_URI, REDIS_KEY_PREFIX } = process.env;
// Simple utility function
const isEnabled = (value) => value === 'true' || value === true;
async function showHelp() {
console.log(`
LibreChat Cache Flush Utility
DESCRIPTION:
Flushes the cache store used by LibreChat. Automatically detects
whether Redis or file-based cache is being used and flushes accordingly.
USAGE:
npm run flush-cache
node config/flush-cache.js [options]
OPTIONS:
--help, -h Show this help message
--dry-run Show what would be flushed without actually doing it
--verbose, -v Show detailed output
CACHE TYPES:
Redis Cache: Flushes all keys with the configured Redis prefix
File Cache: Removes ./data/logs.json and ./data/violations.json
WHAT GETS FLUSHED:
User sessions and authentication tokens
Configuration cache
Model queries cache
Rate limiting data
Conversation titles cache
File upload progress
SharePoint tokens
And more...
NOTE: This will log out all users and may require them to re-authenticate.
`);
}
async function flushRedisCache(dryRun = false, verbose = false) {
try {
console.log('🔍 Redis cache detected');
if (verbose) {
console.log(` URI: ${REDIS_URI ? REDIS_URI.replace(/\/\/.*@/, '//***:***@') : 'Not set'}`);
console.log(` Prefix: ${REDIS_KEY_PREFIX || 'None'}`);
}
// Create Redis client directly
const Redis = require('ioredis');
let redis;
// Handle cluster vs single Redis
if (process.env.USE_REDIS_CLUSTER === 'true') {
const hosts = REDIS_URI.split(',').map((uri) => {
const [host, port] = uri.split(':');
return { host, port: parseInt(port) || 6379 };
});
redis = new Redis.Cluster(hosts);
} else {
redis = new Redis(REDIS_URI);
}
if (dryRun) {
console.log('🔍 [DRY RUN] Would flush Redis cache');
try {
const keys = await redis.keys('*');
console.log(` Would delete ${keys.length} keys`);
if (verbose && keys.length > 0) {
console.log(
' Sample keys:',
keys.slice(0, 10).join(', ') + (keys.length > 10 ? '...' : ''),
);
}
} catch (error) {
console.log(' Could not fetch keys for preview:', error.message);
}
await redis.disconnect();
return true;
}
// Get key count before flushing
let keyCount = 0;
try {
const keys = await redis.keys('*');
keyCount = keys.length;
} catch (error) {
// Continue with flush even if we can't count keys
}
// Flush the Redis cache
await redis.flushdb();
console.log('✅ Redis cache flushed successfully');
if (keyCount > 0) {
console.log(` Deleted ${keyCount} keys`);
}
await redis.disconnect();
return true;
} catch (error) {
console.error('❌ Error flushing Redis cache:', error.message);
if (verbose) {
console.error(' Full error:', error);
}
return false;
}
}
async function flushFileCache(dryRun = false, verbose = false) {
const dataDir = path.join(__dirname, '..', 'data');
const filesToClear = [path.join(dataDir, 'logs.json'), path.join(dataDir, 'violations.json')];
console.log('🔍 Checking file-based cache');
if (dryRun) {
console.log('🔍 [DRY RUN] Would flush file cache');
for (const filePath of filesToClear) {
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(
` Would delete: ${path.basename(filePath)} (${(stats.size / 1024).toFixed(1)} KB)`,
);
}
}
return true;
}
let deletedCount = 0;
let totalSize = 0;
for (const filePath of filesToClear) {
try {
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
totalSize += stats.size;
fs.unlinkSync(filePath);
deletedCount++;
if (verbose) {
console.log(
` ✅ Deleted ${path.basename(filePath)} (${(stats.size / 1024).toFixed(1)} KB)`,
);
}
}
} catch (error) {
if (verbose) {
console.log(` ❌ Failed to delete ${path.basename(filePath)}: ${error.message}`);
}
}
}
if (deletedCount > 0) {
console.log('✅ File cache flushed successfully');
console.log(` Deleted ${deletedCount} cache files (${(totalSize / 1024).toFixed(1)} KB)`);
} else {
console.log(' No file cache to flush');
}
return true;
}
async function restartRecommendation() {
console.log('\n💡 RECOMMENDATION:');
console.log(' For complete cache clearing, especially for in-memory caches,');
console.log(' consider restarting the LibreChat backend:');
console.log('');
console.log(' npm run backend:stop');
console.log(' npm run backend:dev');
console.log('');
}
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const verbose = args.includes('--verbose') || args.includes('-v');
const help = args.includes('--help') || args.includes('-h');
if (help) {
await showHelp();
return;
}
console.log('🧹 LibreChat Cache Flush Utility');
console.log('================================');
if (dryRun) {
console.log('🔍 DRY RUN MODE - No actual changes will be made\n');
}
let success = true;
const isRedisEnabled = isEnabled(USE_REDIS) && REDIS_URI;
// Flush the appropriate cache type
if (isRedisEnabled) {
success = (await flushRedisCache(dryRun, verbose)) && success;
} else {
console.log(' Redis not configured, using file-based cache only');
}
// Always check file cache
success = (await flushFileCache(dryRun, verbose)) && success;
console.log('\n' + '='.repeat(50));
if (success) {
if (dryRun) {
console.log('✅ Cache flush preview completed');
console.log(' Run without --dry-run to actually flush the cache');
} else {
console.log('✅ Cache flush completed successfully');
console.log('⚠️ Note: All users will need to re-authenticate');
}
if (!isRedisEnabled) {
await restartRecommendation();
}
} else {
console.log('❌ Cache flush completed with errors');
console.log(' Check the output above for details');
process.exit(1);
}
}
// Handle errors gracefully
process.on('unhandledRejection', (error) => {
console.error('❌ Unhandled error:', error);
process.exit(1);
});
process.on('uncaughtException', (error) => {
console.error('❌ Uncaught exception:', error);
process.exit(1);
});
// Run the main function
if (require.main === module) {
main().catch((error) => {
console.error('❌ Fatal error:', error);
process.exit(1);
});
}
module.exports = { flushRedisCache, flushFileCache };

View file

@ -73,7 +73,8 @@
"b:test:api": "cd api && bun run b:test", "b:test:api": "cd api && bun run b:test",
"b:balance": "bun config/add-balance.js", "b:balance": "bun config/add-balance.js",
"b:list-balances": "bun config/list-balances.js", "b:list-balances": "bun config/list-balances.js",
"reset-terms": "node config/reset-terms.js" "reset-terms": "node config/reset-terms.js",
"flush-cache": "node config/flush-cache.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -29,7 +29,8 @@ interface DropdownProps {
type MenuProps = Omit< type MenuProps = Omit<
DropdownProps, DropdownProps,
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState' 'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
>; > &
Ariakit.MenuProps;
const DropdownPopup: React.FC<DropdownProps> = ({ const DropdownPopup: React.FC<DropdownProps> = ({
trigger, trigger,
@ -70,7 +71,9 @@ const Menu: React.FC<MenuProps> = ({
finalFocus, finalFocus,
unmountOnHide, unmountOnHide,
preserveTabOrder, preserveTabOrder,
...props
}) => { }) => {
const menuStore = Ariakit.useMenuStore();
const menu = Ariakit.useMenuContext(); const menu = Ariakit.useMenuContext();
return ( return (
<Ariakit.Menu <Ariakit.Menu
@ -83,13 +86,53 @@ const Menu: React.FC<MenuProps> = ({
unmountOnHide={unmountOnHide} unmountOnHide={unmountOnHide}
preserveTabOrder={preserveTabOrder} preserveTabOrder={preserveTabOrder}
className={cn('popover-ui z-50', className)} className={cn('popover-ui z-50', className)}
{...props}
> >
{items {items
.filter((item) => item.show !== false) .filter((item) => item.show !== false)
.map((item, index) => { .map((item, index) => {
const { subItems } = item;
if (item.separate === true) { if (item.separate === true) {
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />; return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
} }
if (subItems && subItems.length > 0) {
return (
<Ariakit.MenuProvider
store={menuStore}
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}-provider`}
>
<Ariakit.MenuButton
className={cn(
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
)}
disabled={item.disabled}
id={item.id}
render={item.render}
ref={item.ref}
// hideOnClick={item.hideOnClick}
>
<span className="flex items-center gap-2">
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
</span>
<Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" />
</Ariakit.MenuButton>
<Menu
items={subItems}
menuId={`${menuId}-${index}`}
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
gutter={12}
portal={true}
/>
</Ariakit.MenuProvider>
);
}
return ( return (
<Ariakit.MenuItem <Ariakit.MenuItem
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`} key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}

View file

@ -0,0 +1,8 @@
import React from 'react';
export default function SharePointIcon({ className = '' }) {
return (
<svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" className={className}>
<path d="M24 13.5q0 1.242-.475 2.332-.474 1.09-1.289 1.904-.814.815-1.904 1.29-1.09.474-2.332.474-.762 0-1.523-.2-.106.997-.557 1.858-.451.862-1.154 1.494-.704.633-1.606.99-.902.358-1.91.358-1.09 0-2.045-.416-.955-.416-1.664-1.125-.709-.709-1.125-1.664Q6 19.84 6 18.75q0-.188.018-.375.017-.188.04-.375H.997q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.54q.14-1.277.726-2.373.586-1.096 1.488-1.904Q7.652.914 8.807.457 9.96 0 11.25 0q1.395 0 2.625.533T16.02 1.98q.914.915 1.447 2.145T18 6.75q0 .188-.012.375-.011.188-.035.375 1.242 0 2.344.469 1.101.468 1.928 1.277.826.809 1.3 1.904Q24 12.246 24 13.5zm-12.75-12q-.973 0-1.857.34-.885.34-1.577.943-.691.604-1.154 1.43Q6.2 5.039 6.06 6h4.945q.41 0 .703.293t.293.703v4.945l.21-.035q.212-.75.61-1.424.399-.673.944-1.218.545-.545 1.213-.944.668-.398 1.43-.61.093-.503.093-.96 0-1.09-.416-2.045-.416-.955-1.125-1.664-.709-.709-1.664-1.125Q12.34 1.5 11.25 1.5zM6.117 15.902q.54 0 1.06-.111.522-.111.932-.37.41-.257.662-.679.252-.422.252-1.055 0-.632-.263-1.054-.264-.422-.662-.703-.399-.282-.856-.463l-.855-.34q-.399-.158-.662-.334-.264-.176-.264-.445 0-.2.14-.323.141-.123.335-.193.193-.07.404-.094.21-.023.351-.023.598 0 1.055.152.457.153.95.457V8.543q-.282-.082-.522-.14-.24-.06-.475-.1-.234-.041-.486-.059-.252-.017-.557-.017-.515 0-1.054.117-.54.117-.979.375-.44.258-.715.68-.275.421-.275 1.03 0 .598.263.997.264.398.663.68.398.28.855.474l.856.363q.398.17.662.358.263.187.263.457 0 .222-.123.351-.123.13-.31.2-.188.07-.393.087-.205.018-.369.018-.703 0-1.248-.234-.545-.235-1.107-.621v1.875q1.195.468 2.472.468zM11.25 22.5q.773 0 1.453-.293t1.19-.803q.51-.51.808-1.195.299-.686.299-1.459 0-.668-.223-1.277-.222-.61-.62-1.096-.4-.486-.95-.826-.55-.34-1.207-.48v1.933q0 .41-.293.703t-.703.293H7.57q-.07.375-.07.75 0 .773.293 1.459t.803 1.195q.51.51 1.195.803.686.293 1.459.293zM18 18q.926 0 1.746-.352.82-.351 1.436-.966.615-.616.966-1.43.352-.815.352-1.752 0-.926-.352-1.746-.351-.82-.966-1.436-.616-.615-1.436-.966Q18.926 9 18 9t-1.74.357q-.815.358-1.43.973t-.973 1.43q-.357.814-.357 1.74 0 .129.006.258t.017.258q.551.27 1.02.65t.838.855q.369.475.627 1.026.258.55.387 1.148Q17.18 18 18 18Z" />
</svg>
);
}

View file

@ -65,3 +65,9 @@ export { default as PersonalizationIcon } from './PersonalizationIcon';
export { default as MCPIcon } from './MCPIcon'; export { default as MCPIcon } from './MCPIcon';
export { default as VectorIcon } from './VectorIcon'; export { default as VectorIcon } from './VectorIcon';
export { default as SquirclePlusIcon } from './SquirclePlusIcon'; export { default as SquirclePlusIcon } from './SquirclePlusIcon';
export { default as CodePaths } from './CodePaths';
export { default as FileIcon } from './FileIcon';
export { default as FilePaths } from './FilePaths';
export { default as SheetPaths } from './SheetPaths';
export { default as TextPaths } from './TextPaths';
export { default as SharePointIcon } from './SharePointIcon';

View file

@ -305,3 +305,7 @@ export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
export const memories = () => '/api/memories'; export const memories = () => '/api/memories';
export const memory = (key: string) => `${memories()}/${encodeURIComponent(key)}`; export const memory = (key: string) => `${memories()}/${encodeURIComponent(key)}`;
export const memoryPreferences = () => `${memories()}/preferences`; export const memoryPreferences = () => `${memories()}/preferences`;
// SharePoint Graph API Token
export const graphToken = (scopes: string) =>
`/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`;

View file

@ -597,6 +597,11 @@ export type TStartupConfig = {
instanceProjectId: string; instanceProjectId: string;
bundlerURL?: string; bundlerURL?: string;
staticBundlerURL?: string; staticBundlerURL?: string;
sharePointFilePickerEnabled?: boolean;
sharePointBaseUrl?: string;
sharePointPickerGraphScope?: string;
sharePointPickerSharePointScope?: string;
openidReuseTokens?: boolean;
webSearch?: { webSearch?: {
searchProvider?: SearchProviders; searchProvider?: SearchProviders;
scraperType?: ScraperTypes; scraperType?: ScraperTypes;

View file

@ -858,3 +858,8 @@ export const createMemory = (data: {
}): Promise<{ created: boolean; memory: q.TUserMemory }> => { }): Promise<{ created: boolean; memory: q.TUserMemory }> => {
return request.post(endpoints.memories(), data); return request.post(endpoints.memories(), data);
}; };
// SharePoint Graph API Token
export function getGraphApiToken(params: q.GraphTokenParams): Promise<q.GraphTokenResponse> {
return request.get(endpoints.graphToken(params.scopes));
}

View file

@ -50,6 +50,7 @@ export enum QueryKeys {
banner = 'banner', banner = 'banner',
/* Memories */ /* Memories */
memories = 'memories', memories = 'memories',
graphToken = 'graphToken',
} }
// Dynamic query keys that require parameters // Dynamic query keys that require parameters

View file

@ -147,3 +147,15 @@ export interface MCPAuthValuesResponse {
serverName: string; serverName: string;
authValueFlags: Record<string, boolean>; authValueFlags: Record<string, boolean>;
} }
/* SharePoint Graph API Token */
export type GraphTokenParams = {
scopes: string;
};
export type GraphTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
};