diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 6c7bdc64dc..020cf00144 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1057,14 +1057,24 @@ async def remove_file_from_knowledge_by_id( pass # Delete the object-storage blob before dropping the DB row so we - # still have file.path available; previously this endpoint only - # removed the DB record and orphaned the blob in S3/GCS/local. + # still have file.path available. If storage deletion fails we + # must NOT drop the DB row: a transient S3/GCS failure would + # otherwise leave an orphan blob with no metadata to retry + # against. Keeping the file row lets an operator re-issue the + # delete once the backend recovers. try: Storage.delete_file(file.path) except Exception as storage_err: - log.warning( + log.exception( f'Failed to delete storage blob for {form_data.file_id}: {storage_err}' ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=( + 'File unlinked from knowledge base, but deleting the ' + 'stored blob failed; file record kept for retry.' + ), + ) # Delete file from database await Files.delete_file_by_id(form_data.file_id, db=db) diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index f04d0efea1..8d9e7e968a 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -481,9 +481,15 @@ // files, or we resolve with an empty list and the caller's // existing empty-check toasts. let settled = false; + let changeStarted = false; + let focusTimer: ReturnType | null = null; const finish = (err?: unknown) => { if (settled) return; settled = true; + if (focusTimer !== null) { + clearTimeout(focusTimer); + focusTimer = null; + } if (input.parentNode) { input.parentNode.removeChild(input); } @@ -496,11 +502,25 @@ }; const onFocus = () => { // 'change' fires after 'focus' returns to the window, so - // wait briefly before deciding the user cancelled. - setTimeout(() => finish(), 500); + // wait briefly before deciding the user cancelled. If a + // change event actually started handling files before the + // timer expires, changeStarted gates finish() so we don't + // resolve in the middle of hashing a large batch with a + // partial files array. + focusTimer = setTimeout(() => { + focusTimer = null; + if (!changeStarted) { + finish(); + } + }, 500); }; input.onchange = async () => { + changeStarted = true; + if (focusTimer !== null) { + clearTimeout(focusTimer); + focusTimer = null; + } try { const inputFiles = Array.from(input.files || []).filter( (file) => !hasHiddenFolder(file.webkitRelativePath) && !file.name.startsWith('.')