feat: export kb to zip

This commit is contained in:
Timothy Jaeryang Baek
2026-01-08 12:49:45 +04:00
parent 9b06fdc8fe
commit c1147578c0
5 changed files with 139 additions and 2 deletions
+52 -1
View File
@@ -1,8 +1,11 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
import logging import logging
import io
import zipfile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from open_webui.internal.db import get_session from open_webui.internal.db import get_session
@@ -25,7 +28,7 @@ from open_webui.routers.retrieval import (
from open_webui.storage.provider import Storage from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
@@ -837,3 +840,51 @@ async def add_files_to_knowledge_batch(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
) )
############################
# ExportKnowledgeById
############################
@router.get("/{id}/export")
async def export_knowledge_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
"""
Export a knowledge base as a zip file containing .txt files.
Admin only.
"""
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
files = Knowledges.get_files_by_id(id, db=db)
# Create zip file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file in files:
content = file.data.get("content", "") if file.data else ""
if content:
# Use original filename with .txt extension
filename = file.filename
if not filename.endswith(".txt"):
filename = f"{filename}.txt"
zf.writestr(filename, content)
zip_buffer.seek(0)
# Sanitize knowledge name for filename
safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in knowledge.name)
zip_filename = f"{safe_name}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)
+26
View File
@@ -485,3 +485,29 @@ export const reindexKnowledgeFiles = async (token: string) => {
return res; return res;
}; };
export const exportKnowledgeById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/export`, {
method: 'GET',
headers: {
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.blob();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
@@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
+29 -1
View File
@@ -8,7 +8,11 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { WEBUI_NAME, knowledge, user } from '$lib/stores'; import { WEBUI_NAME, knowledge, user } from '$lib/stores';
import { deleteKnowledgeById, searchKnowledgeBases } from '$lib/apis/knowledge'; import {
deleteKnowledgeById,
searchKnowledgeBases,
exportKnowledgeById
} from '$lib/apis/knowledge';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { capitalizeFirstLetter } from '$lib/utils'; import { capitalizeFirstLetter } from '$lib/utils';
@@ -104,6 +108,25 @@
} }
}; };
const exportHandler = async (item) => {
try {
const blob = await exportKnowledgeById(localStorage.token, item.id);
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${item.name}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success($i18n.t('Knowledge exported successfully'));
}
} catch (e) {
toast.error(`${e}`);
}
};
onMount(async () => { onMount(async () => {
viewOption = localStorage?.workspaceViewOption || ''; viewOption = localStorage?.workspaceViewOption || '';
loaded = true; loaded = true;
@@ -239,6 +262,11 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class=" flex self-center"> <div class=" flex self-center">
<ItemMenu <ItemMenu
onExport={$user.role === 'admin'
? () => {
exportHandler(item);
}
: null}
on:delete={() => { on:delete={() => {
selectedItem = item; selectedItem = item;
showDeleteConfirm = true; showDeleteConfirm = true;
@@ -18,6 +18,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let onExport: null | Function = null;
export let onClose: Function = () => {}; export let onClose: Function = () => {};
let show = false; let show = false;
@@ -54,6 +55,18 @@
align="end" align="end"
transition={flyAndScale} transition={flyAndScale}
> >
{#if onExport}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
onExport();
}}
>
<Download />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {