mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-23 10:46:12 +01:00
📁 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:
parent
b6413b06bc
commit
a955097faf
40 changed files with 2500 additions and 123 deletions
|
|
@ -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<any>,
|
||||
): QueryObserverResult<any> => {
|
||||
const { scopes, enabled = false } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QueryKeys.graphToken, scopes],
|
||||
queryFn: () => dataService.getGraphApiToken({ scopes }),
|
||||
enabled,
|
||||
staleTime: 50 * 60 * 1000, // 50 minutes (tokens expire in 60 minutes)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './mutations';
|
||||
export * from './queries';
|
||||
export * from './sharepoint';
|
||||
|
|
|
|||
232
client/src/data-provider/Files/sharepoint.ts
Normal file
232
client/src/data-provider/Files/sharepoint.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export interface SharePointFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
webUrl: string;
|
||||
downloadUrl: string;
|
||||
driveId: string;
|
||||
itemId: string;
|
||||
sharePointItem: any;
|
||||
}
|
||||
|
||||
export interface SharePointDownloadProgress {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
loaded: number;
|
||||
total: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface SharePointBatchProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
currentFile?: string;
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
export const useSharePointFileDownload = (): UseMutationResult<
|
||||
File,
|
||||
unknown,
|
||||
{
|
||||
file: SharePointFile;
|
||||
accessToken: string;
|
||||
onProgress?: (progress: SharePointDownloadProgress) => void;
|
||||
}
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ file, accessToken, onProgress }) => {
|
||||
const downloadUrl =
|
||||
file.downloadUrl ||
|
||||
`https://graph.microsoft.com/v1.0/drives/${file.driveId}/items/${file.itemId}/content`;
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0');
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
loaded: receivedLength,
|
||||
total: contentLength || file.size,
|
||||
progress: Math.round((receivedLength / (contentLength || file.size)) * 100),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allChunks = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
allChunks.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get('content-type') || getMimeTypeFromFileName(file.name);
|
||||
|
||||
const blob = new Blob([allChunks], { type: contentType });
|
||||
const downloadedFile = new File([blob], file.name, {
|
||||
type: contentType,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
return downloadedFile;
|
||||
},
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSharePointBatchDownload = (): UseMutationResult<
|
||||
File[],
|
||||
unknown,
|
||||
{
|
||||
files: SharePointFile[];
|
||||
accessToken: string;
|
||||
onProgress?: (progress: SharePointBatchProgress) => void;
|
||||
},
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ files, accessToken, onProgress }) => {
|
||||
const downloadedFiles: File[] = [];
|
||||
const failed: string[] = [];
|
||||
let completed = 0;
|
||||
|
||||
const concurrencyLimit = 3;
|
||||
const chunks: SharePointFile[][] = [];
|
||||
for (let i = 0; i < files.length; i += concurrencyLimit) {
|
||||
chunks.push(files.slice(i, i + concurrencyLimit));
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const chunkPromises = chunk.map(async (file) => {
|
||||
try {
|
||||
const downloadUrl =
|
||||
file.downloadUrl ||
|
||||
`https://graph.microsoft.com/v1.0/drives/${file.driveId}/items/${file.itemId}/content`;
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentType =
|
||||
response.headers.get('content-type') || getMimeTypeFromFileName(file.name);
|
||||
|
||||
const downloadedFile = new File([blob], file.name, {
|
||||
type: contentType,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
completed++;
|
||||
onProgress?.({
|
||||
completed,
|
||||
total: files.length,
|
||||
currentFile: file.name,
|
||||
failed,
|
||||
});
|
||||
|
||||
return downloadedFile;
|
||||
} catch (error) {
|
||||
console.error(`Failed to download ${file.name}:`, error);
|
||||
failed.push(file.name);
|
||||
completed++;
|
||||
onProgress?.({
|
||||
completed,
|
||||
total: files.length,
|
||||
currentFile: `Error: ${file.name}`,
|
||||
failed,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const chunkResults = await Promise.allSettled(chunkPromises);
|
||||
|
||||
chunkResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
downloadedFiles.push(result.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.warn(`Failed to download ${failed.length} files:`, failed);
|
||||
}
|
||||
|
||||
return downloadedFiles;
|
||||
},
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
|
||||
function getMimeTypeFromFileName(fileName: string): string {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
|
||||
// Images
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
|
||||
// Archives
|
||||
zip: 'application/zip',
|
||||
rar: 'application/x-rar-compressed',
|
||||
|
||||
// Media
|
||||
mp4: 'video/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
|
||||
return mimeTypes[extension || ''] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export { getMimeTypeFromFileName };
|
||||
Loading…
Add table
Add a link
Reference in a new issue