mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
feat/enh: kb file pagination
This commit is contained in:
parent
7b0b16ebbd
commit
94a8439105
9 changed files with 602 additions and 319 deletions
|
|
@ -238,6 +238,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.hash = hash
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return FileModel.model_validate(file)
|
||||
|
|
@ -249,6 +250,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.data = {**(file.data if file.data else {}), **data}
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception as e:
|
||||
|
|
@ -260,6 +262,7 @@ class FilesTable:
|
|||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
file.meta = {**(file.meta if file.meta else {}), **meta}
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ import uuid
|
|||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.models.files import File, FileModel, FileMetadataResponse
|
||||
from open_webui.models.files import (
|
||||
File,
|
||||
FileModel,
|
||||
FileMetadataResponse,
|
||||
FileModelResponse,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
|
@ -21,6 +26,7 @@ from sqlalchemy import (
|
|||
Text,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
or_,
|
||||
)
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
|
@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel):
|
|||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class FileUserResponse(FileModelResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class KnowledgeFileListResponse(BaseModel):
|
||||
items: list[FileUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class KnowledgeTable:
|
||||
def insert_new_knowledge(
|
||||
self, user_id: str, form_data: KnowledgeForm
|
||||
|
|
@ -232,6 +247,88 @@ class KnowledgeTable:
|
|||
except Exception:
|
||||
return []
|
||||
|
||||
def search_files_by_id(
|
||||
self,
|
||||
knowledge_id: str,
|
||||
user_id: str,
|
||||
filter: dict,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
) -> KnowledgeFileListResponse:
|
||||
try:
|
||||
with get_db() as db:
|
||||
query = (
|
||||
db.query(File, User)
|
||||
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||
)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(KnowledgeFile.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(KnowledgeFile.user_id != user_id)
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.filename.asc())
|
||||
else:
|
||||
query = query.order_by(File.filename.desc())
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.created_at.desc())
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
files = []
|
||||
for file, user in items:
|
||||
files.append(
|
||||
FileUserResponse(
|
||||
**FileModel.model_validate(file).model_dump(),
|
||||
user=(
|
||||
UserResponse(
|
||||
**UserModel.model_validate(user).model_dump()
|
||||
)
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return KnowledgeFileListResponse(items=files, total=total)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return KnowledgeFileListResponse(items=[], total=0)
|
||||
|
||||
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
||||
try:
|
||||
with get_db() as db:
|
||||
|
|
|
|||
|
|
@ -302,9 +302,6 @@ class NoteTable:
|
|||
else:
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
for key, value in filter.items():
|
||||
query = query.filter(getattr(Note, key).ilike(f"%{value}%"))
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
|
|||
|
||||
|
||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups, GroupMember
|
||||
from open_webui.models.channels import ChannelMember
|
||||
|
||||
|
||||
from open_webui.utils.misc import throttle
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||
import logging
|
||||
|
||||
from open_webui.models.knowledge import (
|
||||
KnowledgeFileListResponse,
|
||||
Knowledges,
|
||||
KnowledgeForm,
|
||||
KnowledgeResponse,
|
||||
|
|
@ -264,6 +265,59 @@ async def update_knowledge_by_id(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetKnowledgeFilesById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
|
||||
async def get_knowledge_files_by_id(
|
||||
id: str,
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or knowledge.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge.access_control)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
page = max(page, 1)
|
||||
|
||||
limit = 30
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
return Knowledges.search_files_by_id(
|
||||
id, user.id, filter=filter, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# AddFileToKnowledge
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const searchKnowledgeFilesById = async (
|
||||
token: string,
|
||||
id: string,
|
||||
query?: string | null = null,
|
||||
viewOption?: string | null = null,
|
||||
orderBy?: string | null = null,
|
||||
direction?: string | null = null,
|
||||
page: number = 1
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (query) searchParams.append('query', query);
|
||||
if (viewOption) searchParams.append('view_option', viewOption);
|
||||
if (orderBy) searchParams.append('order_by', orderBy);
|
||||
if (direction) searchParams.append('direction', direction);
|
||||
searchParams.append('page', page.toString());
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/knowledge/${id}/files?${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();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
type KnowledgeUpdateForm = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
removeFileFromKnowledgeById,
|
||||
resetKnowledgeById,
|
||||
updateFileFromKnowledgeById,
|
||||
updateKnowledgeById
|
||||
updateKnowledgeById,
|
||||
searchKnowledgeFilesById
|
||||
} from '$lib/apis/knowledge';
|
||||
import { blobToFile } from '$lib/utils';
|
||||
|
||||
|
|
@ -43,22 +44,25 @@
|
|||
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
||||
|
||||
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
||||
import Drawer from '$lib/components/common/Drawer.svelte';
|
||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
||||
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
|
||||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
|
||||
let largeScreen = true;
|
||||
|
||||
let pane;
|
||||
let showSidepanel = true;
|
||||
let minSize = 0;
|
||||
|
||||
let showAddTextContentModal = false;
|
||||
let showSyncConfirmModal = false;
|
||||
let showAccessControlModal = false;
|
||||
|
||||
let minSize = 0;
|
||||
type Knowledge = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -71,52 +75,88 @@
|
|||
|
||||
let id = null;
|
||||
let knowledge: Knowledge | null = null;
|
||||
let query = '';
|
||||
|
||||
let showAddTextContentModal = false;
|
||||
let showSyncConfirmModal = false;
|
||||
let showAccessControlModal = false;
|
||||
let selectedFileId = null;
|
||||
let selectedFile = null;
|
||||
let selectedFileContent = '';
|
||||
|
||||
let inputFiles = null;
|
||||
|
||||
let filteredItems = [];
|
||||
$: if (knowledge && knowledge.files) {
|
||||
fuse = new Fuse(knowledge.files, {
|
||||
keys: ['meta.name', 'meta.description']
|
||||
});
|
||||
let query = '';
|
||||
let viewOption = null;
|
||||
let sortKey = null;
|
||||
let direction = null;
|
||||
|
||||
let currentPage = 1;
|
||||
let fileItems = null;
|
||||
let fileItemsTotal = null;
|
||||
|
||||
const reset = () => {
|
||||
currentPage = 1;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
reset();
|
||||
await getItemsPage();
|
||||
};
|
||||
|
||||
$: if (
|
||||
knowledge !== null &&
|
||||
query !== undefined &&
|
||||
viewOption !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
direction !== undefined &&
|
||||
currentPage !== undefined
|
||||
) {
|
||||
getItemsPage();
|
||||
}
|
||||
|
||||
$: if (fuse) {
|
||||
filteredItems = query
|
||||
? fuse.search(query).map((e) => {
|
||||
return e.item;
|
||||
})
|
||||
: (knowledge?.files ?? []);
|
||||
$: if (
|
||||
query !== undefined &&
|
||||
viewOption !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
direction !== undefined
|
||||
) {
|
||||
reset();
|
||||
}
|
||||
|
||||
let selectedFile = null;
|
||||
let selectedFileId = null;
|
||||
let selectedFileContent = '';
|
||||
const getItemsPage = async () => {
|
||||
if (knowledge === null) return;
|
||||
|
||||
// Add cache object
|
||||
let fileContentCache = new Map();
|
||||
fileItems = null;
|
||||
fileItemsTotal = null;
|
||||
|
||||
$: if (selectedFileId) {
|
||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
||||
if (file) {
|
||||
fileSelectHandler(file);
|
||||
} else {
|
||||
selectedFile = null;
|
||||
if (sortKey === null) {
|
||||
direction = null;
|
||||
}
|
||||
} else {
|
||||
selectedFile = null;
|
||||
}
|
||||
|
||||
let fuse = null;
|
||||
let debounceTimeout = null;
|
||||
let mediaQuery;
|
||||
let dragged = false;
|
||||
let isSaving = false;
|
||||
const res = await searchKnowledgeFilesById(
|
||||
localStorage.token,
|
||||
knowledge.id,
|
||||
query,
|
||||
viewOption,
|
||||
sortKey,
|
||||
direction,
|
||||
currentPage
|
||||
).catch(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
fileItems = res.items;
|
||||
fileItemsTotal = res.total;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const fileSelectHandler = async (file) => {
|
||||
try {
|
||||
selectedFile = file;
|
||||
selectedFileContent = selectedFile?.data?.content || '';
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Failed to load file content.'));
|
||||
}
|
||||
};
|
||||
|
||||
const createFileFromText = (name, content) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
|
|
@ -163,8 +203,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
knowledge.files = [...(knowledge.files ?? []), fileItem];
|
||||
|
||||
fileItems = [...(fileItems ?? []), fileItem];
|
||||
try {
|
||||
// If the file is an audio file, provide the language for STT.
|
||||
let metadata = null;
|
||||
|
|
@ -184,7 +223,7 @@
|
|||
|
||||
if (uploadedFile) {
|
||||
console.log(uploadedFile);
|
||||
knowledge.files = knowledge.files.map((item) => {
|
||||
fileItems = fileItems.map((item) => {
|
||||
if (item.itemId === tempItemId) {
|
||||
item.id = uploadedFile.id;
|
||||
}
|
||||
|
|
@ -197,7 +236,7 @@
|
|||
if (uploadedFile.error) {
|
||||
console.warn('File upload warning:', uploadedFile.error);
|
||||
toast.warning(uploadedFile.error);
|
||||
knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id);
|
||||
fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
|
||||
} else {
|
||||
await addFileHandler(uploadedFile.id);
|
||||
}
|
||||
|
|
@ -413,7 +452,7 @@
|
|||
toast.success($i18n.t('File added successfully.'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to add file.'));
|
||||
knowledge.files = knowledge.files.filter((file) => file.id !== fileId);
|
||||
fileItems = fileItems.filter((file) => file.id !== fileId);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -436,32 +475,38 @@
|
|||
}
|
||||
};
|
||||
|
||||
let debounceTimeout = null;
|
||||
let mediaQuery;
|
||||
|
||||
let dragged = false;
|
||||
let isSaving = false;
|
||||
|
||||
const updateFileContentHandler = async () => {
|
||||
if (isSaving) {
|
||||
console.log('Save operation already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const fileId = selectedFile.id;
|
||||
const content = selectedFileContent;
|
||||
// Clear the cache for this file since we're updating it
|
||||
fileContentCache.delete(fileId);
|
||||
const res = await updateFileDataContentById(localStorage.token, fileId, content).catch(
|
||||
(e) => {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
);
|
||||
const updatedKnowledge = await updateFileFromKnowledgeById(
|
||||
const res = await updateFileDataContentById(
|
||||
localStorage.token,
|
||||
id,
|
||||
fileId
|
||||
selectedFile.id,
|
||||
selectedFileContent
|
||||
).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
return null;
|
||||
});
|
||||
if (res && updatedKnowledge) {
|
||||
knowledge = updatedKnowledge;
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('File content updated successfully.'));
|
||||
|
||||
selectedFileId = null;
|
||||
selectedFile = null;
|
||||
selectedFileContent = '';
|
||||
|
||||
await init();
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
|
|
@ -504,29 +549,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
const fileSelectHandler = async (file) => {
|
||||
try {
|
||||
selectedFile = file;
|
||||
|
||||
// Check cache first
|
||||
if (fileContentCache.has(file.id)) {
|
||||
selectedFileContent = fileContentCache.get(file.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getFileById(localStorage.token, file.id);
|
||||
if (response) {
|
||||
selectedFileContent = response.data.content;
|
||||
// Cache the content
|
||||
fileContentCache.set(file.id, response.data.content);
|
||||
} else {
|
||||
toast.error($i18n.t('No content found in file.'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error($i18n.t('Failed to load file content.'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -705,32 +727,42 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col w-full h-full translate-y-1" id="collection-container">
|
||||
<div class="flex flex-col w-full h-full min-h-full" id="collection-container">
|
||||
{#if id && knowledge}
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl={knowledge.access_control}
|
||||
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
||||
sharePu={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||
onChange={() => {
|
||||
changeDebounceHandler();
|
||||
}}
|
||||
accessRoles={['read', 'write']}
|
||||
/>
|
||||
<div class="w-full mb-2.5">
|
||||
<div class="w-full px-2">
|
||||
<div class=" flex w-full">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between w-full px-0.5 mb-1">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<input
|
||||
type="text"
|
||||
class="text-left w-full font-medium text-2xl font-primary bg-transparent outline-hidden"
|
||||
class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1"
|
||||
bind:value={knowledge.name}
|
||||
placeholder={$i18n.t('Knowledge Name')}
|
||||
on:input={() => {
|
||||
changeDebounceHandler();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="shrink-0 mr-2.5">
|
||||
{#if (knowledge?.files ?? []).length}
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('{{count}} files', {
|
||||
count: (knowledge?.files ?? []).length
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="self-center shrink-0">
|
||||
|
|
@ -750,7 +782,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full px-1">
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
type="text"
|
||||
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
||||
|
|
@ -765,204 +797,205 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3">
|
||||
{#if largeScreen}
|
||||
<div class="flex-1 flex justify-start w-full h-full max-h-full">
|
||||
{#if selectedFile}
|
||||
<div class=" flex flex-col w-full">
|
||||
<div class="shrink-0 mb-2 flex items-center">
|
||||
{#if !showSidepanel}
|
||||
<div class="-translate-x-2">
|
||||
<button
|
||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||
on:click={() => {
|
||||
pane.expand();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
|
||||
>
|
||||
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={`${$i18n.t('Search Collection')}`}
|
||||
on:focus={() => {
|
||||
selectedFileId = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" flex-1 text-xl font-medium">
|
||||
<a
|
||||
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1"
|
||||
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
|
||||
target="_blank"
|
||||
>
|
||||
{decodeString(selectedFile?.meta?.name)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSaving}
|
||||
on:click={() => {
|
||||
updateFileContentHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if isSaving}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden"
|
||||
>
|
||||
{#key selectedFile.id}
|
||||
<textarea
|
||||
class="w-full h-full outline-none resize-none"
|
||||
bind:value={selectedFileContent}
|
||||
placeholder={$i18n.t('Add content here')}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex w-full">
|
||||
<div class="m-auto text-xs text-center text-gray-200 dark:text-gray-700">
|
||||
{$i18n.t('Drag and drop a file to upload or select a file to view')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<AddContentMenu
|
||||
on:upload={(e) => {
|
||||
if (e.detail.type === 'directory') {
|
||||
uploadDirectoryHandler();
|
||||
} else if (e.detail.type === 'text') {
|
||||
showAddTextContentModal = true;
|
||||
} else {
|
||||
document.getElementById('files-input').click();
|
||||
}
|
||||
}}
|
||||
on:sync={(e) => {
|
||||
showSyncConfirmModal = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !largeScreen && selectedFileId !== null}
|
||||
<Drawer
|
||||
className="h-full"
|
||||
show={selectedFileId !== null}
|
||||
onClose={() => {
|
||||
selectedFileId = null;
|
||||
</div>
|
||||
|
||||
<div class="px-3 flex justify-between">
|
||||
<div
|
||||
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col justify-start h-full max-h-full p-2">
|
||||
<div class=" flex flex-col w-full h-full max-h-full">
|
||||
<div class="shrink-0 mt-1 mb-2 flex items-center">
|
||||
<div class="mr-2">
|
||||
<button
|
||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||
on:click={() => {
|
||||
selectedFileId = null;
|
||||
}}
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class=" flex-1 text-xl line-clamp-1">
|
||||
{selectedFile?.meta?.name}
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
|
||||
bind:value={viewOption}
|
||||
items={[
|
||||
{ value: null, label: $i18n.t('All') },
|
||||
{ value: 'created', label: $i18n.t('Created by you') },
|
||||
{ value: 'shared', label: $i18n.t('Shared with you') }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
localStorage.workspaceViewOption = value;
|
||||
} else {
|
||||
delete localStorage.workspaceViewOption;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSaving}
|
||||
on:click={() => {
|
||||
updateFileContentHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if isSaving}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={sortKey}
|
||||
placeholder={$i18n.t('Sort')}
|
||||
items={[
|
||||
{ value: 'name', label: $i18n.t('Name') },
|
||||
{ value: 'created_at', label: $i18n.t('Created At') },
|
||||
{ value: 'updated_at', label: $i18n.t('Updated At') }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden"
|
||||
>
|
||||
{#key selectedFile.id}
|
||||
<textarea
|
||||
class="w-full h-full outline-none resize-none"
|
||||
bind:value={selectedFileContent}
|
||||
placeholder={$i18n.t('Add content here')}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="{largeScreen ? 'shrink-0 w-72 max-w-72' : 'flex-1'}
|
||||
flex
|
||||
py-2
|
||||
rounded-2xl
|
||||
border
|
||||
border-gray-50
|
||||
h-full
|
||||
dark:border-gray-850"
|
||||
>
|
||||
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class=" px-3">
|
||||
<div class="flex mb-0.5">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search />
|
||||
</div>
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
|
||||
on:focus={() => {
|
||||
selectedFileId = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<AddContentMenu
|
||||
on:upload={(e) => {
|
||||
if (e.detail.type === 'directory') {
|
||||
uploadDirectoryHandler();
|
||||
} else if (e.detail.type === 'text') {
|
||||
showAddTextContentModal = true;
|
||||
} else {
|
||||
document.getElementById('files-input').click();
|
||||
}
|
||||
}}
|
||||
on:sync={(e) => {
|
||||
showSyncConfirmModal = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredItems.length > 0}
|
||||
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
||||
<Files
|
||||
small
|
||||
files={filteredItems}
|
||||
{selectedFileId}
|
||||
on:click={(e) => {
|
||||
selectedFileId = selectedFileId === e.detail ? null : e.detail;
|
||||
}}
|
||||
on:delete={(e) => {
|
||||
console.log(e.detail);
|
||||
|
||||
selectedFileId = null;
|
||||
deleteFileHandler(e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
||||
<div>
|
||||
{$i18n.t('No content found')}
|
||||
</div>
|
||||
</div>
|
||||
{#if sortKey}
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={direction}
|
||||
items={[
|
||||
{ value: 'asc', label: $i18n.t('Asc') },
|
||||
{ value: null, label: $i18n.t('Desc') }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if fileItems !== null && fileItemsTotal !== null}
|
||||
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
|
||||
<div class="flex-1 flex">
|
||||
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
||||
<div class="w-full h-full flex flex-col min-h-full">
|
||||
{#if fileItems.length > 0}
|
||||
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
||||
<Files
|
||||
files={fileItems}
|
||||
{selectedFileId}
|
||||
onClick={(fileId) => {
|
||||
selectedFileId = fileId;
|
||||
|
||||
if (fileItems) {
|
||||
const file = fileItems.find((file) => file.id === selectedFileId);
|
||||
if (file) {
|
||||
fileSelectHandler(file);
|
||||
} else {
|
||||
selectedFile = null;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDelete={(fileId) => {
|
||||
selectedFileId = null;
|
||||
selectedFile = null;
|
||||
|
||||
deleteFileHandler(fileId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if fileItemsTotal > 30}
|
||||
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
||||
<div>
|
||||
{$i18n.t('No content found')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedFileId !== null}
|
||||
<Drawer
|
||||
className="h-full"
|
||||
show={selectedFileId !== null}
|
||||
onClose={() => {
|
||||
selectedFileId = null;
|
||||
selectedFile = null;
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col justify-start h-full max-h-full">
|
||||
<div class=" flex flex-col w-full h-full max-h-full">
|
||||
<div class="shrink-0 flex items-center p-2">
|
||||
<div class="mr-2">
|
||||
<button
|
||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||
on:click={() => {
|
||||
selectedFileId = null;
|
||||
selectedFile = null;
|
||||
}}
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class=" flex-1 text-lg line-clamp-1">
|
||||
{selectedFile?.meta?.name}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSaving}
|
||||
on:click={() => {
|
||||
updateFileContentHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if isSaving}
|
||||
<div class="ml-2 self-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#key selectedFile.id}
|
||||
<textarea
|
||||
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
|
||||
bind:value={selectedFileContent}
|
||||
placeholder={$i18n.t('Add content here')}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="my-10">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Spinner className="size-5" />
|
||||
|
|
|
|||
|
|
@ -50,14 +50,14 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-44 rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
|
||||
sideOffset={4}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
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={() => {
|
||||
dispatch('upload', { type: 'files' });
|
||||
}}
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
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={() => {
|
||||
dispatch('upload', { type: 'directory' });
|
||||
}}
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
className="w-full"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
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={() => {
|
||||
dispatch('sync', { type: 'directory' });
|
||||
}}
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
</Tooltip>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
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={() => {
|
||||
dispatch('upload', { type: 'text' });
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,94 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import dayjs from '$lib/dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import FileItem from '$lib/components/common/FileItem.svelte';
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { capitalizeFirstLetter } from '$lib/utils';
|
||||
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
export let selectedFileId = null;
|
||||
export let files = [];
|
||||
|
||||
export let small = false;
|
||||
export let onClick = (fileId) => {};
|
||||
export let onDelete = (fileId) => {};
|
||||
</script>
|
||||
|
||||
<div class=" max-h-full flex flex-col w-full">
|
||||
{#each files as file}
|
||||
<div class="mt-1 px-2">
|
||||
<FileItem
|
||||
className="w-full"
|
||||
colorClassName="{selectedFileId === file.id
|
||||
? ' bg-gray-50 dark:bg-gray-850'
|
||||
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
{small}
|
||||
item={file}
|
||||
name={file?.name ?? file?.meta?.name}
|
||||
type="file"
|
||||
size={file?.size ?? file?.meta?.size ?? ''}
|
||||
loading={file.status === 'uploading'}
|
||||
dismissible
|
||||
on:click={() => {
|
||||
if (file.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('click', file.id);
|
||||
<div class=" max-h-full flex flex-col w-full gap-[0.5px]">
|
||||
{#each files as file (file?.id ?? file?.tempId)}
|
||||
<div
|
||||
class=" flex cursor-pointer w-full px-1.5 py-0.5 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
|
||||
? ''
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
|
||||
>
|
||||
<button
|
||||
class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
console.log(file);
|
||||
onClick(file?.id ?? file?.tempId);
|
||||
}}
|
||||
on:dismiss={() => {
|
||||
if (file.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
>
|
||||
<div class="">
|
||||
<div class="flex gap-2 items-center line-clamp-1">
|
||||
<div class="shrink-0">
|
||||
{#if file?.status !== 'uploading'}
|
||||
<DocumentPage className="size-3" />
|
||||
{:else}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
dispatch('delete', file.id);
|
||||
}}
|
||||
/>
|
||||
<div class="line-clamp-1">
|
||||
{file?.name ?? file?.meta?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Tooltip content={dayjs(file.updated_at * 1000).format('LLLL')}>
|
||||
<div>
|
||||
{dayjs(file.updated_at * 1000).fromNow()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={file?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<div class="shrink-0 text-gray-500">
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
file?.user?.name ?? file?.user?.email ?? $i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onDelete(file?.id ?? file?.tempId);
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue