📁 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 b6413b06bc
commit a955097faf
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
40 changed files with 2500 additions and 123 deletions

View file

@ -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';

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,
};
}