feat: export kb to zip
This commit is contained in:
@@ -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}"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user