From 2a86805648f45c36c479c6f9a0127cae799ee14f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 10:50:38 +0900 Subject: [PATCH] refac --- backend/open_webui/routers/files.py | 51 +- src/lib/apis/files/index.ts | 101 +-- src/lib/components/common/Dropdown.svelte | 3 +- src/lib/components/common/Modal.svelte | 22 +- src/lib/components/layout/FilesModal.svelte | 810 +++++++++++--------- src/lib/i18n/locales/en-US/translation.json | 20 +- 6 files changed, 560 insertions(+), 447 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 9d24ca9aac..8b632ce093 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -511,6 +511,15 @@ async def list_files( ############################ +async def _search_files(filename: str, content: bool, skip: int, limit: int, user_id: str | None, db: AsyncSession): + files = await Files.search_files(user_id=user_id, filename=filename, skip=skip, limit=limit, db=db) + if not content: + for file in files: + if file.data and 'content' in file.data: + del file.data['content'] + return files + + @router.get('/search', response_model=list[FileModelResponse]) async def search_files( filename: str = Query( @@ -523,36 +532,28 @@ async def search_files( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - """ - Search for files by filename with support for wildcard patterns. - Uses SQL-based filtering with pagination for better performance. - """ - # Determine user_id: null for admin with bypass (search all), user.id otherwise user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id - - # Use optimized database query with pagination - files = await Files.search_files( - user_id=user_id, - filename=filename, - skip=skip, - limit=limit, - db=db, - ) - + files = await _search_files(filename, content, skip, limit, user_id, db) if not files: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No files found matching the pattern.', ) - - if not content: - for file in files: - if file.data and 'content' in file.data: - del file.data['content'] - return files +@router.get('/user/search', response_model=list[FileModelResponse]) +async def search_user_files( + filename: str = Query('*'), + content: bool = Query(False), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=1000), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return await _search_files(filename, content, skip, limit, user.id, db) + + ############################ # Count Files ############################ @@ -567,6 +568,14 @@ async def count_files( return await Files.count_files_by_user_id(user_id=user_id, db=db) +@router.get('/user/count', response_model=int) +async def count_user_files( + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return await Files.count_files_by_user_id(user_id=user.id, db=db) + + ############################ # Delete All Files ############################ diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts index 6574627f89..633d048932 100644 --- a/src/lib/apis/files/index.ts +++ b/src/lib/apis/files/index.ts @@ -179,73 +179,56 @@ export const getFiles = async (token: string = '', content: boolean = false) => return res; }; -export const searchFiles = async ( +const searchFilesByPath = async ( + path: string, + token: string, + filename: string, + skip: number, + limit: number, + content: boolean +) => { + const searchParams = new URLSearchParams({ + filename, + skip: String(skip), + limit: String(limit), + content: String(content) + }); + const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}?${searchParams}`, { + headers: { Accept: 'application/json', authorization: `Bearer ${token}` } + }); + if (!res.ok) { + const error = await res.json(); + throw error.detail ?? error; + } + return res.json(); +}; + +export const searchFiles = ( token: string, filename: string = '*', skip: number = 0, limit: number = 50, content: boolean = false -) => { - let error = null; +) => searchFilesByPath('search', token, filename, skip, limit, content); - const searchParams = new URLSearchParams(); - searchParams.append('filename', filename); - searchParams.append('skip', String(skip)); - searchParams.append('limit', String(limit)); - searchParams.append('content', String(content)); +export const searchUserFiles = ( + token: string, + filename: string = '*', + skip: number = 0, + limit: number = 50, + content: boolean = false +) => searchFilesByPath('user/search', token, filename, skip, limit, content); - const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return []; - }); - - if (error) { - throw error; - } - - return res; +const getFileCountByPath = async (path: string, token: string) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}`, { + headers: { Accept: 'application/json', authorization: `Bearer ${token}` } + }); + if (!res.ok) throw await res.json(); + return res.json(); }; -export const getFileCount = async (token: string = '') => { - let error = null; - - const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err; - console.error(err); - return null; - }); - - if (error) { - throw error; - } - - return res; -}; +export const getFileCount = (token: string = '') => getFileCountByPath('count', token); +export const getUserFileCount = (token: string = '') => getFileCountByPath('user/count', token); export const getFileById = async (token: string, id: string) => { let error = null; @@ -388,7 +371,7 @@ export const deleteFileById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err.detail; + error = err.detail ?? err; console.error(err); return null; }); diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index 4dfa5456bf..08faf8db31 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -87,7 +87,8 @@ function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - toggleOpen(); + if (show) closeDropdown(); + else openDropdown(true); } } node.addEventListener('click', handleClick); diff --git a/src/lib/components/common/Modal.svelte b/src/lib/components/common/Modal.svelte index 1a578cbe60..aa194618d3 100644 --- a/src/lib/components/common/Modal.svelte +++ b/src/lib/components/common/Modal.svelte @@ -49,6 +49,9 @@ (event.key === 'Escape' || ($settings?.keyboardShortcuts !== false && matchKeybinding(event) === Shortcut.CLOSE_MODAL)) && + !( + event.target instanceof Element && event.target.closest('[role="menu"], [role="listbox"]') + ) && isTopModal() ) { requestClose(); @@ -60,14 +63,16 @@ return modals.length && modals[modals.length - 1] === modalElement; }; - let handleOutsidePointerDown: ((event: PointerEvent) => void) | undefined; + let handleOutsideInteraction: ((event: Event) => void) | undefined; let handleModalFocusIn: (() => void) | undefined; const release = () => { if (typeof document === 'undefined') return; focusTrap?.deactivate(); focusTrap = null; - if (handleOutsidePointerDown) - document.removeEventListener('pointerdown', handleOutsidePointerDown, true); + if (handleOutsideInteraction) { + document.removeEventListener('pointerdown', handleOutsideInteraction, true); + window.removeEventListener('focusin', handleOutsideInteraction, true); + } if (handleModalFocusIn) modalElement?.removeEventListener('focusin', handleModalFocusIn); window.removeEventListener('keydown', handleKeyDown); if (previousOverflow !== null) { @@ -90,13 +95,15 @@ }); focusTrap.activate(); - // Auto-pause focus trap when interacting with portaled content (e.g. Dropdown) - handleOutsidePointerDown = (e) => { + // Portaled menus need keyboard focus as well as pointer interaction. + handleOutsideInteraction = (e) => { if ( focusTrap && modalElement && e.target instanceof Node && - !modalElement.contains(e.target) + !modalElement.contains(e.target) && + (e.type === 'pointerdown' || + (e.target instanceof Element && e.target.closest('[role="menu"], [role="listbox"]'))) ) { focusTrap.pause(); } @@ -106,7 +113,8 @@ focusTrap.unpause(); } }; - document.addEventListener('pointerdown', handleOutsidePointerDown, true); + document.addEventListener('pointerdown', handleOutsideInteraction, true); + window.addEventListener('focusin', handleOutsideInteraction, true); modalElement.addEventListener('focusin', handleModalFocusIn); window.addEventListener('keydown', handleKeyDown); diff --git a/src/lib/components/layout/FilesModal.svelte b/src/lib/components/layout/FilesModal.svelte index 45dd203484..1cbbf0d9d7 100644 --- a/src/lib/components/layout/FilesModal.svelte +++ b/src/lib/components/layout/FilesModal.svelte @@ -1,23 +1,33 @@ { - if (selectedFileId) { - deleteHandler(selectedFileId); - selectedFileId = null; - } - }} + title={$i18n.t('Delete {{count}} files?', { count: deleteIds.length })} + message={$i18n.t('This cannot be undone. References to these files may stop working.')} + confirmLabel={$i18n.t('Delete')} + on:confirm={deleteHandler} /> - - -
-
-
-
{$i18n.t('Files')}
- {#if query && files} -
- {files.length} -
- {:else if fileCount !== null} -
- {fileCount} -
- {/if} + { + if (!operation) show = false; + }} + ariaLabel={$i18n.t('Files')} +> +
+
+
+ {$i18n.t('Files')} + {#if fileCount !== null}{fileCount}{/if}
+ + +
+ + + + +
+
- - - -
-
- -
-
-
- - - -
- +
+ {#if operation} + + {$i18n.t( + operation === 'download' + ? 'Preparing {{processed}} of {{total}}' + : 'Processed {{processed}} of {{total}}', + { processed: progress.done, total: progress.total } + )} + + {:else} + + + {#if query}{/if} + {#if selectedIds.size} + {$i18n.t('{{count}} selected', { count: selectedIds.size })} + + {/if} + {/if} +
- {#if query} -
- -
- {/if} -
+ {#if selectionStatus === 'loading'} +
+ {$i18n.t('Selecting all matching files...')}
+ {:else if selectionStatus === 'error'} + + {:else if allLoadedSelected && !allFilesLoaded && selectionStatus !== 'all' && !operation} + + {/if} - -
- {#if files !== null} -
- {#if files.length > 0} -
- - -
- {/if} - -
- {#if files.length === 0} -
- {$i18n.t('No files found')} -
- {/if} - - {#each files as file (file.id)} -
openFileViewer(file)} - > -
-
{file.filename}
-
- {formatFileSize(file.meta?.size ?? 0)} -
-
- -
- - {dayjs(file.created_at * 1000) - .locale($i18n.language) - .format('MMM D, YYYY')} - - -
- - - -
-
-
- {/each} - - {#if !allFilesLoaded} - { - if (!filesLoading) { - loadMoreFiles(); - } - }} - > -
- -
{$i18n.t('Loading...')}
-
-
- {/if} + {#if files !== null} + {#if files.length} +
+
+ 0 && !allLoadedSelected} + disabled={busy} + ariaLabel={$i18n.t('Select loaded files')} + on:change={() => + toggleSelection( + (files ?? []).map((file) => file.id), + !allLoadedSelected + )} + /> +
+ + {$i18n.t('Size')} + +
+ {/if} +
+ {#if !files.length && !loadError}
+ {$i18n.t('No files found')} +
{/if} + {#each sortedFiles as file (file.id)} +
+
+ toggleSelection([file.id], !selectedIds.has(file.id))} + />
+ + {formatFileSize(file.meta?.size ?? 0)} + + + +
+ + + + +
+
- {:else} -
- + {/each} + {#if loadError} + + {:else if !allFilesLoaded && !busy} + loadFiles(true)} + >
{/if}
-
+ {:else} +
+ {/if}
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index f1c4765a33..7b6b7c094b 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -4087,5 +4087,23 @@ "YouTube": "", "YouTube video player": "", "Zoom in": "", - "Zoom out": "" + "Zoom out": "", + "Delete {{count}} files?_one": "Delete {{count}} file?", + "Delete {{count}} files?_other": "Delete {{count}} files?", + "This cannot be undone. References to these files may stop working.": "This cannot be undone. References to these files may stop working.", + "{{count}} selected": "{{count}} selected", + "Clear selection": "Clear selection", + "Select all matching files": "Select all matching files", + "Selecting all matching files...": "Selecting all matching files...", + "Could not select all matching files. Your selection has not changed.": "Could not select all matching files. Your selection has not changed.", + "Select loaded files": "Select loaded files", + "Select {{filename}}": "Select {{filename}}", + "Delete {{filename}}": "Delete {{filename}}", + "Could not load files.": "Could not load files.", + "Processed {{processed}} of {{total}}": "Processed {{processed}} of {{total}}", + "Stopping...": "Stopping...", + "{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.": "{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.", + "Selection actions": "Selection actions", + "Actions for {{filename}}": "Actions for {{filename}}", + "Preparing {{processed}} of {{total}}": "Preparing {{processed}} of {{total}}" }