mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02: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
15
.env.example
15
.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.
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
86
api/server/services/GraphTokenService.js
Normal file
86
api/server/services/GraphTokenService.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
||||
const { logger } = require('~/config');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const client = require('openid-client');
|
||||
|
||||
/**
|
||||
* Get Microsoft Graph API token using existing token exchange mechanism
|
||||
* @param {Object} user - User object with OpenID information
|
||||
* @param {string} accessToken - Current access token from Authorization header
|
||||
* @param {string} scopes - Graph API scopes for the token
|
||||
* @param {boolean} fromCache - Whether to try getting token from cache first
|
||||
* @returns {Promise<Object>} Graph API token response with access_token and expires_in
|
||||
*/
|
||||
async function getGraphApiToken(user, accessToken, scopes, fromCache = true) {
|
||||
try {
|
||||
if (!user.openidId) {
|
||||
throw new Error('User must be authenticated via Entra ID to access Microsoft Graph');
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required for token exchange');
|
||||
}
|
||||
|
||||
if (!scopes) {
|
||||
throw new Error('Graph API scopes are required for token exchange');
|
||||
}
|
||||
|
||||
const config = getOpenIdConfig();
|
||||
if (!config) {
|
||||
throw new Error('OpenID configuration not available');
|
||||
}
|
||||
|
||||
const cacheKey = `${user.openidId}:${scopes}`;
|
||||
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
||||
|
||||
if (fromCache) {
|
||||
const cachedToken = await tokensCache.get(cacheKey);
|
||||
if (cachedToken) {
|
||||
logger.debug(`[GraphTokenService] Using cached Graph API token for user: ${user.openidId}`);
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`[GraphTokenService] Requesting new Graph API token for user: ${user.openidId}`);
|
||||
logger.debug(`[GraphTokenService] Requested scopes: ${scopes}`);
|
||||
|
||||
const grantResponse = await client.genericGrantRequest(
|
||||
config,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: scopes,
|
||||
assertion: accessToken,
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
|
||||
const tokenResponse = {
|
||||
access_token: grantResponse.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: grantResponse.expires_in || 3600,
|
||||
scope: scopes,
|
||||
};
|
||||
|
||||
await tokensCache.set(
|
||||
cacheKey,
|
||||
tokenResponse,
|
||||
(grantResponse.expires_in || 3600) * 1000, // Convert to milliseconds
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`[GraphTokenService] Successfully obtained and cached Graph API token for user: ${user.openidId}`,
|
||||
);
|
||||
return tokenResponse;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[GraphTokenService] Failed to acquire Graph API token for user ${user.openidId}:`,
|
||||
error,
|
||||
);
|
||||
throw new Error(`Graph token acquisition failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGraphApiToken,
|
||||
};
|
|
@ -20,4 +20,5 @@ export interface MenuItemProps {
|
|||
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
||||
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
| undefined;
|
||||
subItems?: MenuItemProps[];
|
||||
}
|
||||
|
|
|
@ -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: <ImageUpIcon className="icon-md" />,
|
||||
},
|
||||
icon: <ImageUpIcon className="icon-md" />,
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
if (capabilities.ocrEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.ocr);
|
||||
handleUploadClick();
|
||||
},
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
if (capabilities.ocrEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.ocr);
|
||||
onAction();
|
||||
},
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.file_search);
|
||||
onAction();
|
||||
},
|
||||
icon: <FileSearch className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.codeEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.execute_code);
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[EToolResources.execute_code]: true,
|
||||
}));
|
||||
onAction();
|
||||
},
|
||||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const localItems = createMenuItems(handleUploadClick);
|
||||
|
||||
if (sharePointEnabled) {
|
||||
const sharePointItems = createMenuItems(() => {
|
||||
setIsSharePointDialogOpen(true);
|
||||
// Note: toolResource will be set by the specific item clicked
|
||||
});
|
||||
localItems.push({
|
||||
label: localize('com_files_upload_sharepoint'),
|
||||
onClick: () => {},
|
||||
icon: <SharePointIcon className="icon-md" />,
|
||||
subItems: sharePointItems,
|
||||
});
|
||||
return localItems;
|
||||
}
|
||||
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
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: <FileSearch className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.codeEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.execute_code);
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[EToolResources.execute_code]: true,
|
||||
}));
|
||||
handleUploadClick();
|
||||
},
|
||||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
|
||||
return localItems;
|
||||
}, [
|
||||
capabilities,
|
||||
localize,
|
||||
setToolResource,
|
||||
setEphemeralAgent,
|
||||
sharePointEnabled,
|
||||
setIsSharePointDialogOpen,
|
||||
]);
|
||||
|
||||
const menuTrigger = (
|
||||
<TooltipAnchor
|
||||
|
@ -118,25 +162,44 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
|||
disabled={isUploadDisabled}
|
||||
/>
|
||||
);
|
||||
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
|
||||
try {
|
||||
await handleSharePointFiles(sharePointFiles);
|
||||
setIsSharePointDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('SharePoint file processing error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileUpload
|
||||
ref={inputRef}
|
||||
handleFileChange={(e) => {
|
||||
handleFileChange(e, toolResource);
|
||||
}}
|
||||
>
|
||||
<DropdownPopup
|
||||
menuId="attach-file-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
<>
|
||||
<FileUpload
|
||||
ref={inputRef}
|
||||
handleFileChange={(e) => {
|
||||
handleFileChange(e, toolResource);
|
||||
}}
|
||||
>
|
||||
<DropdownPopup
|
||||
menuId="attach-file-menu"
|
||||
className="overflow-visible"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
/>
|
||||
</FileUpload>
|
||||
<SharePointPickerDialog
|
||||
isOpen={isSharePointDialogOpen}
|
||||
onOpenChange={setIsSharePointDialogOpen}
|
||||
onFilesSelected={handleSharePointFilesSelected}
|
||||
isDownloading={isProcessing}
|
||||
downloadProgress={downloadProgress}
|
||||
maxSelectionCount={endpointFileConfig?.fileLimit}
|
||||
/>
|
||||
</FileUpload>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
137
client/src/components/SharePoint/SharePointPickerDialog.tsx
Normal file
137
client/src/components/SharePoint/SharePointPickerDialog.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogPortal,
|
||||
OGDialogOverlay,
|
||||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import type { SharePointBatchProgress } from '~/data-provider/Files/sharepoint';
|
||||
import { useSharePointPicker, useLocalize } from '~/hooks';
|
||||
|
||||
interface SharePointPickerDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilesSelected?: (files: any[]) => void;
|
||||
disabled?: boolean;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: SharePointBatchProgress | null;
|
||||
maxSelectionCount?: number;
|
||||
}
|
||||
|
||||
export default function SharePointPickerDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onFilesSelected,
|
||||
disabled = false,
|
||||
isDownloading = false,
|
||||
downloadProgress = null,
|
||||
maxSelectionCount,
|
||||
}: SharePointPickerDialogProps) {
|
||||
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null);
|
||||
const localize = useLocalize();
|
||||
|
||||
const { openSharePointPicker, closeSharePointPicker, cleanup } = useSharePointPicker({
|
||||
containerNode,
|
||||
onFilesSelected,
|
||||
disabled,
|
||||
onClose: () => handleOpenChange(false),
|
||||
maxSelectionCount,
|
||||
});
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
closeSharePointPicker();
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
// Use callback ref to trigger SharePoint picker when container is attached
|
||||
const containerCallbackRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
setContainerNode(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerNode && isOpen) {
|
||||
openSharePointPicker();
|
||||
}
|
||||
return () => {
|
||||
if (!isOpen) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
// we need to run this effect only when the containerNode or isOpen changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerNode, isOpen]);
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<OGDialogPortal>
|
||||
<OGDialogOverlay className="bg-black/50" />
|
||||
<OGDialogContent
|
||||
className="bg-#F5F5F5 sharepoint-picker-bg fixed left-1/2 top-1/2 z-50 h-[680px] max-h-[90vh] max-w-[90vw] -translate-x-1/2 -translate-y-1/2 rounded-lg border p-2 shadow-lg focus:outline-none"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<OGDialogTitle className="sr-only">
|
||||
{localize('com_files_sharepoint_picker_title')}
|
||||
</OGDialogTitle>
|
||||
<div ref={containerCallbackRef} className="sharepoint-picker-bg relative flex p-2">
|
||||
{/* SharePoint iframe will be injected here by the hook */}
|
||||
|
||||
{isDownloading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/30 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm rounded-lg bg-white p-6 shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
{localize('com_files_downloading')}
|
||||
</h3>
|
||||
{downloadProgress && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
{localize('com_files_download_progress', {
|
||||
0: downloadProgress.completed,
|
||||
1: downloadProgress.total,
|
||||
})}
|
||||
</p>
|
||||
{downloadProgress.currentFile && (
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{downloadProgress.currentFile}
|
||||
</p>
|
||||
)}
|
||||
<div className="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{localize('com_files_download_percent_complete', {
|
||||
0: Math.round(
|
||||
(downloadProgress.completed / downloadProgress.total) * 100,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{downloadProgress.failed.length > 0 && (
|
||||
<p className="text-xs text-red-500">
|
||||
{localize('com_files_download_failed', {
|
||||
0: downloadProgress.failed.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!downloadProgress && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{localize('com_files_preparing_download')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialogPortal>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
1
client/src/components/SharePoint/index.ts
Normal file
1
client/src/components/SharePoint/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as SharePointPickerDialog } from './SharePointPickerDialog';
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
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: <Folder className="icon-md" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_files_upload_sharepoint'),
|
||||
onClick: () => setIsSharePointDialogOpen(true),
|
||||
icon: <SharePointIcon className="icon-md" />,
|
||||
},
|
||||
];
|
||||
const menuTrigger = (
|
||||
<Ariakit.MenuButton className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium">
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
{localize('com_ui_upload_file_context')}
|
||||
</div>
|
||||
</Ariakit.MenuButton>
|
||||
);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<HoverCard openDelay={50}>
|
||||
|
@ -101,26 +142,42 @@ export default function FileContext({
|
|||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agent_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={!agent_id}
|
||||
onChange={handleFileChange}
|
||||
{sharePointEnabled ? (
|
||||
<>
|
||||
<DropdownPopup
|
||||
gutter={2}
|
||||
menuId="file-search-upload-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
/>
|
||||
{localize('com_ui_upload_file_context')}
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agent_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
onClick={handleLocalFileClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
|
||||
{localize('com_ui_upload_file_context')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={!agent_id}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Disabled Message */}
|
||||
{agent_id ? null : (
|
||||
|
@ -129,6 +186,14 @@ export default function FileContext({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SharePointPickerDialog
|
||||
isOpen={isSharePointDialogOpen}
|
||||
onOpenChange={setIsSharePointDialogOpen}
|
||||
onFilesSelected={handleSharePointFilesSelected}
|
||||
isDownloading={isProcessing}
|
||||
downloadProgress={downloadProgress}
|
||||
maxSelectionCount={endpointFileConfig?.fileLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<AgentForm>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
|
||||
|
||||
// Get startup configuration for SharePoint feature flag
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
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: <Folder className="icon-md" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_files_upload_sharepoint'),
|
||||
onClick: () => setIsSharePointDialogOpen(true),
|
||||
icon: <SharePointIcon className="icon-md" />,
|
||||
},
|
||||
];
|
||||
|
||||
const menuTrigger = (
|
||||
<Ariakit.MenuButton
|
||||
disabled={disabledUploadButton}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
{localize('com_ui_upload_file_search')}
|
||||
</div>
|
||||
</Ariakit.MenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
|
@ -86,26 +144,39 @@ export default function FileSearch({
|
|||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agent_id || fileSearchChecked === false}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={!agent_id || fileSearchChecked === false}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{localize('com_ui_upload_file_search')}
|
||||
</div>
|
||||
</button>
|
||||
{sharePointEnabled ? (
|
||||
<DropdownPopup
|
||||
gutter={2}
|
||||
menuId="file-search-upload-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabledUploadButton}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
{localize('com_ui_upload_file_search')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={disabledUploadButton}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Disabled Message */}
|
||||
{agent_id ? null : (
|
||||
|
@ -114,6 +185,16 @@ export default function FileSearch({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SharePointPickerDialog
|
||||
isOpen={isSharePointDialogOpen}
|
||||
onOpenChange={setIsSharePointDialogOpen}
|
||||
onFilesSelected={handleSharePointFilesSelected}
|
||||
disabled={disabledUploadButton}
|
||||
isDownloading={isProcessing}
|
||||
downloadProgress={downloadProgress}
|
||||
maxSelectionCount={endpointFileConfig?.fileLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
640
client/src/components/SidePanel/Agents/config.ts
Normal file
640
client/src/components/SidePanel/Agents/config.ts
Normal file
|
@ -0,0 +1,640 @@
|
|||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
export type ExtFilters =
|
||||
| 'folder'
|
||||
| 'site'
|
||||
| 'documentLibrary'
|
||||
| 'list'
|
||||
| 'onenote'
|
||||
| 'file'
|
||||
| 'media'
|
||||
| 'photo'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'document'
|
||||
| 'listItem'
|
||||
| 'playlist'
|
||||
| 'syntexTemplate'
|
||||
| 'syntexSnippet'
|
||||
| 'syntexField'
|
||||
| `.${string}`;
|
||||
|
||||
//NOTE: IItem type references the following docs: https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0#properties
|
||||
export type IItem = Record<string, any>;
|
||||
export type SPPickerConfig = {
|
||||
sdk: '8.0';
|
||||
/**
|
||||
* Establishes the messaging parameters used to setup the post message communications between
|
||||
* picker and host application
|
||||
*/
|
||||
messaging: {
|
||||
/**
|
||||
* A unique id assigned by the host app to this File Picker instance.
|
||||
* This should ideally be a new GUID generated by the host.
|
||||
*/
|
||||
channelId: string;
|
||||
/**
|
||||
* The host app's authority, used as the target origin for post-messaging.
|
||||
*/
|
||||
origin: string;
|
||||
/**
|
||||
* Whether or not the host app window will need to identify itself.
|
||||
*/
|
||||
identifyParent?: boolean;
|
||||
/**
|
||||
* Whether or not the client app must wait for a 'configure' command to be sent by the host before rendering.
|
||||
*/
|
||||
waitForConfiguration?: boolean;
|
||||
/**
|
||||
* Override timeout for acknowledgement messages.
|
||||
*/
|
||||
acknowledgeTimeout?: number;
|
||||
/**
|
||||
* Override timeout for the initialization handshake.
|
||||
*/
|
||||
initializeTimeout?: number;
|
||||
/**
|
||||
* Override timeout for command responses.
|
||||
*/
|
||||
resultTimeout?: number;
|
||||
};
|
||||
/**
|
||||
* Configuration for the entry location to which the File Picker will navigate on load.
|
||||
* The File Picker app will prioritize path-based navigation if provided, falling back to other address forms
|
||||
* on error (in case of Site redirection or content rename) or if path information is not provided.
|
||||
*/
|
||||
entry: {
|
||||
sharePoint?: {
|
||||
/**
|
||||
* Specify an exact SharePoint content location by path segments.
|
||||
*/
|
||||
byPath?: {
|
||||
/**
|
||||
* Full URL to the root of a Web, or server-relative URL.
|
||||
* @example
|
||||
* 'https://contoso-my.sharepoint.com/personal/user_contoso_com'
|
||||
* @example
|
||||
* '/path/to/web'
|
||||
* @example
|
||||
* 'subweb'
|
||||
*/
|
||||
web?: string;
|
||||
/**
|
||||
* Full URL or path segement to identity a List.
|
||||
* If not preceded with a `/` or a URL scheme, this is assumed to be a list in the specified web.
|
||||
* @example
|
||||
* 'Shared Documents'
|
||||
* @example
|
||||
* '/path/to/web/Shared Documents'
|
||||
* @example
|
||||
* 'https://contoso.sharepoint.com/path/to/web/Shared Documents'
|
||||
*/
|
||||
list?: string;
|
||||
/**
|
||||
* Path segment to a folder within a list, or a server-relative URL to a folder.
|
||||
* @example
|
||||
* 'General'
|
||||
* @example
|
||||
* 'foo/bar'
|
||||
* @example
|
||||
* '/path/to/web/Shared Documents/General'
|
||||
*/
|
||||
folder?: string;
|
||||
/**
|
||||
* Auto fallback to root folder if the specified entry sub folder doesn't exist.
|
||||
*/
|
||||
fallbackToRoot?: boolean;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Indicates that File Picker should start in the Site Pivot
|
||||
* This pivot is only supported in OneDrive for Business
|
||||
*/
|
||||
site?: {};
|
||||
/**
|
||||
* Indicates that File Picker should start in the OAL (My Organization) Pivot
|
||||
* This pivot is only supported in OneDrive for Business
|
||||
*/
|
||||
myOrganization?: {};
|
||||
/**
|
||||
* Indicates that the File Picker should start in the user's OneDrive.
|
||||
*/
|
||||
oneDrive?: {
|
||||
/**
|
||||
* Specifies that File Picker should start in the user's Files tab.
|
||||
*/
|
||||
files?: {
|
||||
/**
|
||||
* Path segment for sub-folder within the user's OneDrive for Business.
|
||||
* @example
|
||||
* 'Pictures'
|
||||
* @example
|
||||
* '/personal/user_contoso_com/Documents/Attachments'
|
||||
*/
|
||||
folder?: string;
|
||||
/**
|
||||
* Auto fallback to root folder if the specified entry sub folder doesn't exist.
|
||||
*/
|
||||
fallbackToRoot?: boolean;
|
||||
};
|
||||
/**
|
||||
* Indicates that File Picker should start in the user's recent files.
|
||||
*/
|
||||
recent?: {};
|
||||
/**
|
||||
* Indicates that File Picker should start in the files shared with the user.
|
||||
*/
|
||||
sharedWithMe?: {};
|
||||
/**
|
||||
* Indicates that File Picker should start in the user's photos.
|
||||
* This pivot is only available in OneDrive for Consumer
|
||||
*/
|
||||
photos?: {};
|
||||
};
|
||||
sortBy?: {
|
||||
/**
|
||||
* Name of the field *in SharePoint* on which to sort.
|
||||
*/
|
||||
fieldName: string;
|
||||
/**
|
||||
* Whether or not to sort in ascending order. Default is `true`.
|
||||
*/
|
||||
isAscending?: boolean;
|
||||
};
|
||||
filterBy?: {
|
||||
/**
|
||||
* Name of the field *in SharePoint* on which to filter on.
|
||||
*/
|
||||
fieldName: string;
|
||||
/**
|
||||
* Filter value
|
||||
*/
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Specifies how to enable a Search behavior.
|
||||
*/
|
||||
search?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
/**
|
||||
* Configuration for handling authentication requests from the embedded app.
|
||||
* Presence of this object (even if empty) indicates that the host will handle authentication.
|
||||
* Omitting this will make the embedded content attempt to rely on cookies.
|
||||
*/
|
||||
authentication?: {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Indicates support for individual token types.
|
||||
*/
|
||||
tokens?: {
|
||||
/**
|
||||
* @defaultValue true
|
||||
*/
|
||||
graph?: boolean;
|
||||
/**
|
||||
* @defaultValue true
|
||||
*/
|
||||
sharePoint?: boolean;
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
substrate?: boolean;
|
||||
};
|
||||
/**
|
||||
* Indicates that the host app can handle 'claims' challenges.
|
||||
*/
|
||||
claimsChallenge?: {
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Configures what types of items are allowed to be picked within the experience.
|
||||
* Note that the default configuration accounts for the expected authentication capabilities of the host app.
|
||||
* Depending on what else is enabled by the host, the host may be expected to provide tokens for more services and scopes.
|
||||
*/
|
||||
typesAndSources?: {
|
||||
/**
|
||||
* Specifies the general category of items picked. Switches between 'file' vs. 'folder' picker mode,
|
||||
* or a general-purpose picker.
|
||||
* @default 'all'
|
||||
*/
|
||||
mode?: 'files' | 'folders' | 'all';
|
||||
/**
|
||||
* `filters` options: file extension, i.e. .xlsx, .docx, .ppt, etc.
|
||||
* `filters` options: 'photo', 'folder', 'video', 'documentLibrary'
|
||||
*/
|
||||
filters?: ExtFilters[];
|
||||
/**
|
||||
* Specifies a filter for *where* the item may come from.
|
||||
*/
|
||||
locations?: {
|
||||
/**
|
||||
* Items may only come from the user's OneDrive.
|
||||
*/
|
||||
oneDrive?: {};
|
||||
/**
|
||||
* Items may only come from a specific location within SharePoint.
|
||||
*/
|
||||
sharePoint?: {
|
||||
byPath?: {
|
||||
web?: string;
|
||||
list?: string;
|
||||
folder?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Specifies filtering based on user access level.
|
||||
*/
|
||||
access?: {
|
||||
/**
|
||||
* Filter for requires user access level for picked items. Default is `'read'`.
|
||||
*/
|
||||
mode?: 'read' | 'read-write';
|
||||
};
|
||||
/**
|
||||
* Specifies which pivots the user may access while browsing files and lists.
|
||||
* Note that if a pivot is disabled here but still targeted in `entry`, it will still be visible in the nav.
|
||||
*/
|
||||
pivots?: {
|
||||
/**
|
||||
* Show "My files".
|
||||
*/
|
||||
oneDrive?: boolean;
|
||||
/**
|
||||
* Show "Recent".
|
||||
*/
|
||||
recent?: boolean;
|
||||
/**
|
||||
* Show "Shared"
|
||||
*/
|
||||
shared?: boolean;
|
||||
/**
|
||||
* Show "Quick access".
|
||||
*/
|
||||
sharedLibraries?: boolean;
|
||||
/**
|
||||
* Show "My organization".
|
||||
* This pivot is only supported in OneDrive for Business
|
||||
*/
|
||||
myOrganization?: boolean;
|
||||
/**
|
||||
* Show the site pivot
|
||||
* This pivot is only supported in OneDrive for Business
|
||||
*/
|
||||
site?: boolean;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Configuration for what item types may be selected within the picker and returned to the host.
|
||||
*/
|
||||
selection?: {
|
||||
/**
|
||||
* Controls how selection works within the list.
|
||||
* @default 'single' for the Picker.
|
||||
*/
|
||||
mode?: 'single' | 'multiple' | 'pick';
|
||||
/**
|
||||
* Whether or not to allow the user to maintain a selection across folders and pivots.
|
||||
*/
|
||||
enablePersistence?: boolean;
|
||||
/**
|
||||
* Whether or not the host expects to be notified whenever selection changes.
|
||||
*/
|
||||
enableNotifications?: boolean;
|
||||
/**
|
||||
* The maximum number of items which may be selected.
|
||||
*/
|
||||
maximumCount?: number;
|
||||
/**
|
||||
* A set of items to pre-select.
|
||||
*/
|
||||
sourceItems?: IItem[];
|
||||
};
|
||||
/**
|
||||
* Configures how commands behave within the experience.
|
||||
*/
|
||||
commands?: {
|
||||
/**
|
||||
* Specifies the behavior for file-picking.
|
||||
*/
|
||||
pick?: {
|
||||
/**
|
||||
* A special action to perform when picking the file, before handing the result
|
||||
* back to the host app.
|
||||
*/
|
||||
action?: 'select' | 'share' | 'download' | 'move';
|
||||
/**
|
||||
* A custom label to apply to the button which picks files.
|
||||
* This must be localized by the host app if supplied.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Configures the 'move' action for picking files.
|
||||
*/
|
||||
move?: {
|
||||
sourceItems?: IItem[];
|
||||
};
|
||||
/**
|
||||
* Configures the 'copy' action for picking files.
|
||||
*/
|
||||
copy?: {
|
||||
sourceItems?: IItem[];
|
||||
};
|
||||
/**
|
||||
* Configures the 'select' action for picking files.
|
||||
*/
|
||||
select?: {
|
||||
/**
|
||||
* Specify if we want download urls to be returned when items are selected.
|
||||
*/
|
||||
urls?: {
|
||||
download?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Specifies the behavior for closing the experience.
|
||||
*/
|
||||
close?: {
|
||||
/**
|
||||
* A custom label to apply to the 'cancel' button.
|
||||
* This must be localized by the host app if supplied.
|
||||
*/
|
||||
label?: string;
|
||||
};
|
||||
/**
|
||||
* Behavior for a "Browse this device" command to pick local files.
|
||||
*/
|
||||
browseThisDevice?: {
|
||||
enabled?: boolean;
|
||||
label?: string;
|
||||
mode?: 'upload' | 'pick';
|
||||
};
|
||||
/**
|
||||
* Behavior for a "From a link" command to pick from a link.
|
||||
*/
|
||||
fromALink?: {
|
||||
enabled?: boolean;
|
||||
mode?: 'nav' | 'pivot';
|
||||
};
|
||||
/**
|
||||
* Behavior for a "Switch account" command.
|
||||
*/
|
||||
switchAccount?: {
|
||||
mode?: 'host' | 'none';
|
||||
};
|
||||
/**
|
||||
* Behavior for a "Manage accounts" command.
|
||||
*/
|
||||
manageAccounts?: {
|
||||
mode?: 'host' | 'none';
|
||||
label?: string;
|
||||
};
|
||||
/**
|
||||
* Behavior for "Upload"
|
||||
*/
|
||||
upload?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
/**
|
||||
* Behavior for "Create folder"
|
||||
*/
|
||||
createFolder?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
/**
|
||||
* Behavior for "Filter by" in the column headers.
|
||||
*/
|
||||
filterByColumn?: {
|
||||
mode?: 'panel' | 'menu';
|
||||
};
|
||||
/**
|
||||
* How to handle actions defined by custom formatters.
|
||||
*/
|
||||
customFormatter?: {
|
||||
actions?: {
|
||||
key: string;
|
||||
mode?: 'host' | 'none';
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* How to handle specified values for `key` in custom commands
|
||||
* in the tray, nav, or command bar.
|
||||
*/
|
||||
custom?: {
|
||||
actions?: {
|
||||
key: string;
|
||||
/**
|
||||
* Filters defining what types of items the action operates on.
|
||||
* If specified, the action will only be available for items which match the given filters.
|
||||
*/
|
||||
filters?: ExtFilters[];
|
||||
/**
|
||||
* How the action is invoked.
|
||||
* 'host': Invokes a `custom` command message against the host app.
|
||||
* 'none': Disables the action.
|
||||
*/
|
||||
mode?: 'host' | 'none';
|
||||
/**
|
||||
* Selection criteria to which the item applies.
|
||||
*/
|
||||
selection?: 'single' | 'multiple' | 'current' | 'none';
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Specifies accessibility cues such as auto-focus behaviors.
|
||||
*/
|
||||
accessibility?: {
|
||||
/**
|
||||
* Whether or not to 'trap focus' within the component. If this is enabled, tab-stops will loop from the last element back to the left navigation automatically.
|
||||
* This is useful if the components's frame is hosted as the only content of a modal overlay and focus should not jump to the outside content.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
enableFocusTrap?: boolean;
|
||||
/**
|
||||
* Whether or not the component should immediately grab focus once the content has loaded.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
trapFocusOnLoad?: boolean;
|
||||
/**
|
||||
* Whether or not to force the currently-focused element within the component to be highlighted.
|
||||
* By default, the focused element is highlighted if the user navigates elements with the keyboard but not when the user interacts via the mouse.
|
||||
* However, if a host application launches the component due to keyboard input it should set this flag to `true` to ensure continuity of behavior.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
showFocusOnLoad?: boolean;
|
||||
};
|
||||
tray?: {
|
||||
/**
|
||||
* Configures the commands normally used to pick files or close the picker.
|
||||
*/
|
||||
commands?: {
|
||||
/**
|
||||
* A key to differentiate the command from others.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* A custom string for the command.
|
||||
* Must be localized by the host.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* The action to perform when the button is clicked.
|
||||
*/
|
||||
action: 'pick' | 'close' | 'custom';
|
||||
/**
|
||||
* If `'pick'` is specified, which pick behavior to use.
|
||||
*/
|
||||
pick?: {
|
||||
action: 'select' | 'share' | 'download' | 'move';
|
||||
};
|
||||
/**
|
||||
* Whether the button should show as the primary button.
|
||||
*/
|
||||
primary?: boolean;
|
||||
/**
|
||||
* Whether the button should remain visible at all times even if unavailable.
|
||||
*/
|
||||
permanent?: boolean;
|
||||
}[];
|
||||
/**
|
||||
* Whether or not the picker tray might be provided by the host instead.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
mode?: 'host' | 'default';
|
||||
/**
|
||||
* Configures a component to render in the picker tray to the left of the commands.
|
||||
* @default 'selection-summary'
|
||||
*/
|
||||
prompt?: 'keep-sharing' | 'selection-summary' | 'selection-editor' | 'save-as' | 'none';
|
||||
/**
|
||||
* Configures use of the 'save-as' prompt.
|
||||
*/
|
||||
saveAs?: {
|
||||
/**
|
||||
* Default file name to show in 'save-as' prompt.
|
||||
*/
|
||||
fileName?: string;
|
||||
};
|
||||
/**
|
||||
* Settings for handling conflicts with existing file names.
|
||||
*/
|
||||
conflicts?: {
|
||||
/**
|
||||
* How to handle when a file name matches an existing file.
|
||||
* `'warn'` - Show a prompt to ask the user to confirm the choice.
|
||||
* `'block'` - Block the choice as an error.
|
||||
* `'accept'` - Accept the choice automatically.
|
||||
* `'none'` - Do not try to match with existing items.
|
||||
*/
|
||||
mode?: 'warn' | 'block' | 'accept' | 'none';
|
||||
};
|
||||
/**
|
||||
* Configures use of the 'keep-sharing' prompt.
|
||||
*/
|
||||
keepSharing?: {
|
||||
active?: boolean;
|
||||
};
|
||||
};
|
||||
leftNav?: {
|
||||
/**
|
||||
* Whether or not a Left Nav should be rendered by the embedded content.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Mode of presentation of the nav.
|
||||
* If the nav is enabled but this is set to `host`, the embedded app
|
||||
* will show a button to ask the host app to show a nav.
|
||||
*/
|
||||
mode?: 'host' | 'default';
|
||||
/**
|
||||
* Indicates whether the left nav will be initially modal.
|
||||
*/
|
||||
initialModality?: 'modal' | 'hidden';
|
||||
|
||||
/**
|
||||
* Type of left nav
|
||||
*/
|
||||
preset?: 'oneDrive' | 'current-site';
|
||||
|
||||
/**
|
||||
* Custom commands to insert at the end of the left nav. Will appear before the default set.
|
||||
*/
|
||||
commands?: {
|
||||
/**
|
||||
* Name to use when notifying the host that the command is being invoked.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Localized string to use for the button text.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Type of action which will be performed when the command is clicked.
|
||||
* 'custom': Configured via `commands.custom`.
|
||||
*/
|
||||
action: 'custom' | 'pick' | 'close' | 'browse-this-device';
|
||||
/**
|
||||
* Name of a Fluent icon to use for the command button.
|
||||
*/
|
||||
icon?: string;
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* The theme to use for the file-picker. Will change the coloring.
|
||||
* Note: custom theme objects are expected in addition to the strings below
|
||||
* @default 'default': Light theme
|
||||
*/
|
||||
theme?: 'default' | 'dark' | 'lists';
|
||||
list?: {
|
||||
/**
|
||||
* A custom override for the initial list layout.
|
||||
*/
|
||||
layout?: {
|
||||
/**
|
||||
* Sets the preferred starting layout for the initial content.
|
||||
*/
|
||||
type?: 'details' | 'compact-details' | 'tiles';
|
||||
};
|
||||
/**
|
||||
* Configures scrolling behavior within the Picker.
|
||||
*/
|
||||
scrolling?: {
|
||||
enableStickyHeaders?: boolean;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Provides a header title for the Picker.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Specifies customizations for specific pivots
|
||||
*/
|
||||
pivots?: {
|
||||
/**
|
||||
* Customize the site pivot
|
||||
*/
|
||||
site?: {
|
||||
byPath?: {
|
||||
/**
|
||||
* Chose the site url to use for this pivot
|
||||
* Required to show the site pivot, if undefined
|
||||
* the site pivot will not be shown
|
||||
*/
|
||||
web?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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';
|
||||
|
|
129
client/src/hooks/Files/useSharePointDownload.ts
Normal file
129
client/src/hooks/Files/useSharePointDownload.ts
Normal 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,
|
||||
};
|
||||
}
|
57
client/src/hooks/Files/useSharePointFileHandling.ts
Normal file
57
client/src/hooks/Files/useSharePointFileHandling.ts
Normal 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,
|
||||
};
|
||||
}
|
383
client/src/hooks/Files/useSharePointPicker.ts
Normal file
383
client/src/hooks/Files/useSharePointPicker.ts
Normal 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,
|
||||
};
|
||||
}
|
46
client/src/hooks/Files/useSharePointToken.ts
Normal file
46
client/src/hooks/Files/useSharePointToken.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -2800,3 +2800,7 @@ html {
|
|||
.custom-style-2 {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sharepoint-picker-bg{
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SheetPaths, TextPaths, FilePaths, CodePaths } from '~/components/svg';
|
||||
import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client';
|
||||
import {
|
||||
megabyte,
|
||||
QueryKeys,
|
||||
|
|
264
config/flush-cache.js
Normal file
264
config/flush-cache.js
Normal file
|
@ -0,0 +1,264 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* LibreChat Cache Flush Utility
|
||||
*
|
||||
* This script flushes the cache store used by LibreChat, whether it's
|
||||
* Redis (if configured) or file-based cache.
|
||||
*
|
||||
* Usage:
|
||||
* npm run flush-cache
|
||||
* node config/flush-cache.js
|
||||
* node config/flush-cache.js --help
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Set up environment
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const { USE_REDIS, REDIS_URI, REDIS_KEY_PREFIX } = process.env;
|
||||
|
||||
// Simple utility function
|
||||
const isEnabled = (value) => value === 'true' || value === true;
|
||||
|
||||
async function showHelp() {
|
||||
console.log(`
|
||||
LibreChat Cache Flush Utility
|
||||
|
||||
DESCRIPTION:
|
||||
Flushes the cache store used by LibreChat. Automatically detects
|
||||
whether Redis or file-based cache is being used and flushes accordingly.
|
||||
|
||||
USAGE:
|
||||
npm run flush-cache
|
||||
node config/flush-cache.js [options]
|
||||
|
||||
OPTIONS:
|
||||
--help, -h Show this help message
|
||||
--dry-run Show what would be flushed without actually doing it
|
||||
--verbose, -v Show detailed output
|
||||
|
||||
CACHE TYPES:
|
||||
• Redis Cache: Flushes all keys with the configured Redis prefix
|
||||
• File Cache: Removes ./data/logs.json and ./data/violations.json
|
||||
|
||||
WHAT GETS FLUSHED:
|
||||
• User sessions and authentication tokens
|
||||
• Configuration cache
|
||||
• Model queries cache
|
||||
• Rate limiting data
|
||||
• Conversation titles cache
|
||||
• File upload progress
|
||||
• SharePoint tokens
|
||||
• And more...
|
||||
|
||||
NOTE: This will log out all users and may require them to re-authenticate.
|
||||
`);
|
||||
}
|
||||
|
||||
async function flushRedisCache(dryRun = false, verbose = false) {
|
||||
try {
|
||||
console.log('🔍 Redis cache detected');
|
||||
|
||||
if (verbose) {
|
||||
console.log(` URI: ${REDIS_URI ? REDIS_URI.replace(/\/\/.*@/, '//***:***@') : 'Not set'}`);
|
||||
console.log(` Prefix: ${REDIS_KEY_PREFIX || 'None'}`);
|
||||
}
|
||||
|
||||
// Create Redis client directly
|
||||
const Redis = require('ioredis');
|
||||
let redis;
|
||||
|
||||
// Handle cluster vs single Redis
|
||||
if (process.env.USE_REDIS_CLUSTER === 'true') {
|
||||
const hosts = REDIS_URI.split(',').map((uri) => {
|
||||
const [host, port] = uri.split(':');
|
||||
return { host, port: parseInt(port) || 6379 };
|
||||
});
|
||||
redis = new Redis.Cluster(hosts);
|
||||
} else {
|
||||
redis = new Redis(REDIS_URI);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('🔍 [DRY RUN] Would flush Redis cache');
|
||||
try {
|
||||
const keys = await redis.keys('*');
|
||||
console.log(` Would delete ${keys.length} keys`);
|
||||
if (verbose && keys.length > 0) {
|
||||
console.log(
|
||||
' Sample keys:',
|
||||
keys.slice(0, 10).join(', ') + (keys.length > 10 ? '...' : ''),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' Could not fetch keys for preview:', error.message);
|
||||
}
|
||||
await redis.disconnect();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get key count before flushing
|
||||
let keyCount = 0;
|
||||
try {
|
||||
const keys = await redis.keys('*');
|
||||
keyCount = keys.length;
|
||||
} catch (error) {
|
||||
// Continue with flush even if we can't count keys
|
||||
}
|
||||
|
||||
// Flush the Redis cache
|
||||
await redis.flushdb();
|
||||
console.log('✅ Redis cache flushed successfully');
|
||||
|
||||
if (keyCount > 0) {
|
||||
console.log(` Deleted ${keyCount} keys`);
|
||||
}
|
||||
|
||||
await redis.disconnect();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Error flushing Redis cache:', error.message);
|
||||
if (verbose) {
|
||||
console.error(' Full error:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function flushFileCache(dryRun = false, verbose = false) {
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
const filesToClear = [path.join(dataDir, 'logs.json'), path.join(dataDir, 'violations.json')];
|
||||
|
||||
console.log('🔍 Checking file-based cache');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('🔍 [DRY RUN] Would flush file cache');
|
||||
for (const filePath of filesToClear) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
console.log(
|
||||
` Would delete: ${path.basename(filePath)} (${(stats.size / 1024).toFixed(1)} KB)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
for (const filePath of filesToClear) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
totalSize += stats.size;
|
||||
fs.unlinkSync(filePath);
|
||||
deletedCount++;
|
||||
if (verbose) {
|
||||
console.log(
|
||||
` ✅ Deleted ${path.basename(filePath)} (${(stats.size / 1024).toFixed(1)} KB)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.log(` ❌ Failed to delete ${path.basename(filePath)}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log('✅ File cache flushed successfully');
|
||||
console.log(` Deleted ${deletedCount} cache files (${(totalSize / 1024).toFixed(1)} KB)`);
|
||||
} else {
|
||||
console.log('ℹ️ No file cache to flush');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function restartRecommendation() {
|
||||
console.log('\n💡 RECOMMENDATION:');
|
||||
console.log(' For complete cache clearing, especially for in-memory caches,');
|
||||
console.log(' consider restarting the LibreChat backend:');
|
||||
console.log('');
|
||||
console.log(' npm run backend:stop');
|
||||
console.log(' npm run backend:dev');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const verbose = args.includes('--verbose') || args.includes('-v');
|
||||
const help = args.includes('--help') || args.includes('-h');
|
||||
|
||||
if (help) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🧹 LibreChat Cache Flush Utility');
|
||||
console.log('================================');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('🔍 DRY RUN MODE - No actual changes will be made\n');
|
||||
}
|
||||
|
||||
let success = true;
|
||||
const isRedisEnabled = isEnabled(USE_REDIS) && REDIS_URI;
|
||||
|
||||
// Flush the appropriate cache type
|
||||
if (isRedisEnabled) {
|
||||
success = (await flushRedisCache(dryRun, verbose)) && success;
|
||||
} else {
|
||||
console.log('ℹ️ Redis not configured, using file-based cache only');
|
||||
}
|
||||
|
||||
// Always check file cache
|
||||
success = (await flushFileCache(dryRun, verbose)) && success;
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
|
||||
if (success) {
|
||||
if (dryRun) {
|
||||
console.log('✅ Cache flush preview completed');
|
||||
console.log(' Run without --dry-run to actually flush the cache');
|
||||
} else {
|
||||
console.log('✅ Cache flush completed successfully');
|
||||
console.log('⚠️ Note: All users will need to re-authenticate');
|
||||
}
|
||||
|
||||
if (!isRedisEnabled) {
|
||||
await restartRecommendation();
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Cache flush completed with errors');
|
||||
console.log(' Check the output above for details');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors gracefully
|
||||
process.on('unhandledRejection', (error) => {
|
||||
console.error('❌ Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('❌ Uncaught exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Run the main function
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { flushRedisCache, flushFileCache };
|
|
@ -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",
|
||||
|
|
|
@ -29,7 +29,8 @@ interface DropdownProps {
|
|||
type MenuProps = Omit<
|
||||
DropdownProps,
|
||||
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
|
||||
>;
|
||||
> &
|
||||
Ariakit.MenuProps;
|
||||
|
||||
const DropdownPopup: React.FC<DropdownProps> = ({
|
||||
trigger,
|
||||
|
@ -70,7 +71,9 @@ const Menu: React.FC<MenuProps> = ({
|
|||
finalFocus,
|
||||
unmountOnHide,
|
||||
preserveTabOrder,
|
||||
...props
|
||||
}) => {
|
||||
const menuStore = Ariakit.useMenuStore();
|
||||
const menu = Ariakit.useMenuContext();
|
||||
return (
|
||||
<Ariakit.Menu
|
||||
|
@ -83,13 +86,53 @@ const Menu: React.FC<MenuProps> = ({
|
|||
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 <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
|
||||
}
|
||||
if (subItems && subItems.length > 0) {
|
||||
return (
|
||||
<Ariakit.MenuProvider
|
||||
store={menuStore}
|
||||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}-provider`}
|
||||
>
|
||||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
id={item.id}
|
||||
render={item.render}
|
||||
ref={item.ref}
|
||||
// hideOnClick={item.hideOnClick}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.icon != null && (
|
||||
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
<Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" />
|
||||
</Ariakit.MenuButton>
|
||||
<Menu
|
||||
items={subItems}
|
||||
menuId={`${menuId}-${index}`}
|
||||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
|
||||
gutter={12}
|
||||
portal={true}
|
||||
/>
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Ariakit.MenuItem
|
||||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
|
||||
|
|
8
packages/client/src/svgs/SharePointIcon.tsx
Normal file
8
packages/client/src/svgs/SharePointIcon.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
export default function SharePointIcon({ className = '' }) {
|
||||
return (
|
||||
<svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" className={className}>
|
||||
<path d="M24 13.5q0 1.242-.475 2.332-.474 1.09-1.289 1.904-.814.815-1.904 1.29-1.09.474-2.332.474-.762 0-1.523-.2-.106.997-.557 1.858-.451.862-1.154 1.494-.704.633-1.606.99-.902.358-1.91.358-1.09 0-2.045-.416-.955-.416-1.664-1.125-.709-.709-1.125-1.664Q6 19.84 6 18.75q0-.188.018-.375.017-.188.04-.375H.997q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.54q.14-1.277.726-2.373.586-1.096 1.488-1.904Q7.652.914 8.807.457 9.96 0 11.25 0q1.395 0 2.625.533T16.02 1.98q.914.915 1.447 2.145T18 6.75q0 .188-.012.375-.011.188-.035.375 1.242 0 2.344.469 1.101.468 1.928 1.277.826.809 1.3 1.904Q24 12.246 24 13.5zm-12.75-12q-.973 0-1.857.34-.885.34-1.577.943-.691.604-1.154 1.43Q6.2 5.039 6.06 6h4.945q.41 0 .703.293t.293.703v4.945l.21-.035q.212-.75.61-1.424.399-.673.944-1.218.545-.545 1.213-.944.668-.398 1.43-.61.093-.503.093-.96 0-1.09-.416-2.045-.416-.955-1.125-1.664-.709-.709-1.664-1.125Q12.34 1.5 11.25 1.5zM6.117 15.902q.54 0 1.06-.111.522-.111.932-.37.41-.257.662-.679.252-.422.252-1.055 0-.632-.263-1.054-.264-.422-.662-.703-.399-.282-.856-.463l-.855-.34q-.399-.158-.662-.334-.264-.176-.264-.445 0-.2.14-.323.141-.123.335-.193.193-.07.404-.094.21-.023.351-.023.598 0 1.055.152.457.153.95.457V8.543q-.282-.082-.522-.14-.24-.06-.475-.1-.234-.041-.486-.059-.252-.017-.557-.017-.515 0-1.054.117-.54.117-.979.375-.44.258-.715.68-.275.421-.275 1.03 0 .598.263.997.264.398.663.68.398.28.855.474l.856.363q.398.17.662.358.263.187.263.457 0 .222-.123.351-.123.13-.31.2-.188.07-.393.087-.205.018-.369.018-.703 0-1.248-.234-.545-.235-1.107-.621v1.875q1.195.468 2.472.468zM11.25 22.5q.773 0 1.453-.293t1.19-.803q.51-.51.808-1.195.299-.686.299-1.459 0-.668-.223-1.277-.222-.61-.62-1.096-.4-.486-.95-.826-.55-.34-1.207-.48v1.933q0 .41-.293.703t-.703.293H7.57q-.07.375-.07.75 0 .773.293 1.459t.803 1.195q.51.51 1.195.803.686.293 1.459.293zM18 18q.926 0 1.746-.352.82-.351 1.436-.966.615-.616.966-1.43.352-.815.352-1.752 0-.926-.352-1.746-.351-.82-.966-1.436-.616-.615-1.436-.966Q18.926 9 18 9t-1.74.357q-.815.358-1.43.973t-.973 1.43q-.357.814-.357 1.74 0 .129.006.258t.017.258q.551.27 1.02.65t.838.855q.369.475.627 1.026.258.55.387 1.148Q17.18 18 18 18Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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)}`;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<q.GraphTokenResponse> {
|
||||
return request.get(endpoints.graphToken(params.scopes));
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ export enum QueryKeys {
|
|||
banner = 'banner',
|
||||
/* Memories */
|
||||
memories = 'memories',
|
||||
graphToken = 'graphToken',
|
||||
}
|
||||
|
||||
// Dynamic query keys that require parameters
|
||||
|
|
|
@ -147,3 +147,15 @@ export interface MCPAuthValuesResponse {
|
|||
serverName: string;
|
||||
authValueFlags: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/* SharePoint Graph API Token */
|
||||
export type GraphTokenParams = {
|
||||
scopes: string;
|
||||
};
|
||||
|
||||
export type GraphTokenResponse = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue