enh/refac: kb pagination

This commit is contained in:
Timothy Jaeryang Baek 2025-12-10 23:19:19 -05:00
parent 3ed1df2e53
commit ceae3d48e6
18 changed files with 1086 additions and 526 deletions

View file

@ -104,6 +104,11 @@ class FileUpdateForm(BaseModel):
meta: Optional[dict] = None meta: Optional[dict] = None
class FileListResponse(BaseModel):
items: list[FileModel]
total: int
class FilesTable: class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]: def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db: with get_db() as db:

View file

@ -5,6 +5,7 @@ from typing import Optional
import uuid import uuid
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import ( from open_webui.models.files import (
@ -30,6 +31,8 @@ from sqlalchemy import (
) )
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"]) log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -145,6 +148,11 @@ class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class KnowledgeListResponse(BaseModel):
items: list[KnowledgeUserModel]
total: int
class KnowledgeFileListResponse(BaseModel): class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse] items: list[FileUserResponse]
total: int total: int
@ -177,12 +185,13 @@ class KnowledgeTable:
except Exception: except Exception:
return None return None
def get_knowledge_bases(self) -> list[KnowledgeUserModel]: def get_knowledge_bases(
self, skip: int = 0, limit: int = 30
) -> list[KnowledgeUserModel]:
with get_db() as db: with get_db() as db:
all_knowledge = ( all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
) )
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids) if user_ids else []
@ -201,6 +210,126 @@ class KnowledgeTable:
) )
return knowledge_bases return knowledge_bases
def search_knowledge_bases(
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeListResponse:
try:
with get_db() as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Knowledge.user_id == user_id)
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
query = query.order_by(Knowledge.updated_at.desc())
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
if user
else None
),
}
)
)
return KnowledgeListResponse(items=knowledge_bases, total=total)
except Exception as e:
print(e)
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db() as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
)
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
# Apply filename search
if filter:
q = filter.get("query")
if q:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
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)
rows = query.all()
items = []
for file, user in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=items, total=total)
except Exception as e:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool: def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id) knowledge = self.get_knowledge_by_id(id)
if not knowledge: if not knowledge:

View file

@ -39,7 +39,6 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
from open_webui.routers.retrieval import ProcessFileForm, process_file from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe from open_webui.routers.audio import transcribe

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
import logging import logging
from open_webui.models.groups import Groups
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
KnowledgeFileListResponse, KnowledgeFileListResponse,
Knowledges, Knowledges,
@ -40,53 +41,115 @@ router = APIRouter()
# getKnowledgeBases # getKnowledgeBases
############################ ############################
PAGE_ITEM_COUNT = 30
class KnowledgeAccessResponse(KnowledgeUserResponse): class KnowledgeAccessResponse(KnowledgeUserResponse):
write_access: Optional[bool] = False write_access: Optional[bool] = False
@router.get("/", response_model=list[KnowledgeAccessResponse]) class KnowledgeAccessListResponse(BaseModel):
async def get_knowledge(user=Depends(get_verified_user)): items: list[KnowledgeAccessResponse]
# Return knowledge bases with read access total: int
knowledge_bases = []
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
return [
@router.get("/", response_model=KnowledgeAccessListResponse)
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse( KnowledgeAccessResponse(
**knowledge_base.model_dump(), **knowledge_base.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control) or has_access(user.id, "write", knowledge_base.access_control)
), ),
) )
for knowledge_base in knowledge_bases for knowledge_base in result.items
] ],
total=result.total,
)
@router.get("/list", response_model=list[KnowledgeAccessResponse]) @router.get("/search", response_model=KnowledgeAccessListResponse)
async def get_knowledge_list(user=Depends(get_verified_user)): async def search_knowledge_bases(
# Return knowledge bases with write access query: Optional[str] = None,
knowledge_bases = [] view_option: Optional[str] = None,
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: page: Optional[int] = 1,
knowledge_bases = Knowledges.get_knowledge_bases() user=Depends(get_verified_user),
else: ):
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read") page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
return [ filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse( KnowledgeAccessResponse(
**knowledge_base.model_dump(), **knowledge_base.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control) or has_access(user.id, "write", knowledge_base.access_control)
), ),
) )
for knowledge_base in knowledge_bases for knowledge_base in result.items
] ],
total=result.total,
)
@router.get("/search/files", response_model=KnowledgeFileListResponse)
async def search_knowledge_files(
query: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit)
############################ ############################
@ -198,7 +261,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
class KnowledgeFilesResponse(KnowledgeResponse): class KnowledgeFilesResponse(KnowledgeResponse):
files: list[FileMetadataResponse] files: Optional[list[FileMetadataResponse]] = None
write_access: Optional[bool] = False write_access: Optional[bool] = False
@ -215,7 +278,6 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
write_access=( write_access=(
user.id == knowledge.user_id user.id == knowledge.user_id
or has_access(user.id, "write", knowledge.access_control) or has_access(user.id, "write", knowledge.access_control)

View file

@ -0,0 +1,130 @@
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
def has_permission(db, DocumentModel, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
DocumentModel.access_control["read"]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
DocumentModel.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(DocumentModel.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
DocumentModel.access_control["write"]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
DocumentModel.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(DocumentModel.access_control.isnot(None))
write_exclusions.append(cast(DocumentModel.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
DocumentModel.access_control.is_(None),
cast(DocumentModel.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(DocumentModel.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
DocumentModel.access_control[permission]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
DocumentModel.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query

View file

@ -38,10 +38,13 @@ export const createNewKnowledge = async (
return res; return res;
}; };
export const getKnowledgeBases = async (token: string = '') => { export const getKnowledgeBases = async (token: string = '', page: number | null = null) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, { const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -69,10 +72,20 @@ export const getKnowledgeBases = async (token: string = '') => {
return res; return res;
}; };
export const getKnowledgeBaseList = async (token: string = '') => { export const searchKnowledgeBases = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
page: number | null = null
) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, { const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -100,6 +113,55 @@ export const getKnowledgeBaseList = async (token: string = '') => {
return res; return res;
}; };
export const searchKnowledgeFiles = async (
token: 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/search/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;
};
export const getKnowledgeById = async (token: string, id: string) => { export const getKnowledgeById = async (token: string, id: string) => {
let error = null; let error = null;

View file

@ -28,9 +28,6 @@
await Promise.all([ await Promise.all([
(async () => { (async () => {
prompts.set(await getPrompts(localStorage.token)); prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})() })()
]); ]);
loading = false; loading = false;
@ -103,7 +100,6 @@
bind:this={suggestionElement} bind:this={suggestionElement}
{query} {query}
bind:filteredItems bind:filteredItems
knowledge={$knowledge ?? []}
onSelect={(e) => { onSelect={(e) => {
const { type, data } = e; const { type, data } = e;

View file

@ -15,6 +15,7 @@
import Youtube from '$lib/components/icons/Youtube.svelte'; import Youtube from '$lib/components/icons/Youtube.svelte';
import { folders } from '$lib/stores'; import { folders } from '$lib/stores';
import Folder from '$lib/components/icons/Folder.svelte'; import Folder from '$lib/components/icons/Folder.svelte';
import { getFolders } from '$lib/apis/folders';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -80,6 +81,10 @@
}; };
onMount(async () => { onMount(async () => {
if ($folders === null) {
await folders.set(await getFolders(localStorage.token));
}
let collections = knowledge let collections = knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -87,31 +92,6 @@
type: 'collection' type: 'collection'
})); }));
let collection_files =
knowledge.length > 0
? [
...knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.description}`,
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
type: 'file'
}))
]
: [];
let folder_items = $folders.map((folder) => ({ let folder_items = $folders.map((folder) => ({
...folder, ...folder,
type: 'folder', type: 'folder',
@ -119,7 +99,7 @@
title: folder.name title: folder.name
})); }));
items = [...folder_items, ...collections, ...collection_files]; items = [...folder_items, ...collections];
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });

View file

@ -73,16 +73,6 @@
} }
}; };
const init = async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
}
};
$: if (show) {
init();
}
const onSelect = (item) => { const onSelect = (item) => {
if (files.find((f) => f.id === item.id)) { if (files.find((f) => f.id === item.id)) {
return; return;
@ -249,7 +239,6 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if ($knowledge ?? []).length > 0}
<Tooltip <Tooltip
content={fileUploadCapableModels.length !== selectedModels.length content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload') ? $i18n.t('Model(s) do not support file upload')
@ -279,7 +268,6 @@
</div> </div>
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if ($chats ?? []).length > 0} {#if ($chats ?? []).length > 0}
<Tooltip <Tooltip

View file

@ -4,77 +4,177 @@
import { decodeString } from '$lib/utils'; import { decodeString } from '$lib/utils';
import { knowledge } from '$lib/stores'; import { knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge'; import { getKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte'; import Database from '$lib/components/icons/Database.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let onSelect = (e) => {}; export let onSelect = (e) => {};
let loaded = false; let loaded = false;
let items = [];
let selectedIdx = 0; let selectedIdx = 0;
onMount(async () => { let selectedItem = null;
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token)); let selectedFileItemsPage = 1;
let selectedFileItems = null;
let selectedFileItemsTotal = null;
let selectedFileItemsLoading = false;
let selectedFileAllItemsLoaded = false;
$: if (selectedItem) {
initSelectedFileItems();
} }
let collections = $knowledge const initSelectedFileItems = async () => {
.filter((item) => !item?.meta?.document) selectedFileItemsPage = 1;
.map((item) => ({ selectedFileItems = null;
...item, selectedFileItemsTotal = null;
type: 'collection' selectedFileAllItemsLoaded = false;
})); selectedFileItemsLoading = false;
``;
let collection_files =
$knowledge.length > 0
? [
...$knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
type: 'file'
}))
]
: [];
items = [...collections, ...collection_files];
await tick(); await tick();
await getSelectedFileItemsPage();
};
const loadMoreSelectedFileItems = async () => {
if (selectedFileAllItemsLoaded) return;
selectedFileItemsPage += 1;
await getSelectedFileItemsPage();
};
const getSelectedFileItemsPage = async () => {
if (!selectedItem) return;
selectedFileItemsLoading = true;
const res = await searchKnowledgeFilesById(
localStorage.token,
selectedItem.id,
null,
null,
null,
null,
selectedFileItemsPage
).catch(() => {
return null;
});
if (res) {
selectedFileItemsTotal = res.total;
const pageItems = res.items;
if ((pageItems ?? []).length === 0) {
selectedFileAllItemsLoaded = true;
} else {
selectedFileAllItemsLoaded = false;
}
if (selectedFileItems) {
selectedFileItems = [...selectedFileItems, ...pageItems];
} else {
selectedFileItems = pageItems;
}
}
selectedFileItemsLoading = false;
return res;
};
let page = 1;
let items = null;
let total = null;
let itemsLoading = false;
let allItemsLoaded = false;
$: if (loaded) {
init();
}
const init = async () => {
reset();
await tick();
await getItemsPage();
};
const reset = () => {
page = 1;
items = null;
total = null;
allItemsLoaded = false;
itemsLoading = false;
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
const res = await getKnowledgeBases(localStorage.token, page).catch(() => {
return null;
});
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
if (items) {
items = [...items, ...pageItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
};
onMount(async () => {
await tick();
loaded = true; loaded = true;
}); });
</script> </script>
{#if loaded} {#if loaded && items !== null}
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
{#each items as item, idx} {#if items.length === 0}
<button <div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge bases found.')}
</div>
{:else}
{#each items as item, idx (item.id)}
<div
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx === class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}" : ''}"
>
<button
class="w-full flex-1"
type="button" type="button"
on:click={() => { on:click={() => {
console.log(item); onSelect({
onSelect(item); type: 'collection',
...item
});
}} }}
on:mousemove={() => { on:mousemove={() => {
selectedIdx = idx; selectedIdx = idx;
@ -86,32 +186,114 @@
}} }}
data-selected={idx === selectedIdx} data-selected={idx === selectedIdx}
> >
<div class=" text-black dark:text-gray-100 flex items-center gap-1"> <div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
<Tooltip <Tooltip content={$i18n.t('Collection')} placement="top">
content={item?.legacy
? $i18n.t('Legacy')
: item?.type === 'file'
? $i18n.t('File')
: item?.type === 'collection'
? $i18n.t('Collection')
: ''}
placement="top"
>
{#if item?.type === 'collection'}
<Database className="size-4" /> <Database className="size-4" />
{:else}
<DocumentPage className="size-4" />
{/if}
</Tooltip> </Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start"> <Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1"> <div class="line-clamp-1 flex-1 text-sm">
{decodeString(item?.name)} {decodeString(item?.name)}
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
</button> </button>
<Tooltip content={$i18n.t('Show Files')} placement="top">
<button
type="button"
class=" ml-2 opacity-50 hover:opacity-100 transition"
on:click={() => {
if (selectedItem && selectedItem.id === item.id) {
selectedItem = null;
} else {
selectedItem = item;
}
}}
>
{#if selectedItem && selectedItem.id === item.id}
<ChevronDown className="size-3" />
{:else}
<ChevronRight className="size-3" />
{/if}
</button>
</Tooltip>
</div>
{#if selectedItem && selectedItem.id === item.id}
<div class="pl-3 mb-1 flex flex-col gap-0.5">
{#if selectedFileItems === null && selectedFileItemsTotal === null}
<div class=" py-1 flex justify-center">
<Spinner className="size-3" />
</div>
{:else if selectedFileItemsTotal === 0}
<div class=" text-xs text-gray-500 dark:text-gray-400 italic py-0.5 px-2">
{$i18n.t('No files in this knowledge base.')}
</div>
{:else}
{#each selectedFileItems as file, fileIdx (file.id)}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100"
type="button"
on:click={() => {
console.log(file);
onSelect({
type: 'file',
name: file?.meta?.name,
...file
});
}}
>
<div class=" flex items-center gap-1.5">
<Tooltip content={$i18n.t('Collection')} placement="top">
<DocumentPage className="size-4" />
</Tooltip>
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
<div class="line-clamp-1 flex-1 text-sm">
{decodeString(file?.meta?.name)}
</div>
</Tooltip>
</div>
</button>
{/each} {/each}
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
<Loader
on:visible={async (e) => {
if (!selectedFileItemsLoading) {
await loadMoreSelectedFileItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-3" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div>
{/if}
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div> </div>
{:else} {:else}
<div class="py-4.5"> <div class="py-4.5">

View file

@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import Fuse from 'fuse.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -10,11 +8,7 @@
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 { import { deleteKnowledgeById, searchKnowledgeBases } from '$lib/apis/knowledge';
getKnowledgeBases,
deleteKnowledgeById,
getKnowledgeBaseList
} from '$lib/apis/knowledge';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { capitalizeFirstLetter } from '$lib/utils'; import { capitalizeFirstLetter } from '$lib/utils';
@ -28,75 +22,90 @@
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import ViewSelector from './common/ViewSelector.svelte'; import ViewSelector from './common/ViewSelector.svelte';
import Loader from '../common/Loader.svelte';
let loaded = false; let loaded = false;
let query = '';
let selectedItem = null;
let showDeleteConfirm = false; let showDeleteConfirm = false;
let tagsContainerElement: HTMLDivElement; let tagsContainerElement: HTMLDivElement;
let selectedItem = null;
let page = 1;
let query = '';
let viewOption = ''; let viewOption = '';
let fuse = null; let items = null;
let total = null;
let knowledgeBases = []; let allItemsLoaded = false;
let itemsLoading = false;
let items = []; $: if (loaded && query !== undefined && viewOption !== undefined) {
let filteredItems = []; init();
}
const setFuse = async () => { const reset = () => {
items = knowledgeBases.filter( page = 1;
(item) => items = null;
viewOption === '' || total = null;
(viewOption === 'created' && item.user_id === $user?.id) || allItemsLoaded = false;
(viewOption === 'shared' && item.user_id !== $user?.id) itemsLoading = false;
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const init = async () => {
reset();
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
const res = await searchKnowledgeBases(localStorage.token, query, viewOption, page).catch(
() => {
return [];
}
); );
fuse = new Fuse(items, { if (res) {
keys: [ console.log(res);
'name', total = res.total;
'description', const pageItems = res.items;
'user.name', // Ensures Fuse looks into item.user.name
'user.email' // Ensures Fuse looks into item.user.email
],
threshold: 0.3
});
await tick(); if ((pageItems ?? []).length === 0) {
setFilteredItems(); allItemsLoaded = true;
};
$: if (knowledgeBases.length > 0 && viewOption !== undefined) {
// Added a check for non-empty array, good practice
setFuse();
} else { } else {
fuse = null; // Reset fuse if knowledgeBases is empty allItemsLoaded = false;
} }
const setFilteredItems = () => { if (items) {
filteredItems = query ? fuse.search(query).map((result) => result.item) : items; items = [...items, ...pageItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
}; };
$: if (query !== undefined && fuse) {
setFilteredItems();
}
const deleteHandler = async (item) => { const deleteHandler = async (item) => {
const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => { const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
}); });
if (res) { if (res) {
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
knowledge.set(await getKnowledgeBases(localStorage.token));
toast.success($i18n.t('Knowledge deleted successfully.')); toast.success($i18n.t('Knowledge deleted successfully.'));
init();
} }
}; };
onMount(async () => { onMount(async () => {
viewOption = localStorage?.workspaceViewOption || ''; viewOption = localStorage?.workspaceViewOption || '';
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
loaded = true; loaded = true;
}); });
</script> </script>
@ -123,7 +132,7 @@
</div> </div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500"> <div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredItems.length} {total}
</div> </div>
</div> </div>
@ -192,10 +201,11 @@
</div> </div>
</div> </div>
{#if (filteredItems ?? []).length !== 0} {#if items !== null && total !== null}
{#if (items ?? []).length !== 0}
<!-- The Aleph dreams itself into being, and the void learns its own name --> <!-- The Aleph dreams itself into being, and the void learns its own name -->
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2"> <div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
{#each filteredItems as item} {#each items as item}
<button <button
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl" class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
on:click={() => { on:click={() => {
@ -247,13 +257,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}> <Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
<div class=" text-xs text-gray-500 line-clamp-1"> <div class=" text-xs text-gray-500 line-clamp-1 hidden sm:block">
{$i18n.t('Updated')} {$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()} {dayjs(item.updated_at * 1000).fromNow()}
</div> </div>
</Tooltip> </Tooltip>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500 shrink-0">
<Tooltip <Tooltip
content={item?.user?.email ?? $i18n.t('Deleted User')} content={item?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0" className="flex shrink-0"
@ -273,6 +283,21 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{:else} {:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24"> <div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center"> <div class="max-w-md text-center">
@ -284,6 +309,11 @@
</div> </div>
</div> </div>
{/if} {/if}
{:else}
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
</div> </div>
<div class=" text-gray-500 text-xs m-2"> <div class=" text-gray-500 text-xs m-2">

View file

@ -1,11 +1,13 @@
<script> <script>
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { createNewKnowledge, getKnowledgeBases } from '$lib/apis/knowledge'; import { user } from '$lib/stores';
import { toast } from 'svelte-sonner'; import { createNewKnowledge } from '$lib/apis/knowledge';
import { knowledge, user } from '$lib/stores';
import AccessControl from '../common/AccessControl.svelte'; import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
@ -37,7 +39,6 @@
if (res) { if (res) {
toast.success($i18n.t('Knowledge created successfully.')); toast.success($i18n.t('Knowledge created successfully.'));
knowledge.set(await getKnowledgeBases(localStorage.token));
goto(`/workspace/knowledge/${res.id}`); goto(`/workspace/knowledge/${res.id}`);
} }

View file

@ -27,7 +27,6 @@
import { import {
addFileToKnowledgeById, addFileToKnowledgeById,
getKnowledgeById, getKnowledgeById,
getKnowledgeBases,
removeFileFromKnowledgeById, removeFileFromKnowledgeById,
resetKnowledgeById, resetKnowledgeById,
updateFileFromKnowledgeById, updateFileFromKnowledgeById,
@ -534,7 +533,6 @@
if (res) { if (res) {
toast.success($i18n.t('Knowledge updated successfully')); toast.success($i18n.t('Knowledge updated successfully'));
_knowledge.set(await getKnowledgeBases(localStorage.token));
} }
}, 1000); }, 1000);
}; };

View file

@ -43,13 +43,13 @@
<div class="flex gap-2 items-center line-clamp-1"> <div class="flex gap-2 items-center line-clamp-1">
<div class="shrink-0"> <div class="shrink-0">
{#if file?.status !== 'uploading'} {#if file?.status !== 'uploading'}
<DocumentPage className="size-3" /> <DocumentPage className="size-3.5" />
{:else} {:else}
<Spinner className="size-3" /> <Spinner className="size-3.5" />
{/if} {/if}
</div> </div>
<div class="line-clamp-1"> <div class="line-clamp-1 text-sm">
{file?.name ?? file?.meta?.name} {file?.name ?? file?.meta?.name}
{#if file?.meta?.size} {#if file?.meta?.size}
<span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span> <span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>

View file

@ -2,7 +2,7 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
import { config, knowledge, settings, user } from '$lib/stores'; import { config, knowledge, settings, user } from '$lib/stores';
import Selector from './Knowledge/Selector.svelte'; import KnowledgeSelector from './Knowledge/KnowledgeSelector.svelte';
import FileItem from '$lib/components/common/FileItem.svelte'; import FileItem from '$lib/components/common/FileItem.svelte';
import { getKnowledgeBases } from '$lib/apis/knowledge'; import { getKnowledgeBases } from '$lib/apis/knowledge';
@ -128,9 +128,6 @@
}; };
onMount(async () => { onMount(async () => {
if (!$knowledge) {
knowledge.set(await getKnowledgeBases(localStorage.token));
}
loaded = true; loaded = true;
}); });
</script> </script>
@ -190,8 +187,7 @@
{#if loaded} {#if loaded}
<div class="flex flex-wrap flex-row text-sm gap-1"> <div class="flex flex-wrap flex-row text-sm gap-1">
<Selector <KnowledgeSelector
knowledgeItems={$knowledge || []}
on:select={(e) => { on:select={(e) => {
const item = e.detail; const item = e.detail;
@ -210,7 +206,7 @@
> >
{$i18n.t('Select Knowledge')} {$i18n.t('Select Knowledge')}
</div> </div>
</Selector> </KnowledgeSelector>
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload} {#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
<button <button

View file

@ -0,0 +1,190 @@
<script lang="ts">
import dayjs from 'dayjs';
import { DropdownMenu } from 'bits-ui';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { flyAndScale } from '$lib/utils/transitions';
import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let onClose: Function = () => {};
let show = false;
let query = '';
let noteItems = [];
let knowledgeItems = [];
let fileItems = [];
let items = [];
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
}
const getItems = () => {
getNoteItems();
getKnowledgeItems();
getKnowledgeFileItems();
};
const getNoteItems = async () => {
const res = await searchNotes(localStorage.token, query).catch(() => {
return null;
});
if (res) {
noteItems = res.items.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
}
};
const getKnowledgeItems = async () => {
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
return null;
});
if (res) {
knowledgeItems = res.items.map((note) => {
return {
...note,
type: 'collection'
};
});
}
};
const getKnowledgeFileItems = async () => {
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
return null;
});
if (res) {
fileItems = res.items.map((file) => {
return {
...file,
type: 'file',
name: file.meta?.name || file.filename,
description: file.description || ''
};
});
}
};
onMount(async () => {
getItems();
});
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
query = '';
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class=" text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-70 p-1.5"
sideOffset={8}
side="bottom"
align="start"
transition={flyAndScale}
>
<div class=" flex w-full space-x-2 px-2 pb-0.5">
<div class="flex flex-1">
<div class=" self-center mr-2">
<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')}
/>
</div>
</div>
<div class="max-h-56 overflow-y-scroll gap-0.5 flex flex-col">
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 pt-4 pb-6">
{$i18n.t('No knowledge found')}
</div>
{:else}
{#each items as item, i}
{#if i === 0 || item?.type !== items[i - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'note'}
{$i18n.t('Notes')}
{:else if item?.type === 'collection'}
{$i18n.t('Collections')}
{:else if item?.type === 'file'}
{$i18n.t('Files')}
{/if}
</div>
{/if}
<div
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100 selected-command-option-button"
>
<button
class="w-full flex-1"
type="button"
on:click={() => {
dispatch('select', item);
show = false;
}}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
{#if item.type === 'note'}
<Tooltip content={$i18n.t('Note')} placement="top">
<PageEdit className="size-4" />
</Tooltip>
{:else}
<Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-4" />
</Tooltip>
{/if}
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
>
<div class="line-clamp-1 flex-1 text-sm text-left">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
</div>
{/each}
{/if}
</div>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -1,186 +0,0 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { DropdownMenu } from 'bits-ui';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { knowledge } from '$lib/stores';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import { getNoteList } from '$lib/apis/notes';
import dayjs from 'dayjs';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let onClose: Function = () => {};
export let knowledgeItems = [];
let query = '';
let items = [];
let filteredItems = [];
let fuse = null;
$: if (fuse) {
filteredItems = query
? fuse.search(query).map((e) => {
return e.item;
})
: items;
}
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onMount(async () => {
let notes = await getNoteList(localStorage.token).catch(() => {
return [];
});
notes = notes.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
let collections = knowledgeItems
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
type: 'collection'
}));
let collection_files =
knowledgeItems.length > 0
? [
...knowledgeItems
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
type: 'file'
}))
]
: [];
items = [...notes, ...collections, ...collection_files];
fuse = new Fuse(items, {
keys: ['name', 'description']
});
});
</script>
<Dropdown
on:change={(e) => {
if (e.detail === false) {
onClose();
query = '';
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-96 rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-[99999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="start"
transition={flyAndScale}
>
<div class=" flex w-full space-x-2 py-0.5 px-2 pb-2">
<div class="flex flex-1">
<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 Knowledge')}
/>
</div>
</div>
<div class="max-h-56 overflow-y-scroll">
{#if filteredItems.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
{$i18n.t('No knowledge found')}
</div>
{:else}
{#each filteredItems as item}
<DropdownMenu.Item
class="flex gap-2.5 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('select', item);
}}
>
<div>
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Document
</div>
{:else if item?.type === 'file'}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
File
</div>
{:else if item?.type === 'note'}
<div
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Note
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Collection
</div>
{/if}
<div class="line-clamp-1">
{decodeString(item?.name)}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{item?.description}
</div>
</div>
</DropdownMenu.Item>
{/each}
{/if}
</div>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -2,12 +2,11 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte'; import { onMount, getContext, tick } from 'svelte';
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores'; import { models, tools, functions, user } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
import { getFunctions } from '$lib/apis/functions'; import { getFunctions } from '$lib/apis/functions';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Tags from '$lib/components/common/Tags.svelte'; import Tags from '$lib/components/common/Tags.svelte';
@ -223,7 +222,6 @@
onMount(async () => { onMount(async () => {
await tools.set(await getTools(localStorage.token)); await tools.set(await getTools(localStorage.token));
await functions.set(await getFunctions(localStorage.token)); await functions.set(await getFunctions(localStorage.token));
await knowledgeCollections.set([...(await getKnowledgeBases(localStorage.token))]);
// Scroll to top 'workspace-container' element // Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container'); const workspaceContainer = document.getElementById('workspace-container');