From a955097fafafece62601710433924d0d9d7485be Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 25 Jul 2025 00:03:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=81=20feat:=20Integrate=20SharePoint?= =?UTF-8?q?=20File=20Picker=20and=20Download=20Workflow=20(#8651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 15 + api/server/controllers/AuthController.js | 46 ++ api/server/routes/auth.js | 3 + api/server/routes/config.js | 8 + api/server/services/GraphTokenService.js | 86 +++ client/src/common/menus.ts | 1 + .../Chat/Input/Files/AttachFileMenu.tsx | 189 ++++-- .../Chat/Input/Files/FilePreview.tsx | 3 +- .../SharePoint/SharePointPickerDialog.tsx | 137 ++++ client/src/components/SharePoint/index.ts | 1 + .../SidePanel/Agents/FileContext.tsx | 121 +++- .../SidePanel/Agents/FileSearch.tsx | 125 +++- .../src/components/SidePanel/Agents/config.ts | 640 ++++++++++++++++++ client/src/components/svg/index.ts | 5 - client/src/data-provider/Auth/queries.ts | 24 + client/src/data-provider/Files/index.ts | 1 + client/src/data-provider/Files/sharepoint.ts | 232 +++++++ client/src/hooks/Files/index.ts | 4 + .../src/hooks/Files/useSharePointDownload.ts | 129 ++++ .../hooks/Files/useSharePointFileHandling.ts | 57 ++ client/src/hooks/Files/useSharePointPicker.ts | 383 +++++++++++ client/src/hooks/Files/useSharePointToken.ts | 46 ++ client/src/locales/en/translation.json | 8 + client/src/style.css | 4 + client/src/utils/files.ts | 2 +- config/flush-cache.js | 264 ++++++++ package.json | 3 +- .../client/src/components/DropdownPopup.tsx | 45 +- .../client/src/svgs}/CodePaths.tsx | 0 .../client/src/svgs}/FileIcon.tsx | 0 .../client/src/svgs}/FilePaths.tsx | 0 packages/client/src/svgs/SharePointIcon.tsx | 8 + .../client/src/svgs}/SheetPaths.tsx | 0 .../client/src/svgs}/TextPaths.tsx | 0 packages/client/src/svgs/index.ts | 6 + packages/data-provider/src/api-endpoints.ts | 4 + packages/data-provider/src/config.ts | 5 + packages/data-provider/src/data-service.ts | 5 + packages/data-provider/src/keys.ts | 1 + packages/data-provider/src/types/queries.ts | 12 + 40 files changed, 2500 insertions(+), 123 deletions(-) create mode 100644 api/server/services/GraphTokenService.js create mode 100644 client/src/components/SharePoint/SharePointPickerDialog.tsx create mode 100644 client/src/components/SharePoint/index.ts create mode 100644 client/src/components/SidePanel/Agents/config.ts delete mode 100644 client/src/components/svg/index.ts create mode 100644 client/src/data-provider/Files/sharepoint.ts create mode 100644 client/src/hooks/Files/useSharePointDownload.ts create mode 100644 client/src/hooks/Files/useSharePointFileHandling.ts create mode 100644 client/src/hooks/Files/useSharePointPicker.ts create mode 100644 client/src/hooks/Files/useSharePointToken.ts create mode 100644 config/flush-cache.js rename {client/src/components/svg => packages/client/src/svgs}/CodePaths.tsx (100%) rename {client/src/components/svg => packages/client/src/svgs}/FileIcon.tsx (100%) rename {client/src/components/svg => packages/client/src/svgs}/FilePaths.tsx (100%) create mode 100644 packages/client/src/svgs/SharePointIcon.tsx rename {client/src/components/svg => packages/client/src/svgs}/SheetPaths.tsx (100%) rename {client/src/components/svg => packages/client/src/svgs}/TextPaths.tsx (100%) diff --git a/.env.example b/.env.example index 819b0dfab..df6bf872e 100644 --- a/.env.example +++ b/.env.example @@ -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 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 # Note: If OpenID is enabled, SAML authentication will be automatically disabled. diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 3dbb1a2f3..b06ba8a8c 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -12,6 +12,7 @@ const { } = require('~/server/services/AuthService'); const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models'); const { getOpenIdConfig } = require('~/strategies'); +const { getGraphApiToken } = require('~/server/services/GraphTokenService'); const registrationController = async (req, res) => { 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 = { refreshController, registrationController, resetPasswordController, resetPasswordRequestController, + graphTokenController, }; diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index 187d908ab..22f5723ee 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -4,6 +4,7 @@ const { registrationController, resetPasswordController, resetPasswordRequestController, + graphTokenController, } = require('~/server/controllers/AuthController'); const { loginController } = require('~/server/controllers/auth/LoginController'); 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/backup/regenerate', requireJwtAuth, regenerateBackupCodes); +router.get('/graph-token', requireJwtAuth, graphTokenController); + module.exports = router; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 84cef4497..f812ae10a 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -21,6 +21,9 @@ const publicSharedLinksEnabled = (process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined || 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) { const cache = getLogStores(CacheKeys.CONFIG_STORE); @@ -98,6 +101,11 @@ router.get('/', async function (req, res) { instanceProjectId: instanceProject._id.toString(), bundlerURL: process.env.SANDPACK_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 = {}; diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js new file mode 100644 index 000000000..d70e7c01c --- /dev/null +++ b/api/server/services/GraphTokenService.js @@ -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} 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, +}; diff --git a/client/src/common/menus.ts b/client/src/common/menus.ts index 4d70f282c..d0d81460d 100644 --- a/client/src/common/menus.ts +++ b/client/src/common/menus.ts @@ -20,4 +20,5 @@ export interface MenuItemProps { | RenderProp & { ref?: React.Ref | undefined }> | React.ReactElement> | undefined; + subItems?: MenuItemProps[]; } diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 5e42dd063..9c7891310 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -2,11 +2,21 @@ import React, { useRef, useState, useMemo } from 'react'; import * as Ariakit from '@ariakit/react'; import { useSetRecoilState } from 'recoil'; 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 { + FileUpload, + TooltipAnchor, + DropdownPopup, + AttachmentIcon, + SharePointIcon, +} from '@librechat/client'; import type { EndpointFileConfig } from 'librechat-data-provider'; 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 { MenuItemProps } from '~/common'; import { cn } from '~/utils'; interface AttachFileMenuProps { @@ -26,7 +36,15 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach overrideEndpoint: EModelEndpoint.agents, 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(); /** TODO: Ephemeral Agent Capabilities * Allow defining agent capabilities on a per-endpoint basis @@ -45,57 +63,83 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach }; const dropdownItems = useMemo(() => { - const items = [ - { - label: localize('com_ui_upload_image_input'), - onClick: () => { - setToolResource(undefined); - handleUploadClick(true); + const createMenuItems = (onAction: (isImage?: boolean) => void) => { + const items: MenuItemProps[] = [ + { + label: localize('com_ui_upload_image_input'), + onClick: () => { + setToolResource(undefined); + onAction(true); + }, + icon: , }, - icon: , - }, - ]; + ]; - if (capabilities.ocrEnabled) { - items.push({ - label: localize('com_ui_upload_ocr_text'), - onClick: () => { - setToolResource(EToolResources.ocr); - handleUploadClick(); - }, - icon: , + if (capabilities.ocrEnabled) { + items.push({ + label: localize('com_ui_upload_ocr_text'), + onClick: () => { + setToolResource(EToolResources.ocr); + onAction(); + }, + icon: , + }); + } + + if (capabilities.fileSearchEnabled) { + items.push({ + label: localize('com_ui_upload_file_search'), + onClick: () => { + setToolResource(EToolResources.file_search); + onAction(); + }, + icon: , + }); + } + + 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: , + }); + } + + 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: , + subItems: sharePointItems, + }); + return localItems; } - if (capabilities.fileSearchEnabled) { - items.push({ - label: localize('com_ui_upload_file_search'), - onClick: () => { - setToolResource(EToolResources.file_search); - /** File search is not automatically enabled to simulate legacy behavior */ - handleUploadClick(); - }, - icon: , - }); - } - - 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: , - }); - } - - return items; - }, [capabilities, localize, setToolResource, setEphemeralAgent]); + return localItems; + }, [ + capabilities, + localize, + setToolResource, + setEphemeralAgent, + sharePointEnabled, + setIsSharePointDialogOpen, + ]); const menuTrigger = ( ); + const handleSharePointFilesSelected = async (sharePointFiles: any[]) => { + try { + await handleSharePointFiles(sharePointFiles); + setIsSharePointDialogOpen(false); + } catch (error) { + console.error('SharePoint file processing error:', error); + } + }; return ( - { - handleFileChange(e, toolResource); - }} - > - + { + handleFileChange(e, toolResource); + }} + > + + + - + ); }; diff --git a/client/src/components/Chat/Input/Files/FilePreview.tsx b/client/src/components/Chat/Input/Files/FilePreview.tsx index 600092278..ec9c26a77 100644 --- a/client/src/components/Chat/Input/Files/FilePreview.tsx +++ b/client/src/components/Chat/Input/Files/FilePreview.tsx @@ -1,7 +1,6 @@ -import { Spinner } from '@librechat/client'; +import { Spinner, FileIcon } from '@librechat/client'; import type { TFile } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; -import { FileIcon } from '~/components/svg'; import SourceIcon from './SourceIcon'; import { cn } from '~/utils'; diff --git a/client/src/components/SharePoint/SharePointPickerDialog.tsx b/client/src/components/SharePoint/SharePointPickerDialog.tsx new file mode 100644 index 000000000..fcdaff37c --- /dev/null +++ b/client/src/components/SharePoint/SharePointPickerDialog.tsx @@ -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(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 ( + + + + + + {localize('com_files_sharepoint_picker_title')} + +
+ {/* SharePoint iframe will be injected here by the hook */} + + {isDownloading && ( +
+
+
+
+

+ {localize('com_files_downloading')} +

+ {downloadProgress && ( +
+

+ {localize('com_files_download_progress', { + 0: downloadProgress.completed, + 1: downloadProgress.total, + })} +

+ {downloadProgress.currentFile && ( +

+ {downloadProgress.currentFile} +

+ )} +
+
+
+

+ {localize('com_files_download_percent_complete', { + 0: Math.round( + (downloadProgress.completed / downloadProgress.total) * 100, + ), + })} +

+ {downloadProgress.failed.length > 0 && ( +

+ {localize('com_files_download_failed', { + 0: downloadProgress.failed.length, + })} +

+ )} +
+ )} + {!downloadProgress && ( +

+ {localize('com_files_preparing_download')} +

+ )} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/client/src/components/SharePoint/index.ts b/client/src/components/SharePoint/index.ts new file mode 100644 index 000000000..0af958504 --- /dev/null +++ b/client/src/components/SharePoint/index.ts @@ -0,0 +1 @@ +export { default as SharePointPickerDialog } from './SharePointPickerDialog'; diff --git a/client/src/components/SidePanel/Agents/FileContext.tsx b/client/src/components/SidePanel/Agents/FileContext.tsx index 299967579..7fb20c388 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -1,4 +1,6 @@ import { useState, useRef } from 'react'; +import { Folder } from 'lucide-react'; +import * as Ariakit from '@ariakit/react'; import { EModelEndpoint, EToolResources, @@ -7,16 +9,21 @@ import { } from 'librechat-data-provider'; import { HoverCard, - HoverCardContent, - HoverCardPortal, - HoverCardTrigger, + DropdownPopup, AttachmentIcon, CircleHelpIcon, + AttachmentIcon, + CircleHelpIcon, + SharePointIcon, + HoverCardPortal, + HoverCardContent, + HoverCardTrigger, } from '@librechat/client'; 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 { useGetFileConfig } from '~/data-provider'; import { useChatContext } from '~/Providers'; import { ESide } from '~/common'; @@ -31,6 +38,10 @@ export default function FileContext({ const { setFilesLoading } = useChatContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(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({ select: (data) => mergeFileConfig(data), @@ -41,7 +52,11 @@ export default function FileContext({ additionalMetadata: { agent_id, tool_resource: EToolResources.ocr }, fileSetter: setFiles, }); - + const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ + overrideEndpoint: EModelEndpoint.agents, + additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + fileSetter: setFiles, + }); useLazyEffect( () => { if (_files) { @@ -54,19 +69,45 @@ export default function FileContext({ const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; 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) { return null; } - const handleButtonClick = () => { + const handleLocalFileClick = () => { // necessary to reset the input if (fileInputRef.current) { fileInputRef.current.value = ''; } fileInputRef.current?.click(); }; - + const dropdownItems = [ + { + label: localize('com_files_upload_local_machine'), + onClick: handleLocalFileClick, + icon: , + }, + { + label: localize('com_files_upload_sharepoint'), + onClick: () => setIsSharePointDialogOpen(true), + icon: , + }, + ]; + const menuTrigger = ( + +
+ + {localize('com_ui_upload_file_context')} +
+
+ ); return (
@@ -101,26 +142,42 @@ export default function FileContext({ Wrapper={({ children }) =>
{children}
} />
- + + ) : ( + + )} +
{/* Disabled Message */} {agent_id ? null : ( @@ -129,6 +186,14 @@ export default function FileContext({
)} + ); } diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index 3eb55a81f..5e73084e0 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -1,6 +1,8 @@ import { useState, useRef } from 'react'; +import { Folder } from 'lucide-react'; +import * as Ariakit from '@ariakit/react'; import { useFormContext } from 'react-hook-form'; -import { AttachmentIcon } from '@librechat/client'; +import { SharePointIcon, AttachmentIcon, DropdownPopup } from '@librechat/client'; import { EModelEndpoint, EToolResources, @@ -9,10 +11,12 @@ import { fileConfig as defaultFileConfig, } from 'librechat-data-provider'; 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 { SharePointPickerDialog } from '~/components/SharePoint'; import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileSearchCheckbox from './FileSearchCheckbox'; -import { useGetFileConfig } from '~/data-provider'; import { useChatContext } from '~/Providers'; export default function FileSearch({ @@ -27,6 +31,11 @@ export default function FileSearch({ const { watch } = useFormContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(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({ select: (data) => mergeFileConfig(data), @@ -38,6 +47,12 @@ export default function FileSearch({ fileSetter: setFiles, }); + const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ + overrideEndpoint: EModelEndpoint.agents, + additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + fileSetter: setFiles, + }); + useLazyEffect( () => { if (_files) { @@ -53,6 +68,17 @@ export default function FileSearch({ const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; 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) { return null; } @@ -65,6 +91,38 @@ export default function FileSearch({ 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: , + }, + { + label: localize('com_files_upload_sharepoint'), + onClick: () => setIsSharePointDialogOpen(true), + icon: , + }, + ]; + + const menuTrigger = ( + +
+ + {localize('com_ui_upload_file_search')} +
+
+ ); + return (
@@ -86,26 +144,39 @@ export default function FileSearch({ Wrapper={({ children }) =>
{children}
} />
- + {sharePointEnabled ? ( + + ) : ( + + )} +
{/* Disabled Message */} {agent_id ? null : ( @@ -114,6 +185,16 @@ export default function FileSearch({
)}
+ + ); } diff --git a/client/src/components/SidePanel/Agents/config.ts b/client/src/components/SidePanel/Agents/config.ts new file mode 100644 index 000000000..e6e95d71c --- /dev/null +++ b/client/src/components/SidePanel/Agents/config.ts @@ -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; +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; + }; + }; + }; +}; diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts deleted file mode 100644 index 308c24759..000000000 --- a/client/src/components/svg/index.ts +++ /dev/null @@ -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'; diff --git a/client/src/data-provider/Auth/queries.ts b/client/src/data-provider/Auth/queries.ts index 5fbbc49eb..ff34d05d0 100644 --- a/client/src/data-provider/Auth/queries.ts +++ b/client/src/data-provider/Auth/queries.ts @@ -18,3 +18,27 @@ export const useGetUserQuery = ( enabled: (config?.enabled ?? true) === true && queriesEnabled, }); }; + +export interface UseGraphTokenQueryOptions { + scopes?: string; + enabled?: boolean; +} + +export const useGraphTokenQuery = ( + options: UseGraphTokenQueryOptions = {}, + config?: UseQueryOptions, +): QueryObserverResult => { + 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, + }); +}; diff --git a/client/src/data-provider/Files/index.ts b/client/src/data-provider/Files/index.ts index 684ad1668..06eb4468e 100644 --- a/client/src/data-provider/Files/index.ts +++ b/client/src/data-provider/Files/index.ts @@ -1,2 +1,3 @@ export * from './mutations'; export * from './queries'; +export * from './sharepoint'; diff --git a/client/src/data-provider/Files/sharepoint.ts b/client/src/data-provider/Files/sharepoint.ts new file mode 100644 index 000000000..6a9dfb9a3 --- /dev/null +++ b/client/src/data-provider/Files/sharepoint.ts @@ -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 = { + // 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 }; diff --git a/client/src/hooks/Files/index.ts b/client/src/hooks/Files/index.ts index af61a8b44..df86c02a9 100644 --- a/client/src/hooks/Files/index.ts +++ b/client/src/hooks/Files/index.ts @@ -5,3 +5,7 @@ export { default as useFileDeletion } from './useFileDeletion'; export { default as useUpdateFiles } from './useUpdateFiles'; export { default as useDragHelpers } from './useDragHelpers'; 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'; diff --git a/client/src/hooks/Files/useSharePointDownload.ts b/client/src/hooks/Files/useSharePointDownload.ts new file mode 100644 index 000000000..d78a4a641 --- /dev/null +++ b/client/src/hooks/Files/useSharePointDownload.ts @@ -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; + onError?: (error: Error) => void; +} + +interface UseSharePointDownloadReturn { + downloadSharePointFiles: (files: SharePointFile[]) => Promise; + isDownloading: boolean; + downloadProgress: SharePointBatchProgress | null; + error: string | null; +} + +export default function useSharePointDownload({ + onFilesDownloaded, + onError, +}: UseSharePointDownloadProps = {}): UseSharePointDownloadReturn { + const { showToast } = useToastContext(); + const [downloadProgress, setDownloadProgress] = useState(null); + const [error, setError] = useState(null); + + const { token, refetch: refetchToken } = useSharePointToken({ + enabled: false, + purpose: 'Download', + }); + + const batchDownloadMutation = useSharePointBatchDownload(); + + const downloadSharePointFiles = useCallback( + async (files: SharePointFile[]): Promise => { + 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, + }; +} diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts new file mode 100644 index 000000000..1c48f9b3a --- /dev/null +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -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; + overrideEndpoint?: any; + overrideEndpointFileConfig?: any; + toolResource?: string; +} + +interface UseSharePointFileHandlingReturn { + handleSharePointFiles: (files: SharePointFile[]) => Promise; + 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, + }; +} diff --git a/client/src/hooks/Files/useSharePointPicker.ts b/client/src/hooks/Files/useSharePointPicker.ts new file mode 100644 index 000000000..e3812c1b2 --- /dev/null +++ b/client/src/hooks/Files/useSharePointPicker.ts @@ -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(null); + const portRef = useRef(null); + const channelIdRef = useRef(''); + + 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, + }; +} diff --git a/client/src/hooks/Files/useSharePointToken.ts b/client/src/hooks/Files/useSharePointToken.ts new file mode 100644 index 000000000..d9e71ad88 --- /dev/null +++ b/client/src/hooks/Files/useSharePointToken.ts @@ -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; +} + +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, + }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 16bc764e5..9304e43b8 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1070,6 +1070,14 @@ "com_ui_upload_success": "Successfully uploaded file", "com_ui_upload_type": "Select Upload Type", "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_backup_code": "Use Backup Code Instead", "com_ui_use_memory": "Use memory", diff --git a/client/src/style.css b/client/src/style.css index bdb18e056..a0aae5e5d 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2800,3 +2800,7 @@ html { .custom-style-2 { padding: 12px; } + +.sharepoint-picker-bg{ + background-color: #F5F5F5; +} diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index e98909012..ab822bd50 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -1,4 +1,4 @@ -import { SheetPaths, TextPaths, FilePaths, CodePaths } from '~/components/svg'; +import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client'; import { megabyte, QueryKeys, diff --git a/config/flush-cache.js b/config/flush-cache.js new file mode 100644 index 000000000..b65339ad1 --- /dev/null +++ b/config/flush-cache.js @@ -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 }; diff --git a/package.json b/package.json index 589f6494c..803351080 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "b:test:api": "cd api && bun run b:test", "b:balance": "bun config/add-balance.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": { "type": "git", diff --git a/packages/client/src/components/DropdownPopup.tsx b/packages/client/src/components/DropdownPopup.tsx index 9d4c7f153..30ed03067 100644 --- a/packages/client/src/components/DropdownPopup.tsx +++ b/packages/client/src/components/DropdownPopup.tsx @@ -29,7 +29,8 @@ interface DropdownProps { type MenuProps = Omit< DropdownProps, 'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState' ->; +> & + Ariakit.MenuProps; const DropdownPopup: React.FC = ({ trigger, @@ -70,7 +71,9 @@ const Menu: React.FC = ({ finalFocus, unmountOnHide, preserveTabOrder, + ...props }) => { + const menuStore = Ariakit.useMenuStore(); const menu = Ariakit.useMenuContext(); return ( = ({ unmountOnHide={unmountOnHide} preserveTabOrder={preserveTabOrder} className={cn('popover-ui z-50', className)} + {...props} > {items .filter((item) => item.show !== false) .map((item, index) => { + const { subItems } = item; if (item.separate === true) { return ; } + if (subItems && subItems.length > 0) { + return ( + + + + {item.icon != null && ( + + )} + {item.label} + + + + + + ); + } + return ( + + + ); +} diff --git a/client/src/components/svg/SheetPaths.tsx b/packages/client/src/svgs/SheetPaths.tsx similarity index 100% rename from client/src/components/svg/SheetPaths.tsx rename to packages/client/src/svgs/SheetPaths.tsx diff --git a/client/src/components/svg/TextPaths.tsx b/packages/client/src/svgs/TextPaths.tsx similarity index 100% rename from client/src/components/svg/TextPaths.tsx rename to packages/client/src/svgs/TextPaths.tsx diff --git a/packages/client/src/svgs/index.ts b/packages/client/src/svgs/index.ts index 9c975a8e8..13a5a1cc0 100644 --- a/packages/client/src/svgs/index.ts +++ b/packages/client/src/svgs/index.ts @@ -65,3 +65,9 @@ export { default as PersonalizationIcon } from './PersonalizationIcon'; export { default as MCPIcon } from './MCPIcon'; export { default as VectorIcon } from './VectorIcon'; 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'; diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index ba0c89d94..156fc258f 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -305,3 +305,7 @@ export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp'; export const memories = () => '/api/memories'; export const memory = (key: string) => `${memories()}/${encodeURIComponent(key)}`; export const memoryPreferences = () => `${memories()}/preferences`; + +// SharePoint Graph API Token +export const graphToken = (scopes: string) => + `/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 83c193df2..e4d038b2c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -597,6 +597,11 @@ export type TStartupConfig = { instanceProjectId: string; bundlerURL?: string; staticBundlerURL?: string; + sharePointFilePickerEnabled?: boolean; + sharePointBaseUrl?: string; + sharePointPickerGraphScope?: string; + sharePointPickerSharePointScope?: string; + openidReuseTokens?: boolean; webSearch?: { searchProvider?: SearchProviders; scraperType?: ScraperTypes; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 0f018d09c..9a8712f91 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -858,3 +858,8 @@ export const createMemory = (data: { }): Promise<{ created: boolean; memory: q.TUserMemory }> => { return request.post(endpoints.memories(), data); }; + +// SharePoint Graph API Token +export function getGraphApiToken(params: q.GraphTokenParams): Promise { + return request.get(endpoints.graphToken(params.scopes)); +} diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 5cb4c8195..91a50aded 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -50,6 +50,7 @@ export enum QueryKeys { banner = 'banner', /* Memories */ memories = 'memories', + graphToken = 'graphToken', } // Dynamic query keys that require parameters diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index db186b2b9..49c1a2185 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -147,3 +147,15 @@ export interface MCPAuthValuesResponse { serverName: string; authValueFlags: Record; } + +/* SharePoint Graph API Token */ +export type GraphTokenParams = { + scopes: string; +}; + +export type GraphTokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + scope: string; +};