diff --git a/package-lock.json b/package-lock.json index c65870772..066cf2be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "open-webui", "version": "0.5.16", "dependencies": { + "@azure/msal-browser": "^4.4.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -134,6 +135,27 @@ "node": ">=6.0.0" } }, + "node_modules/@azure/msal-browser": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.4.0.tgz", + "integrity": "sha512-rU6juYXk67CKQmpgi6fDgZoPQ9InZ1760z1BSAH7RbeIc4lHZM/Tu+H0CyRk7cnrfvTkexyYE4pjYhMghpzheA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/runtime": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", diff --git a/package.json b/package.json index 86568869f..0e8fe2bc6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "type": "module", "dependencies": { + "@azure/msal-browser": "^4.4.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 38cb91cc0..a81139d2f 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -2,6 +2,7 @@ import { toast } from 'svelte-sonner'; import { v4 as uuidv4 } from 'uuid'; import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker'; + import { openOneDrivePicker } from '$lib/utils/onedrive-file-picker'; import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte'; const dispatch = createEventDispatcher(); @@ -1108,6 +1109,26 @@ ); } }} + uploadOneDriveHandler={async () => { + try { + const fileData = await openOneDrivePicker(); + if (fileData) { + const file = new File([fileData.blob], fileData.name, { + type: fileData.blob.type + }); + await uploadFileHandler(file); + } else { + console.log('No file was selected from OneDrive'); + } + } catch (error) { + console.error('OneDrive Error:', error); + toast.error( + $i18n.t('Error accessing OneDrive: {{error}}', { + error: error.message + }) + ); + } + }} onClose={async () => { await tick(); diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 801093d8f..91f9cf81b 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -5,6 +5,7 @@ import { config, user, tools as _tools, mobile } from '$lib/stores'; import { createPicker } from '$lib/utils/google-drive-picker'; + import { getTools } from '$lib/apis/tools'; import Dropdown from '$lib/components/common/Dropdown.svelte'; @@ -24,6 +25,7 @@ export let inputFilesHandler: Function; export let uploadGoogleDriveHandler: Function; + export let uploadOneDriveHandler: Function; export let selectedToolIds: string[] = []; @@ -225,6 +227,35 @@
{$i18n.t('Google Drive')}
{/if} + + {#if $config?.features?.enable_onedrive_integration || true} + { + uploadOneDriveHandler(); + }} + > + + + + + + +
{$i18n.t('OneDrive')}
+
+ {/if} diff --git a/src/lib/utils/onedrive-auth.ts b/src/lib/utils/onedrive-auth.ts new file mode 100644 index 000000000..be2de44a0 --- /dev/null +++ b/src/lib/utils/onedrive-auth.ts @@ -0,0 +1,42 @@ +import { PublicClientApplication } from '@azure/msal-browser'; + +const msalParams = { + auth: { + authority: 'https://login.microsoftonline.com/consumers', + clientId: '2ab80a1e-7300-4cb1-beac-c38c730e8b7f' + } +}; + +// MSAL 초기화 +const app = new PublicClientApplication(msalParams); + +export async function initializeMsal() { + try { + await app.initialize(); + console.log('MSAL initialized successfully'); + } catch (error) { + console.error('MSAL initialization error:', error); + } + } + + export async function getToken(): Promise { + const authParams = { scopes: ['OneDrive.ReadWrite'] }; + let accessToken = ''; + + try { + // Ensure initialization happens early + await initializeMsal(); + const resp = await app.acquireTokenSilent(authParams); + accessToken = resp.accessToken; + } catch (err) { + const resp = await app.loginPopup(authParams); + app.setActiveAccount(resp.account); + + if (resp.idToken) { + const resp2 = await app.acquireTokenSilent(authParams); + accessToken = resp2.accessToken; + } + } + + return accessToken; + } diff --git a/src/lib/utils/onedrive-file-picker.ts b/src/lib/utils/onedrive-file-picker.ts new file mode 100644 index 000000000..d003e38ec --- /dev/null +++ b/src/lib/utils/onedrive-file-picker.ts @@ -0,0 +1,211 @@ +// src/lib/utils/onedrive-file-picker.ts +import { getToken } from './onedrive-auth'; + + +const baseUrl = "https://onedrive.live.com/picker"; +const params = { + sdk: '8.0', + entry: { + oneDrive: { + files: {} + } + }, + authentication: {}, + messaging: { + origin: 'http://localhost:3000', // 현재 부모 페이지의 origin + channelId: '27' // 메시징 채널용 임의의 ID + }, + typesAndSources: { + mode: 'files', + pivots: { + oneDrive: true, + recent: true + } + } +}; + +/** + * OneDrive 파일 피커 창을 열고, 사용자가 선택한 파일 메타데이터를 받아오는 함수 + */ +export async function openOneDrivePicker(): Promise { + // SSR 환경(SvelteKit)에서 window 객체가 없을 수 있으므로 가드 + if (typeof window === 'undefined') { + throw new Error('Not in browser environment'); + } + + return new Promise(async (resolve, reject) => { + let pickerWindow: Window | null = null; + let channelPort: MessagePort | null = null; + + try { + const authToken = await getToken(); + if (!authToken) { + return reject(new Error('Failed to acquire access token')); + } + + // 팝업 창 오픈 + pickerWindow = window.open('', 'OneDrivePicker', 'width=800,height=600'); + if (!pickerWindow) { + return reject(new Error('Failed to open OneDrive picker window')); + } + + // 쿼리스트링 구성 + const queryString = new URLSearchParams({ + filePicker: JSON.stringify(params) + }); + const url = `${baseUrl}?${queryString.toString()}`; + + // 새로 연 window에 form을 동적으로 추가하여 POST + const form = pickerWindow.document.createElement('form'); + form.setAttribute('action', url); + form.setAttribute('method', 'POST'); + + const input = pickerWindow.document.createElement('input'); + input.setAttribute('type', 'hidden'); + input.setAttribute('name', 'access_token'); + input.setAttribute('value', authToken); + + form.appendChild(input); + pickerWindow.document.body.appendChild(form); + form.submit(); + + // 부모 창에서 message 이벤트 수신 + const handleWindowMessage = (event: MessageEvent) => { + // pickerWindow가 아닌 다른 window에서 온 메시지는 무시 + if (event.source !== pickerWindow) return; + + const message = event.data; + + // 초기화 메시지 => SharedWorker(MessageChannel) 식으로 포트 받기 + if ( + message?.type === 'initialize' && + message?.channelId === params.messaging.channelId + ) { + channelPort = event.ports?.[0]; + if (!channelPort) return; + + channelPort.addEventListener('message', handlePortMessage); + channelPort.start(); + + // picker iframe에 'activate' 전달 + channelPort.postMessage({ + type: 'activate' + }); + } + }; + + // 포트 메시지 핸들러 + const handlePortMessage = async (portEvent: MessageEvent) => { + const portData = portEvent.data; + switch (portData.type) { + case 'notification': + console.log('notification:', portData); + break; + + case 'command': { + // picker에 응답 + channelPort?.postMessage({ + type: 'acknowledge', + id: portData.id + }); + + const command = portData.data; + + switch (command.command) { + case 'authenticate': { + // 재인증 + try { + const newToken = await getToken(); + if (newToken) { + channelPort?.postMessage({ + type: 'result', + id: portData.id, + data: { + result: 'token', + token: newToken + } + }); + } else { + throw new Error('Could not retrieve auth token'); + } + } catch (err) { + console.error(err); + channelPort?.postMessage({ + result: 'error', + error: { + code: 'tokenError', + message: 'Failed to get token' + }, + isExpected: true + }); + } + break; + } + + case 'close': { + // 사용자가 취소하거나 닫았을 경우 + cleanup(); + resolve(null); + break; + } + + case 'pick': { + // 사용자가 파일 선택 완료 + console.log('Picked:', command); + /** + * command 안에는 사용자가 선택한 파일들의 메타데이터 정보가 들어있습니다. + * 필요하다면 Microsoft Graph API 등을 통해 Blob(실제 파일 데이터)을 받아와야 할 수 있습니다. + */ + + // picker에 응답 + channelPort?.postMessage({ + type: 'result', + id: portData.id, + data: { + result: 'success' + } + }); + + // 선택한 파일들(메타정보)을 resolve + cleanup(); + resolve(command); + break; + } + + default: { + console.warn('Unsupported command:', command); + channelPort?.postMessage({ + result: 'error', + error: { + code: 'unsupportedCommand', + message: command.command + }, + isExpected: true + }); + break; + } + } + break; + } + } + }; + + function cleanup() { + window.removeEventListener('message', handleWindowMessage); + if (channelPort) { + channelPort.removeEventListener('message', handlePortMessage); + } + if (pickerWindow) { + pickerWindow.close(); + pickerWindow = null; + } + } + + // 메시지 이벤트 등록 + window.addEventListener('message', handleWindowMessage); + } catch (err) { + if (pickerWindow) pickerWindow.close(); + reject(err); + } + }); +}