Parcourir la source

feat: add onedrive file picker

Gunwoo Hur il y a 2 mois
Parent
commit
0335d479f9

+ 22 - 0
package-lock.json

@@ -8,6 +8,7 @@
 			"name": "open-webui",
 			"name": "open-webui",
 			"version": "0.5.16",
 			"version": "0.5.16",
 			"dependencies": {
 			"dependencies": {
+				"@azure/msal-browser": "^4.4.0",
 				"@codemirror/lang-javascript": "^6.2.2",
 				"@codemirror/lang-javascript": "^6.2.2",
 				"@codemirror/lang-python": "^6.1.6",
 				"@codemirror/lang-python": "^6.1.6",
 				"@codemirror/language-data": "^6.5.1",
 				"@codemirror/language-data": "^6.5.1",
@@ -134,6 +135,27 @@
 				"node": ">=6.0.0"
 				"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": {
 		"node_modules/@babel/runtime": {
 			"version": "7.24.1",
 			"version": "7.24.1",
 			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz",
 			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz",

+ 1 - 0
package.json

@@ -51,6 +51,7 @@
 	},
 	},
 	"type": "module",
 	"type": "module",
 	"dependencies": {
 	"dependencies": {
+		"@azure/msal-browser": "^4.4.0",
 		"@codemirror/lang-javascript": "^6.2.2",
 		"@codemirror/lang-javascript": "^6.2.2",
 		"@codemirror/lang-python": "^6.1.6",
 		"@codemirror/lang-python": "^6.1.6",
 		"@codemirror/language-data": "^6.5.1",
 		"@codemirror/language-data": "^6.5.1",

+ 21 - 0
src/lib/components/chat/MessageInput.svelte

@@ -2,6 +2,7 @@
 	import { toast } from 'svelte-sonner';
 	import { toast } from 'svelte-sonner';
 	import { v4 as uuidv4 } from 'uuid';
 	import { v4 as uuidv4 } from 'uuid';
 	import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
 	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';
 	import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
 	const dispatch = createEventDispatcher();
 	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 () => {
 											onClose={async () => {
 												await tick();
 												await tick();
 
 

+ 31 - 0
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -5,6 +5,7 @@
 
 
 	import { config, user, tools as _tools, mobile } from '$lib/stores';
 	import { config, user, tools as _tools, mobile } from '$lib/stores';
 	import { createPicker } from '$lib/utils/google-drive-picker';
 	import { createPicker } from '$lib/utils/google-drive-picker';
+	
 	import { getTools } from '$lib/apis/tools';
 	import { getTools } from '$lib/apis/tools';
 
 
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -24,6 +25,7 @@
 	export let inputFilesHandler: Function;
 	export let inputFilesHandler: Function;
 
 
 	export let uploadGoogleDriveHandler: Function;
 	export let uploadGoogleDriveHandler: Function;
+	export let uploadOneDriveHandler: Function;
 
 
 	export let selectedToolIds: string[] = [];
 	export let selectedToolIds: string[] = [];
 
 
@@ -225,6 +227,35 @@
 					<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
 					<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
 				</DropdownMenu.Item>
 				</DropdownMenu.Item>
 			{/if}
 			{/if}
+
+			{#if $config?.features?.enable_onedrive_integration || true}
+				<DropdownMenu.Item
+					class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
+					on:click={() => {
+						uploadOneDriveHandler();
+					}}
+				>
+					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-5 h-5">
+						<path
+							d="M21.69 13.91l-5.5-3.16l-4.08 3.45l-1.87-1.08l-4.86 4.47l.86.5a2.998 2.998 0 0 0 4.09-1.11a3 3 0 0 0 4.09-1.11a3.06 3.06 0 0 0 1.27-.13a3 3 0 0 0 4.09-1.11a2.81 2.81 0 0 0 1.91-.72z"
+							fill="#0364B8"
+						/>
+						<path
+							d="M7.5 13.5L2 10.5l5-3l5.5 3.16l-5 2.84z"
+							fill="#0078D4"
+						/>
+						<path
+							d="M16.19 10.75L12 7.94V4.5l5.5 3.16l-1.31 3.09z"
+							fill="#1490DF"
+						/>
+						<path
+							d="M12 4.5l-5 3l-5-3l5-3l5 3z"
+							fill="#28A8EA"
+						/>
+					</svg>
+					<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
+				</DropdownMenu.Item>
+			{/if}
 		</DropdownMenu.Content>
 		</DropdownMenu.Content>
 	</div>
 	</div>
 </Dropdown>
 </Dropdown>

+ 42 - 0
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<string> {
+    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;
+  }

+ 211 - 0
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<any> {
+	// SSR 환경(SvelteKit)에서 window 객체가 없을 수 있으므로 가드
+	if (typeof window === 'undefined') {
+		throw new Error('Not in browser environment');
+	}
+
+	return new Promise<any>(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);
+		}
+	});
+}