Compare commits

...

16 commits

Author SHA1 Message Date
Juan Jose Soliz Priore
f9221951a6
Merge fa8580f594 into 3b3e12b43a 2025-12-11 08:04:21 +01:00
Timothy Jaeryang Baek
3b3e12b43a refac
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-11 01:09:14 -05:00
Timothy Jaeryang Baek
4d9a51ba33 refac 2025-12-11 00:11:12 -05:00
Timothy Jaeryang Baek
4b4241273d refac: styling 2025-12-11 00:07:32 -05:00
Timothy Jaeryang Baek
db95e96688 chore: dep 2025-12-10 23:59:52 -05:00
Shirasawa
99c820d607
fix: fixed the issue of mismatched spaces in audio MIME types (#17771) 2025-12-10 23:59:10 -05:00
Timothy Jaeryang Baek
282c541427 refac 2025-12-10 23:56:20 -05:00
Timothy Jaeryang Baek
b364cf43d3 feat: resizable sidebar
Co-Authored-By: ALiNew <42788336+sukjinkim@users.noreply.github.com>
2025-12-10 23:54:36 -05:00
Timothy Jaeryang Baek
b9676cf36f refac: styling 2025-12-10 23:35:46 -05:00
G30
258caaeced
fix: resolve layout shift in knowledge items with long names (#19832)
Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-10 23:34:36 -05:00
Timothy Jaeryang Baek
6e99b10163 refac 2025-12-10 23:31:11 -05:00
Timothy Jaeryang Baek
a2a9a9bcf4 refac 2025-12-10 23:28:40 -05:00
Timothy Jaeryang Baek
0addc1ea46 refac 2025-12-10 23:28:33 -05:00
Timothy Jaeryang Baek
6812d3b9d1 refac 2025-12-10 23:20:38 -05:00
Timothy Jaeryang Baek
ceae3d48e6 enh/refac: kb pagination 2025-12-10 23:19:19 -05:00
Haervwe
fa8580f594 fix: preserve parameters through tool calling loops
- Fixed convert_payload_openai_to_ollama to preserve all fields by copying entire payload
- Fixed options dict mutation by creating proper copy instead of reference
- Ensures parameters like 'think' persist through native tool call loops
- Resolves issue where filter-set parameters were reset after tool calls
2025-12-10 11:40:21 -03:00
44 changed files with 1579 additions and 818 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"])
@ -132,7 +135,7 @@ class KnowledgeResponse(KnowledgeModel):
class KnowledgeUserResponse(KnowledgeUserModel): class KnowledgeUserResponse(KnowledgeUserModel):
files: Optional[list[FileMetadataResponse | dict]] = None pass
class KnowledgeForm(BaseModel): class KnowledgeForm(BaseModel):
@ -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

@ -33,6 +33,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.config import ( from open_webui.config import (
@ -1155,17 +1156,9 @@ def transcription(
stt_supported_content_types = getattr( stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
) ) or ["audio/*", "video/webm"]
if not any( if not strict_match_mime_type(stt_supported_content_types, file.content_type):
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,

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
@ -48,7 +47,7 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel from pydantic import BaseModel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -109,17 +108,9 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
if file.content_type: if file.content_type:
stt_supported_content_types = getattr( stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
) ) or ["audio/*", "video/webm"]
if any( if strict_match_mime_type(stt_supported_content_types, file.content_type):
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
file_path = Storage.get_file(file_path) file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata, user) result = transcribe(request, file_path, file_metadata, user)

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

@ -9,6 +9,7 @@ from pathlib import Path
from typing import Callable, Optional, Sequence, Union from typing import Callable, Optional, Sequence, Union
import json import json
import aiohttp import aiohttp
import mimeparse
import collections.abc import collections.abc
@ -577,6 +578,37 @@ def throttle(interval: float = 10.0):
return decorator return decorator
def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[str]:
"""
Strictly match the mime type with the supported mime types.
:param supported: The supported mime types.
:param header: The header to match.
:return: The matched mime type or None if no match is found.
"""
try:
if isinstance(supported, str):
supported = supported.split(",")
supported = [s for s in supported if s.strip() and "/" in s]
match = mimeparse.best_match(supported, header)
if not match:
return None
_, _, match_params = mimeparse.parse_mime_type(match)
_, _, header_params = mimeparse.parse_mime_type(header)
for k, v in match_params.items():
if header_params.get(k) != v:
return None
return match
except Exception as e:
log.exception(f"Failed to match mime type {header}: {e}")
return None
def extract_urls(text: str) -> list[str]: def extract_urls(text: str) -> list[str]:
# Regex pattern to match URLs # Regex pattern to match URLs
url_pattern = re.compile( url_pattern = re.compile(

View file

@ -286,7 +286,9 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns: Returns:
dict: A modified payload compatible with the Ollama API. dict: A modified payload compatible with the Ollama API.
""" """
ollama_payload = {} # Start by copying all fields from the original payload to preserve unknown/Ollama-specific fields
# This is important for preserving parameters across tool calling loops
ollama_payload = {**openai_payload}
# Mapping basic model and message details # Mapping basic model and message details
ollama_payload["model"] = openai_payload.get("model") ollama_payload["model"] = openai_payload.get("model")
@ -299,12 +301,13 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
if "max_tokens" in openai_payload: if "max_tokens" in openai_payload:
ollama_payload["num_predict"] = openai_payload["max_tokens"] ollama_payload["num_predict"] = openai_payload["max_tokens"]
del openai_payload["max_tokens"] del ollama_payload["max_tokens"]
# If there are advanced parameters in the payload, format them in Ollama's options field # If there are advanced parameters in the payload, format them in Ollama's options field
if openai_payload.get("options"): if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"] # Create a copy of options to avoid mutating the original
ollama_options = openai_payload["options"] ollama_options = dict(openai_payload["options"])
ollama_payload["options"] = ollama_options
def parse_json(value: str) -> dict: def parse_json(value: str) -> dict:
""" """

View file

@ -20,6 +20,7 @@ aiofiles
starlette-compress==1.6.1 starlette-compress==1.6.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1 starsessions[redis]==2.2.1
python-mimeparse==2.0.0
sqlalchemy==2.0.44 sqlalchemy==2.0.44
alembic==1.17.2 alembic==1.17.2

View file

@ -28,6 +28,7 @@ dependencies = [
"starlette-compress==1.6.1", "starlette-compress==1.6.1",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1", "httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1", "starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
"sqlalchemy==2.0.44", "sqlalchemy==2.0.44",
"alembic==1.17.2", "alembic==1.17.2",

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

@ -290,7 +290,7 @@
<div <div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} w-full max-w-full flex flex-col" : ''} w-full max-w-full flex flex-col"
id="channel-container" id="channel-container"
> >

View file

@ -2384,7 +2384,7 @@
<div <div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? ' md:max-w-[calc(100%-260px)]' ? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '} w-full max-w-full flex flex-col" : ' '} w-full max-w-full flex flex-col"
id="chat-container" id="chat-container"
> >

View file

@ -1,10 +1,37 @@
<script> <script lang="ts">
import { embed, showControls, showEmbeds } from '$lib/stores'; import { embed, showControls, showEmbeds } from '$lib/stores';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte'; import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
export let overlay = false; export let overlay = false;
const getSrcUrl = (url: string, chatId?: string, messageId?: string) => {
try {
const parsed = new URL(url);
if (chatId) {
parsed.searchParams.set('chat_id', chatId);
}
if (messageId) {
parsed.searchParams.set('message_id', messageId);
}
return parsed.toString();
} catch {
// Fallback for relative URLs or invalid input
const hasQuery = url.includes('?');
const parts = [];
if (chatId) parts.push(`chat_id=${encodeURIComponent(chatId)}`);
if (messageId) parts.push(`message_id=${encodeURIComponent(messageId)}`);
if (parts.length === 0) return url;
return url + (hasQuery ? '&' : '?') + parts.join('&');
}
};
</script> </script>
{#if $embed} {#if $embed}
@ -40,7 +67,11 @@
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div> <div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if} {/if}
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" /> <FullHeightIframe
src={getSrcUrl($embed?.url ?? '', $embed?.chatId, $embed?.messageId)}
payload={$embed?.source ?? null}
iframeClassName="w-full h-full"
/>
</div> </div>
</div> </div>
{/if} {/if}

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

@ -1,19 +1,21 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
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);
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import { folders } from '$lib/stores';
import { getFolders } from '$lib/apis/folders';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl, decodeString } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Database from '$lib/components/icons/Database.svelte'; import Database from '$lib/components/icons/Database.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Youtube from '$lib/components/icons/Youtube.svelte'; import Youtube from '$lib/components/icons/Youtube.svelte';
import { folders } from '$lib/stores';
import Folder from '$lib/components/icons/Folder.svelte'; import Folder from '$lib/components/icons/Folder.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -21,22 +23,11 @@
export let query = ''; export let query = '';
export let onSelect = (e) => {}; export let onSelect = (e) => {};
export let knowledge = [];
let selectedIdx = 0; let selectedIdx = 0;
let items = []; let items = [];
let fuse = null;
export let filteredItems = []; export let filteredItems = [];
$: if (fuse) { $: filteredItems = [
filteredItems = [
...(query
? fuse.search(query).map((e) => {
return e.item;
})
: items),
...(query.startsWith('http') ...(query.startsWith('http')
? isYoutubeUrl(query) ? isYoutubeUrl(query)
? [{ type: 'youtube', name: query, description: query }] ? [{ type: 'youtube', name: query, description: query }]
@ -47,9 +38,9 @@
description: query description: query
} }
] ]
: []) : []),
...items
]; ];
}
$: if (query) { $: if (query) {
selectedIdx = 0; selectedIdx = 0;
@ -71,59 +62,71 @@
item.click(); item.click();
} }
}; };
const decodeString = (str: string) => {
try { let folderItems = [];
return decodeURIComponent(str); let knowledgeItems = [];
} catch (e) { let fileItems = [];
return str;
$: items = [...folderItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
} }
const getItems = () => {
getFolderItems();
getKnowledgeItems();
getKnowledgeFileItems();
}; };
onMount(async () => { const getFolderItems = async () => {
let collections = knowledge folderItems = $folders
.filter((item) => !item?.meta?.document) .map((folder) => ({
.map((item) => ({
...item,
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) => ({
...folder, ...folder,
type: 'folder', type: 'folder',
description: $i18n.t('Folder'), description: $i18n.t('Folder'),
title: folder.name title: folder.name
})); }))
.filter((folder) => folder.name.toLowerCase().includes(query.toLowerCase()));
};
items = [...folder_items, ...collections, ...collection_files]; const getKnowledgeItems = async () => {
fuse = new Fuse(items, { const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
keys: ['name', 'description'] return null;
}); });
if (res) {
knowledgeItems = res.items.map((item) => {
return {
...item,
type: 'collection'
};
});
}
};
const getKnowledgeFileItems = async () => {
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
return null;
});
if (res) {
fileItems = res.items.map((item) => {
return {
...item,
type: 'file',
name: item.filename,
description: item.collection ? item.collection.name : ''
};
});
}
};
onMount(async () => {
if ($folders === null) {
await folders.set(await getFolders(localStorage.token));
}
await tick(); await tick();
}); });
@ -142,12 +145,20 @@
}); });
</script> </script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Knowledge')}
</div>
{#if filteredItems.length > 0 || query.startsWith('http')} {#if filteredItems.length > 0 || query.startsWith('http')}
{#each filteredItems as item, idx} {#each filteredItems as item, idx}
{#if idx === 0 || item?.type !== items[idx - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'folder'}
{$i18n.t('Folders')}
{:else if item?.type === 'collection'}
{$i18n.t('Collections')}
{:else if item?.type === 'file'}
{$i18n.t('Files')}
{/if}
</div>
{/if}
{#if !['youtube', 'web'].includes(item.type)} {#if !['youtube', 'web'].includes(item.type)}
<button <button
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx === class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===

View file

@ -18,7 +18,7 @@
<div <div
bind:this={overlayElement} bind:this={overlayElement}
class="fixed {$showSidebar class="fixed {$showSidebar
? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]' ? 'left-0 md:left-[var(--sidebar-width)] md:w-[calc(100%-var(--sidebar-width))]'
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none" : 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none"
id="dropzone" id="dropzone"
role="region" role="region"

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,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import CitationModal from './Citations/CitationModal.svelte';
import { embed, showControls, showEmbeds } from '$lib/stores'; import { embed, showControls, showEmbeds } from '$lib/stores';
import CitationModal from './Citations/CitationModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let id = ''; export let id = '';
export let chatId = '';
export let sources = []; export let sources = [];
export let readOnly = false; export let readOnly = false;
@ -35,8 +38,11 @@
showControls.set(true); showControls.set(true);
showEmbeds.set(true); showEmbeds.set(true);
embed.set({ embed.set({
url: embedUrl,
title: citations[sourceIdx]?.source?.name || 'Embedded Content', title: citations[sourceIdx]?.source?.name || 'Embedded Content',
url: embedUrl source: citations[sourceIdx],
chatId: chatId,
messageId: id
}); });
} }
} else { } else {

View file

@ -39,8 +39,6 @@
}; };
</script> </script>
{sourceIds}
{#if sourceIds} {#if sourceIds}
{#if (token?.ids ?? []).length == 1} {#if (token?.ids ?? []).length == 1}
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} /> <Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />

View file

@ -824,6 +824,7 @@
<Citations <Citations
bind:this={citationsElement} bind:this={citationsElement}
id={message?.id} id={message?.id}
{chatId}
sources={message?.sources ?? message?.citations} sources={message?.sources ?? message?.citations}
{readOnly} {readOnly}
/> />

View file

@ -21,6 +21,8 @@
'strict-origin-when-cross-origin'; 'strict-origin-when-cross-origin';
export let allowFullscreen = true; export let allowFullscreen = true;
export let payload = null; // payload to send into the iframe on request
let iframe: HTMLIFrameElement | null = null; let iframe: HTMLIFrameElement | null = null;
let iframeSrc: string | null = null; let iframeSrc: string | null = null;
let iframeDoc: string | null = null; let iframeDoc: string | null = null;
@ -142,13 +144,29 @@ window.Chart = parent.Chart; // Chart previously assigned on parent
} }
} }
// Handle height messages from the iframe (we also verify the sender)
function onMessage(e: MessageEvent) { function onMessage(e: MessageEvent) {
if (!iframe || e.source !== iframe.contentWindow) return; if (!iframe || e.source !== iframe.contentWindow) return;
const data = e.data as { type?: string; height?: number };
const data = e.data || {};
if (data?.type === 'iframe:height' && typeof data.height === 'number') { if (data?.type === 'iframe:height' && typeof data.height === 'number') {
iframe.style.height = Math.max(0, data.height) + 'px'; iframe.style.height = Math.max(0, data.height) + 'px';
} }
// Pong message for testing connectivity
if (data?.type === 'pong') {
console.log('Received pong from iframe:', data);
// Optional: reply back
iframe.contentWindow?.postMessage({ type: 'pong:ack' }, '*');
}
// Send payload data if requested
if (data?.type === 'payload') {
iframe.contentWindow?.postMessage(
{ type: 'payload', requestId: data?.requestId ?? null, payload: payload },
'*'
);
}
} }
// When the iframe loads, try same-origin resize (cross-origin will noop) // When the iframe loads, try same-origin resize (cross-origin will noop)

View file

@ -36,7 +36,7 @@
let filter = {}; let filter = {};
$: filter = { $: filter = {
...(query ? { query } : {}), ...(query ? { query: query } : {}),
...(orderBy ? { order_by: orderBy } : {}), ...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {}) ...(direction ? { direction } : {})
}; };

View file

@ -121,6 +121,7 @@
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query} bind:value={query}
placeholder={$i18n.t('Search Chats')} placeholder={$i18n.t('Search Chats')}
maxlength="500"
/> />
{#if query} {#if query}

View file

@ -25,7 +25,8 @@
isApp, isApp,
models, models,
selectedFolder, selectedFolder,
WEBUI_NAME WEBUI_NAME,
sidebarWidth
} from '$lib/stores'; } from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -371,8 +372,55 @@
selectedChatId = null; selectedChatId = null;
}; };
const MIN_WIDTH = 220;
const MAX_WIDTH = 480;
let isResizing = false;
let startWidth = 0;
let startClientX = 0;
const resizeStartHandler = (e: MouseEvent) => {
if ($mobile) return;
isResizing = true;
startClientX = e.clientX;
startWidth = $sidebarWidth ?? 260;
document.body.style.userSelect = 'none';
};
const resizeEndHandler = () => {
if (!isResizing) return;
isResizing = false;
document.body.style.userSelect = '';
localStorage.setItem('sidebarWidth', String($sidebarWidth));
};
const resizeSidebarHandler = (endClientX) => {
const dx = endClientX - startClientX;
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
sidebarWidth.set(newSidebarWidth);
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
let unsubscribers = []; let unsubscribers = [];
onMount(async () => { onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
if (!Number.isNaN(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
sidebarWidth.set(width);
}
} catch {}
document.documentElement.style.setProperty('--sidebar-width', `${$sidebarWidth}px`);
sidebarWidth.subscribe((w) => {
document.documentElement.style.setProperty('--sidebar-width', `${w}px`);
});
await showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false); await showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
unsubscribers = [ unsubscribers = [
@ -570,6 +618,16 @@
}} }}
/> />
<svelte:window
on:mousemove={(e) => {
if (!isResizing) return;
resizeSidebarHandler(e.clientX);
}}
on:mouseup={() => {
resizeEndHandler();
}}
/>
{#if !$mobile && !$showSidebar} {#if !$mobile && !$showSidebar}
<div <div
class=" pt-[7px] pb-2 px-2 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30" class=" pt-[7px] pb-2 px-2 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30"
@ -775,7 +833,7 @@
data-state={$showSidebar} data-state={$showSidebar}
> >
<div <div
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[var(--sidebar-width)] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
? '' ? ''
: 'invisible'}" : 'invisible'}"
> >
@ -1321,4 +1379,17 @@
</div> </div>
</div> </div>
</div> </div>
{#if !$mobile}
<div
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="sidebar-resizer"
on:mousedown={resizeStartHandler}
role="separator"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</div>
{/if}
{/if} {/if}

View file

@ -209,6 +209,7 @@
class="w-full rounded-r-xl py-1.5 pl-2.5 text-sm bg-transparent dark:text-gray-300 outline-hidden" class="w-full rounded-r-xl py-1.5 pl-2.5 text-sm bg-transparent dark:text-gray-300 outline-hidden"
placeholder={placeholder ? placeholder : $i18n.t('Search')} placeholder={placeholder ? placeholder : $i18n.t('Search')}
autocomplete="off" autocomplete="off"
maxlength="500"
bind:value bind:value
on:input={() => { on:input={() => {
dispatch('input'); dispatch('input');

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={() => {
@ -245,15 +255,15 @@
</div> </div>
</Tooltip> </Tooltip>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
<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,
@ -423,7 +422,7 @@
// Helper function to maintain file paths within zip // Helper function to maintain file paths within zip
const syncDirectoryHandler = async () => { const syncDirectoryHandler = async () => {
if ((knowledge?.files ?? []).length > 0) { if (fileItems.length > 0) {
const res = await resetKnowledgeById(localStorage.token, id).catch((e) => { const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
}); });
@ -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);
}; };
@ -759,10 +757,10 @@
/> />
<div class="shrink-0 mr-2.5"> <div class="shrink-0 mr-2.5">
{#if (knowledge?.files ?? []).length} {#if fileItemsTotal}
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
{$i18n.t('{{count}} files', { {$i18n.t('{{count}} files', {
count: (knowledge?.files ?? []).length count: fileItemsTotal
})} })}
</div> </div>
{/if} {/if}

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

@ -68,13 +68,18 @@
let models = null; let models = null;
let total = null; let total = null;
let searchDebounceTimer;
$: if ( $: if (
page !== undefined && page !== undefined &&
query !== undefined && query !== undefined &&
selectedTag !== undefined && selectedTag !== undefined &&
viewOption !== undefined viewOption !== undefined
) { ) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getModelList(); getModelList();
}, 300);
} }
const getModelList = async () => { const getModelList = async () => {
@ -381,6 +386,7 @@
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query} bind:value={query}
placeholder={$i18n.t('Search Models')} placeholder={$i18n.t('Search Models')}
maxlength="500"
/> />
{#if query} {#if query}
@ -430,6 +436,7 @@
</div> </div>
</div> </div>
{#if models !== null}
{#if (models ?? []).length !== 0} {#if (models ?? []).length !== 0}
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list"> <div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
{#each models as model (model.id)} {#each models as model (model.id)}
@ -487,7 +494,9 @@
<div class="flex flex-row gap-0.5 items-center"> <div class="flex flex-row gap-0.5 items-center">
{#if shiftKey} {#if shiftKey}
<Tooltip <Tooltip
content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')} content={model?.meta?.hidden
? $i18n.t('Show')
: $i18n.t('Hide')}
> >
<button <button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
@ -639,6 +648,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>
{#if $config?.features.enable_community_sharing} {#if $config?.features.enable_community_sharing}

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,195 @@
<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';
import DocumentPage from '$lib/components/icons/DocumentPage.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 if item.type === 'collection'}
<Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-4" />
</Tooltip>
{:else if item.type === 'file'}
<Tooltip content={$i18n.t('File')} placement="top">
<DocumentPage 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');

View file

@ -75,6 +75,8 @@ export const settings: Writable<Settings> = writable({});
export const audioQueue = writable(null); export const audioQueue = writable(null);
export const sidebarWidth = writable(260);
export const showSidebar = writable(false); export const showSidebar = writable(false);
export const showSearch = writable(false); export const showSearch = writable(false);
export const showSettings = writable(false); export const showSettings = writable(false);

View file

@ -383,7 +383,7 @@
{:else} {:else}
<div <div
class="w-full flex-1 h-full flex items-center justify-center {$showSidebar class="w-full flex-1 h-full flex items-center justify-center {$showSidebar
? ' md:max-w-[calc(100%-260px)]' ? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '}" : ' '}"
> >
<Spinner className="size-5" /> <Spinner className="size-5" />

View file

@ -29,7 +29,7 @@
{#if loaded} {#if loaded}
<div <div
class=" flex flex-col h-screen max-h-[100dvh] flex-1 transition-width duration-200 ease-in-out {$showSidebar class=" flex flex-col h-screen max-h-[100dvh] flex-1 transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ' md:max-w-[calc(100%-49px)]'} w-full max-w-full" : ' md:max-w-[calc(100%-49px)]'} w-full max-w-full"
> >
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region"> <nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">

View file

@ -18,7 +18,7 @@
<div <div
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full" : ''} max-w-full"
> >
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region"> <nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -41,7 +41,7 @@
{#if loaded} {#if loaded}
<div <div
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full" : ''} max-w-full"
> >
<nav class=" px-2 pt-1.5 backdrop-blur-xl w-full drag-region"> <nav class=" px-2 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -20,7 +20,7 @@
{#if loaded} {#if loaded}
<div <div
id="note-container" id="note-container"
class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}" class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-var(--sidebar-width))]' : ''}"
> >
<NoteEditor id={$page.params.id} /> <NoteEditor id={$page.params.id} />
</div> </div>

View file

@ -18,7 +18,7 @@
<div <div
class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class=" flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full" : ''} max-w-full"
> >
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region"> <nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -52,7 +52,7 @@
{#if loaded} {#if loaded}
<div <div
class=" relative flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class=" relative flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full" : ''} max-w-full"
> >
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region"> <nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">