diff --git a/backend/open_webui/apps/retrieval/main.py b/backend/open_webui/apps/retrieval/main.py index 87df03238f..8a9d410e6d 100644 --- a/backend/open_webui/apps/retrieval/main.py +++ b/backend/open_webui/apps/retrieval/main.py @@ -709,8 +709,8 @@ def save_docs_to_vector_db( if overwrite: VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name) log.info(f"deleting existing collection {collection_name}") - - if add is False: + elif add is False: + log.info(f"collection {collection_name} already exists, overwrite is False and add is False") return True log.info(f"adding to collection {collection_name}") diff --git a/backend/open_webui/apps/retrieval/utils.py b/backend/open_webui/apps/retrieval/utils.py index aa09ec5827..153bd804ff 100644 --- a/backend/open_webui/apps/retrieval/utils.py +++ b/backend/open_webui/apps/retrieval/utils.py @@ -385,6 +385,8 @@ def get_rag_context( extracted_collections.extend(collection_names) if context: + if "data" in file: + del file["data"] relevant_contexts.append({**context, "file": file}) contexts = [] @@ -401,11 +403,8 @@ def get_rag_context( ] ) ) - contexts.append( - (", ".join(file_names) + ":\n\n") - if file_names - else "" + ((", ".join(file_names) + ":\n\n") if file_names else "") + "\n\n".join( [text for text in context["documents"][0] if text is not None] ) @@ -423,7 +422,9 @@ def get_rag_context( except Exception as e: log.exception(e) - print(contexts, citations) + print("contexts", contexts) + print("citations", citations) + return contexts, citations diff --git a/backend/open_webui/apps/webui/main.py b/backend/open_webui/apps/webui/main.py index 1d12d708eb..94e42f4a80 100644 --- a/backend/open_webui/apps/webui/main.py +++ b/backend/open_webui/apps/webui/main.py @@ -9,6 +9,7 @@ from open_webui.apps.webui.models.models import Models from open_webui.apps.webui.routers import ( auths, chats, + folders, configs, files, functions, @@ -110,6 +111,7 @@ app.include_router(configs.router, prefix="/configs", tags=["configs"]) app.include_router(auths.router, prefix="/auths", tags=["auths"]) app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(chats.router, prefix="/chats", tags=["chats"]) +app.include_router(folders.router, prefix="/folders", tags=["folders"]) app.include_router(models.router, prefix="/models", tags=["models"]) app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"]) diff --git a/backend/open_webui/apps/webui/models/chats.py b/backend/open_webui/apps/webui/models/chats.py index 509dff9feb..84f8a74d1e 100644 --- a/backend/open_webui/apps/webui/models/chats.py +++ b/backend/open_webui/apps/webui/models/chats.py @@ -33,6 +33,7 @@ class Chat(Base): pinned = Column(Boolean, default=False, nullable=True) meta = Column(JSON, server_default="{}") + folder_id = Column(Text, nullable=True) class ChatModel(BaseModel): @@ -51,6 +52,7 @@ class ChatModel(BaseModel): pinned: Optional[bool] = False meta: dict = {} + folder_id: Optional[str] = None #################### @@ -61,10 +63,12 @@ class ChatModel(BaseModel): class ChatForm(BaseModel): chat: dict + class ChatTitleMessagesForm(BaseModel): title: str messages: list[dict] + class ChatTitleForm(BaseModel): title: str @@ -80,6 +84,7 @@ class ChatResponse(BaseModel): archived: bool pinned: Optional[bool] = False meta: dict = {} + folder_id: Optional[str] = None class ChatTitleIdResponse(BaseModel): @@ -252,14 +257,18 @@ class ChatTable: limit: int = 50, ) -> list[ChatModel]: with get_db() as db: - query = db.query(Chat).filter_by(user_id=user_id) + query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None) if not include_archived: query = query.filter_by(archived=False) - all_chats = ( - query.order_by(Chat.updated_at.desc()) - # .limit(limit).offset(skip) - .all() - ) + + query = query.order_by(Chat.updated_at.desc()) + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + all_chats = query.all() return [ChatModel.model_validate(chat) for chat in all_chats] def get_chat_title_id_list_by_user_id( @@ -270,7 +279,9 @@ class ChatTable: limit: Optional[int] = None, ) -> list[ChatTitleIdResponse]: with get_db() as db: - query = db.query(Chat).filter_by(user_id=user_id) + query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None) + query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) + if not include_archived: query = query.filter_by(archived=False) @@ -361,7 +372,7 @@ class ChatTable: with get_db() as db: all_chats = ( db.query(Chat) - .filter_by(user_id=user_id, pinned=True) + .filter_by(user_id=user_id, pinned=True, archived=False) .order_by(Chat.updated_at.desc()) ) return [ChatModel.model_validate(chat) for chat in all_chats] @@ -387,9 +398,25 @@ class ChatTable: Filters chats based on a search query using Python, allowing pagination using skip and limit. """ search_text = search_text.lower().strip() + if not search_text: return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit) + search_text_words = search_text.split(" ") + + # search_text might contain 'tag:tag_name' format so we need to extract the tag_name, split the search_text and remove the tags + tag_ids = [ + word.replace("tag:", "").replace(" ", "_").lower() + for word in search_text_words + if word.startswith("tag:") + ] + + search_text_words = [ + word for word in search_text_words if not word.startswith("tag:") + ] + + search_text = " ".join(search_text_words) + with get_db() as db: query = db.query(Chat).filter(Chat.user_id == user_id) @@ -418,6 +445,26 @@ class ChatTable: ) ).params(search_text=search_text) ) + + # Check if there are any tags to filter, it should have all the tags + if tag_ids: + query = query.filter( + and_( + *[ + text( + f""" + EXISTS ( + SELECT 1 + FROM json_each(Chat.meta, '$.tags') AS tag + WHERE tag.value = :tag_id_{tag_idx} + ) + """ + ).params(**{f"tag_id_{tag_idx}": tag_id}) + for tag_idx, tag_id in enumerate(tag_ids) + ] + ) + ) + elif dialect_name == "postgresql": # PostgreSQL relies on proper JSON query for search query = query.filter( @@ -436,6 +483,25 @@ class ChatTable: ) ).params(search_text=search_text) ) + + # Check if there are any tags to filter, it should have all the tags + if tag_ids: + query = query.filter( + and_( + *[ + text( + f""" + EXISTS ( + SELECT 1 + FROM json_array_elements_text(Chat.meta->'tags') AS tag + WHERE tag = :tag_id_{tag_idx} + ) + """ + ).params(**{f"tag_id_{tag_idx}": tag_id}) + for tag_idx, tag_id in enumerate(tag_ids) + ] + ) + ) else: raise NotImplementedError( f"Unsupported dialect: {db.bind.dialect.name}" @@ -444,9 +510,34 @@ class ChatTable: # Perform pagination at the SQL level all_chats = query.offset(skip).limit(limit).all() + print(len(all_chats)) + # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] + def get_chats_by_folder_id_and_user_id( + self, folder_id: str, user_id: str + ) -> list[ChatModel]: + with get_db() as db: + all_chats = ( + db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id).all() + ) + return [ChatModel.model_validate(chat) for chat in all_chats] + + def update_chat_folder_id_by_id_and_user_id( + self, id: str, user_id: str, folder_id: str + ) -> Optional[ChatModel]: + try: + with get_db() as db: + chat = db.get(Chat, id) + chat.folder_id = folder_id + chat.updated_at = int(time.time()) + db.commit() + db.refresh(chat) + return ChatModel.model_validate(chat) + except Exception: + return None + def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]: with get_db() as db: chat = db.get(Chat, id) @@ -498,7 +589,7 @@ class ChatTable: if tag_id not in chat.meta.get("tags", []): chat.meta = { **chat.meta, - "tags": chat.meta.get("tags", []) + [tag_id], + "tags": list(set(chat.meta.get("tags", []) + [tag_id])), } db.commit() @@ -509,7 +600,7 @@ class ChatTable: def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> int: with get_db() as db: # Assuming `get_db()` returns a session object - query = db.query(Chat).filter_by(user_id=user_id) + query = db.query(Chat).filter_by(user_id=user_id, archived=False) # Normalize the tag_name for consistency tag_id = tag_name.replace(" ", "_").lower() @@ -555,7 +646,7 @@ class ChatTable: tags = [tag for tag in tags if tag != tag_id] chat.meta = { **chat.meta, - "tags": tags, + "tags": list(set(tags)), } db.commit() return True diff --git a/backend/open_webui/apps/webui/models/folders.py b/backend/open_webui/apps/webui/models/folders.py new file mode 100644 index 0000000000..91aa0175e3 --- /dev/null +++ b/backend/open_webui/apps/webui/models/folders.py @@ -0,0 +1,225 @@ +import logging +import time +import uuid +from typing import Optional + +from open_webui.apps.webui.internal.db import Base, get_db + + +from open_webui.env import SRC_LOG_LEVELS +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, Text, JSON, Boolean + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + + +#################### +# Folder DB Schema +#################### + + +class Folder(Base): + __tablename__ = "folder" + id = Column(Text, primary_key=True) + parent_id = Column(Text, nullable=True) + user_id = Column(Text) + name = Column(Text) + items = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + is_expanded = Column(Boolean, default=False) + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class FolderModel(BaseModel): + id: str + parent_id: Optional[str] = None + user_id: str + name: str + items: Optional[dict] = None + meta: Optional[dict] = None + is_expanded: bool = False + created_at: int + updated_at: int + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class FolderForm(BaseModel): + name: str + model_config = ConfigDict(extra="allow") + + +class FolderTable: + def insert_new_folder( + self, user_id: str, name: str, parent_id: Optional[str] = None + ) -> Optional[FolderModel]: + with get_db() as db: + id = str(uuid.uuid4()) + folder = FolderModel( + **{ + "id": id, + "user_id": user_id, + "name": name, + "parent_id": parent_id, + "created_at": int(time.time()), + "updated_at": int(time.time()), + } + ) + try: + result = Folder(**folder.model_dump()) + db.add(result) + db.commit() + db.refresh(result) + if result: + return FolderModel.model_validate(result) + else: + return None + except Exception as e: + print(e) + return None + + def get_folder_by_id_and_user_id( + self, id: str, user_id: str + ) -> Optional[FolderModel]: + try: + with get_db() as db: + folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + + if not folder: + return None + + return FolderModel.model_validate(folder) + except Exception: + return None + + def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]: + with get_db() as db: + return [ + FolderModel.model_validate(folder) + for folder in db.query(Folder).filter_by(user_id=user_id).all() + ] + + def get_folder_by_parent_id_and_user_id_and_name( + self, parent_id: Optional[str], user_id: str, name: str + ) -> Optional[FolderModel]: + try: + with get_db() as db: + # Check if folder exists + folder = ( + db.query(Folder) + .filter_by(parent_id=parent_id, user_id=user_id) + .filter(Folder.name.ilike(name)) + .first() + ) + + if not folder: + return None + + return FolderModel.model_validate(folder) + except Exception as e: + log.error(f"get_folder_by_parent_id_and_user_id_and_name: {e}") + return None + + def get_folders_by_parent_id_and_user_id( + self, parent_id: Optional[str], user_id: str + ) -> list[FolderModel]: + with get_db() as db: + return [ + FolderModel.model_validate(folder) + for folder in db.query(Folder) + .filter_by(parent_id=parent_id, user_id=user_id) + .all() + ] + + def update_folder_parent_id_by_id_and_user_id( + self, + id: str, + user_id: str, + parent_id: str, + ) -> Optional[FolderModel]: + try: + with get_db() as db: + folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + + if not folder: + return None + + folder.parent_id = parent_id + folder.updated_at = int(time.time()) + + db.commit() + + return FolderModel.model_validate(folder) + except Exception as e: + log.error(f"update_folder: {e}") + return + + def update_folder_name_by_id_and_user_id( + self, id: str, user_id: str, name: str + ) -> Optional[FolderModel]: + try: + with get_db() as db: + folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + + if not folder: + return None + + existing_folder = ( + db.query(Folder) + .filter_by(name=name, parent_id=folder.parent_id, user_id=user_id) + .first() + ) + + if existing_folder: + return None + + folder.name = name + folder.updated_at = int(time.time()) + + db.commit() + + return FolderModel.model_validate(folder) + except Exception as e: + log.error(f"update_folder: {e}") + return + + def update_folder_is_expanded_by_id_and_user_id( + self, id: str, user_id: str, is_expanded: bool + ) -> Optional[FolderModel]: + try: + with get_db() as db: + folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + + if not folder: + return None + + folder.is_expanded = is_expanded + folder.updated_at = int(time.time()) + + db.commit() + + return FolderModel.model_validate(folder) + except Exception as e: + log.error(f"update_folder: {e}") + return + + def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool: + try: + with get_db() as db: + folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + db.delete(folder) + db.commit() + return True + except Exception as e: + log.error(f"delete_folder: {e}") + return False + + +Folders = FolderTable() diff --git a/backend/open_webui/apps/webui/routers/auths.py b/backend/open_webui/apps/webui/routers/auths.py index e9f94ff6a0..35fd254f12 100644 --- a/backend/open_webui/apps/webui/routers/auths.py +++ b/backend/open_webui/apps/webui/routers/auths.py @@ -1,10 +1,13 @@ import re import uuid +import time +import datetime from open_webui.apps.webui.models.auths import ( AddUserForm, ApiKey, Auths, + Token, SigninForm, SigninResponse, SignupForm, @@ -34,6 +37,7 @@ from open_webui.utils.utils import ( get_password_hash, ) from open_webui.utils.webhook import post_webhook +from typing import Optional router = APIRouter() @@ -42,25 +46,44 @@ router = APIRouter() ############################ -@router.get("/", response_model=UserResponse) +class SessionUserResponse(Token, UserResponse): + expires_at: Optional[int] = None + + +@router.get("/", response_model=SessionUserResponse) async def get_session_user( request: Request, response: Response, user=Depends(get_current_user) ): + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + token = create_token( data={"id": user.id}, - expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), + expires_delta=expires_delta, + ) + + datetime_expires_at = ( + datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) + if expires_at + else None ) # Set the cookie token response.set_cookie( key="token", value=token, + expires=datetime_expires_at, httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_SESSION_COOKIE_SAME_SITE, - secure=WEBUI_SESSION_COOKIE_SECURE, + samesite=WEBUI_SESSION_COOKIE_SAME_SITE, + secure=WEBUI_SESSION_COOKIE_SECURE, ) return { + "token": token, + "token_type": "Bearer", + "expires_at": expires_at, "id": user.id, "email": user.email, "name": user.name, @@ -119,7 +142,7 @@ async def update_password( ############################ -@router.post("/signin", response_model=SigninResponse) +@router.post("/signin", response_model=SessionUserResponse) async def signin(request: Request, response: Response, form_data: SigninForm): if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers: @@ -161,23 +184,37 @@ async def signin(request: Request, response: Response, form_data: SigninForm): user = Auths.authenticate_user(form_data.email.lower(), form_data.password) if user: + + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + token = create_token( data={"id": user.id}, - expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), + expires_delta=expires_delta, + ) + + datetime_expires_at = ( + datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) + if expires_at + else None ) # Set the cookie token response.set_cookie( key="token", value=token, + expires=datetime_expires_at, httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_SESSION_COOKIE_SAME_SITE, - secure=WEBUI_SESSION_COOKIE_SECURE, + samesite=WEBUI_SESSION_COOKIE_SAME_SITE, + secure=WEBUI_SESSION_COOKIE_SECURE, ) return { "token": token, "token_type": "Bearer", + "expires_at": expires_at, "id": user.id, "email": user.email, "name": user.name, @@ -193,7 +230,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): ############################ -@router.post("/signup", response_model=SigninResponse) +@router.post("/signup", response_model=SessionUserResponse) async def signup(request: Request, response: Response, form_data: SignupForm): if WEBUI_AUTH: if ( @@ -233,18 +270,30 @@ async def signup(request: Request, response: Response, form_data: SignupForm): ) if user: + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + token = create_token( data={"id": user.id}, - expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), + expires_delta=expires_delta, + ) + + datetime_expires_at = ( + datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) + if expires_at + else None ) # Set the cookie token response.set_cookie( key="token", value=token, + expires=datetime_expires_at, httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_SESSION_COOKIE_SAME_SITE, - secure=WEBUI_SESSION_COOKIE_SECURE, + samesite=WEBUI_SESSION_COOKIE_SAME_SITE, + secure=WEBUI_SESSION_COOKIE_SECURE, ) if request.app.state.config.WEBHOOK_URL: @@ -261,6 +310,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): return { "token": token, "token_type": "Bearer", + "expires_at": expires_at, "id": user.id, "email": user.email, "name": user.name, diff --git a/backend/open_webui/apps/webui/routers/chats.py b/backend/open_webui/apps/webui/routers/chats.py index b919d14473..9c404a9a7f 100644 --- a/backend/open_webui/apps/webui/routers/chats.py +++ b/backend/open_webui/apps/webui/routers/chats.py @@ -114,13 +114,24 @@ async def search_user_chats( limit = 60 skip = (page - 1) * limit - return [ + chat_list = [ ChatTitleIdResponse(**chat.model_dump()) for chat in Chats.get_chats_by_user_id_and_search_text( user.id, text, skip=skip, limit=limit ) ] + # Delete tag if no chat is found + words = text.strip().split(" ") + if page == 1 and len(words) == 1 and words[0].startswith("tag:"): + tag_id = words[0].replace("tag:", "") + if len(chat_list) == 0: + if Tags.get_tag_by_name_and_user_id(tag_id, user.id): + log.debug(f"deleting tag: {tag_id}") + Tags.delete_tag_by_name_and_user_id(tag_id, user.id) + + return chat_list + ############################ # GetPinnedChats @@ -315,7 +326,13 @@ async def update_chat_by_id( @router.delete("/{id}", response_model=bool) async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): if user.role == "admin": + chat = Chats.get_chat_by_id(id) + for tag in chat.meta.get("tags", []): + if Chats.count_chats_by_tag_name_and_user_id(tag, user.id) == 1: + Tags.delete_tag_by_name_and_user_id(tag, user.id) + result = Chats.delete_chat_by_id(id) + return result else: if not request.app.state.config.USER_PERMISSIONS.get("chat", {}).get( @@ -326,6 +343,11 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + chat = Chats.get_chat_by_id(id) + for tag in chat.meta.get("tags", []): + if Chats.count_chats_by_tag_name_and_user_id(tag, user.id) == 1: + Tags.delete_tag_by_name_and_user_id(tag, user.id) + result = Chats.delete_chat_by_id_and_user_id(id, user.id) return result @@ -397,6 +419,20 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): chat = Chats.get_chat_by_id_and_user_id(id, user.id) if chat: chat = Chats.toggle_chat_archive_by_id(id) + + # Delete tags if chat is archived + if chat.archived: + for tag_id in chat.meta.get("tags", []): + if Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id) == 0: + log.debug(f"deleting tag: {tag_id}") + Tags.delete_tag_by_name_and_user_id(tag_id, user.id) + else: + for tag_id in chat.meta.get("tags", []): + tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id) + if tag is None: + log.debug(f"inserting tag: {tag_id}") + tag = Tags.insert_new_tag(tag_id, user.id) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -455,6 +491,31 @@ async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user)): ) +############################ +# UpdateChatFolderIdById +############################ + + +class ChatFolderIdForm(BaseModel): + folder_id: Optional[str] = None + + +@router.post("/{id}/folder", response_model=Optional[ChatResponse]) +async def update_chat_folder_id_by_id( + id: str, form_data: ChatFolderIdForm, user=Depends(get_verified_user) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: + chat = Chats.update_chat_folder_id_by_id_and_user_id( + id, user.id, form_data.folder_id + ) + return ChatResponse(**chat.model_dump()) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # GetChatTagsById ############################ diff --git a/backend/open_webui/apps/webui/routers/folders.py b/backend/open_webui/apps/webui/routers/folders.py new file mode 100644 index 0000000000..08f07b8c64 --- /dev/null +++ b/backend/open_webui/apps/webui/routers/folders.py @@ -0,0 +1,259 @@ +import logging +import os +import shutil +import uuid +from pathlib import Path +from typing import Optional +from pydantic import BaseModel +import mimetypes + + +from open_webui.apps.webui.models.folders import ( + FolderForm, + FolderModel, + Folders, +) +from open_webui.apps.webui.models.chats import Chats + +from open_webui.config import UPLOAD_DIR +from open_webui.env import SRC_LOG_LEVELS +from open_webui.constants import ERROR_MESSAGES + + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import FileResponse, StreamingResponse + + +from open_webui.utils.utils import get_admin_user, get_verified_user + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + + +router = APIRouter() + + +############################ +# Get Folders +############################ + + +@router.get("/", response_model=list[FolderModel]) +async def get_folders(user=Depends(get_verified_user)): + folders = Folders.get_folders_by_user_id(user.id) + + return [ + { + **folder.model_dump(), + "items": { + "chats": [ + {"title": chat.title, "id": chat.id} + for chat in Chats.get_chats_by_folder_id_and_user_id( + folder.id, user.id + ) + ] + }, + } + for folder in folders + ] + + +############################ +# Create Folder +############################ + + +@router.post("/") +def create_folder(form_data: FolderForm, user=Depends(get_verified_user)): + folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + None, user.id, form_data.name + ) + + if folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + ) + + try: + folder = Folders.insert_new_folder(user.id, form_data.name) + return folder + except Exception as e: + log.exception(e) + log.error("Error creating folder") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error creating folder"), + ) + + +############################ +# Get Folders By Id +############################ + + +@router.get("/{id}", response_model=Optional[FolderModel]) +async def get_folder_by_id(id: str, user=Depends(get_verified_user)): + folder = Folders.get_folder_by_id_and_user_id(id, user.id) + if folder: + return folder + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# Update Folder Name By Id +############################ + + +@router.post("/{id}/update") +async def update_folder_name_by_id( + id: str, form_data: FolderForm, user=Depends(get_verified_user) +): + folder = Folders.get_folder_by_id_and_user_id(id, user.id) + if folder: + existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + folder.parent_id, user.id, form_data.name + ) + if existing_folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + ) + + try: + folder = Folders.update_folder_name_by_id_and_user_id( + id, user.id, form_data.name + ) + + return folder + except Exception as e: + log.exception(e) + log.error(f"Error updating folder: {id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# Update Folder Parent Id By Id +############################ + + +class FolderParentIdForm(BaseModel): + parent_id: Optional[str] = None + + +@router.post("/{id}/update/parent") +async def update_folder_parent_id_by_id( + id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user) +): + folder = Folders.get_folder_by_id_and_user_id(id, user.id) + if folder: + existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + form_data.parent_id, user.id, folder.name + ) + + if existing_folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + ) + + try: + folder = Folders.update_folder_parent_id_by_id_and_user_id( + id, user.id, form_data.parent_id + ) + return folder + except Exception as e: + log.exception(e) + log.error(f"Error updating folder: {id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# Update Folder Is Expanded By Id +############################ + + +class FolderIsExpandedForm(BaseModel): + is_expanded: bool + + +@router.post("/{id}/update/expanded") +async def update_folder_is_expanded_by_id( + id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user) +): + folder = Folders.get_folder_by_id_and_user_id(id, user.id) + if folder: + try: + folder = Folders.update_folder_is_expanded_by_id_and_user_id( + id, user.id, form_data.is_expanded + ) + return folder + except Exception as e: + log.exception(e) + log.error(f"Error updating folder: {id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# Delete Folder By Id +############################ + + +@router.delete("/{id}") +async def delete_folder_by_id(id: str, user=Depends(get_verified_user)): + folder = Folders.get_folder_by_id_and_user_id(id, user.id) + if folder: + try: + result = Folders.delete_folder_by_id_and_user_id(id, user.id) + if result: + # Delete all chats in the folder + chats = Chats.get_chats_by_folder_id_and_user_id(id, user.id) + for chat in chats: + Chats.delete_chat_by_id(chat.id, user.id) + + return result + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"), + ) + except Exception as e: + log.exception(e) + log.error(f"Error deleting folder: {id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"), + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index d55619ee0d..05b27c4471 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1042,7 +1042,7 @@ CHUNK_OVERLAP = PersistentConfig( DEFAULT_RAG_TEMPLATE = """You are given a user query, some textual context and rules, all inside xml tags. You have to answer the query based on the context while respecting the rules. -[context] +{{CONTEXT}} @@ -1055,7 +1055,7 @@ DEFAULT_RAG_TEMPLATE = """You are given a user query, some textual context and r -[query] +{{QUERY}} """ diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 704cdd0745..4e2ef008bc 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -20,7 +20,9 @@ class ERROR_MESSAGES(str, Enum): def __str__(self) -> str: return super().__str__() - DEFAULT = lambda err="": f"Something went wrong :/\n[ERROR: {err if err else ''}]" + DEFAULT = ( + lambda err="": f'{"Something went wrong :/" if err == "" else "[ERROR: " + err + "]"}' + ) ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance." DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot." diff --git a/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py b/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py index 9d79b57495..8a0ab1b491 100644 --- a/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py +++ b/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py @@ -135,7 +135,7 @@ def upgrade(): tags = chat_updates[chat_id]["meta"].get("tags", []) tags.append(tag_name) - chat_updates[chat_id]["meta"]["tags"] = tags + chat_updates[chat_id]["meta"]["tags"] = list(set(tags)) # Update chats based on accumulated changes for chat_id, updates in chat_updates.items(): diff --git a/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py index 7c7126e2f6..6e010424b0 100644 --- a/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py +++ b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py @@ -28,25 +28,39 @@ def upgrade(): unique_constraints = inspector.get_unique_constraints("tag") existing_indexes = inspector.get_indexes("tag") - print(existing_pk, unique_constraints) + print(f"Primary Key: {existing_pk}") + print(f"Unique Constraints: {unique_constraints}") + print(f"Indexes: {existing_indexes}") with op.batch_alter_table("tag", schema=None) as batch_op: - # Drop unique constraints that could conflict with new primary key + # Drop existing primary key constraint if it exists + if existing_pk and existing_pk.get("constrained_columns"): + pk_name = existing_pk.get("name") + if pk_name: + print(f"Dropping primary key constraint: {pk_name}") + batch_op.drop_constraint(pk_name, type_="primary") + + # Now create the new primary key with the combination of 'id' and 'user_id' + print("Creating new primary key with 'id' and 'user_id'.") + batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"]) + + # Drop unique constraints that could conflict with the new primary key for constraint in unique_constraints: - if constraint["name"] == "uq_id_user_id": + if ( + constraint["name"] == "uq_id_user_id" + ): # Adjust this name according to what is actually returned by the inspector + print(f"Dropping unique constraint: {constraint['name']}") batch_op.drop_constraint(constraint["name"], type_="unique") for index in existing_indexes: if index["unique"]: - # Drop the unique index - batch_op.drop_index(index["name"]) - - # Drop existing primary key constraint if it exists - if existing_pk and existing_pk.get("constrained_columns"): - batch_op.drop_constraint(existing_pk["name"], type_="primary") - - # Immediately after dropping the old primary key, create the new one - batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"]) + if not any( + constraint["name"] == index["name"] + for constraint in unique_constraints + ): + # You are attempting to drop unique indexes + print(f"Dropping unique index: {index['name']}") + batch_op.drop_index(index["name"]) def downgrade(): diff --git a/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py b/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py new file mode 100644 index 0000000000..83e0dc28ed --- /dev/null +++ b/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py @@ -0,0 +1,50 @@ +"""Add folder table + +Revision ID: c69f45358db4 +Revises: 3ab32c4b8f59 +Create Date: 2024-10-16 02:02:35.241684 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "c69f45358db4" +down_revision = "3ab32c4b8f59" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "folder", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("parent_id", sa.Text(), nullable=True), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("items", sa.JSON(), nullable=True), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("is_expanded", sa.Boolean(), default=False, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + sa.PrimaryKeyConstraint("id", "user_id"), + ) + + op.add_column( + "chat", + sa.Column("folder_id", sa.Text(), nullable=True), + ) + + +def downgrade(): + op.drop_column("chat", "folder_id") + + op.drop_table("folder") diff --git a/backend/open_webui/utils/utils.py b/backend/open_webui/utils/utils.py index 45a7eef305..79faa1831f 100644 --- a/backend/open_webui/utils/utils.py +++ b/backend/open_webui/utils/utils.py @@ -7,7 +7,7 @@ import jwt from open_webui.apps.webui.models.users import Users from open_webui.constants import ERROR_MESSAGES from open_webui.env import WEBUI_SECRET_KEY -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from passlib.context import CryptContext diff --git a/backend/requirements.txt b/backend/requirements.txt index 12f2ce3b05..561f291cb5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -90,3 +90,5 @@ duckduckgo-search~=6.2.13 docker~=7.1.0 pytest~=8.3.2 pytest-docker~=3.1.1 + +googleapis-common-protos==1.63.2 diff --git a/pyproject.toml b/pyproject.toml index 80b04e140e..b6248063da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,6 @@ dependencies = [ "sentence-transformers==3.2.0", "colbert-ai==0.2.21", "einops==0.8.0", - "ftfy==6.2.3", "pypdf==4.3.1", @@ -94,7 +93,9 @@ dependencies = [ "docker~=7.1.0", "pytest~=8.3.2", - "pytest-docker~=3.1.1" + "pytest-docker~=3.1.1", + + "googleapis-common-protos==1.63.2" ] readme = "README.md" requires-python = ">= 3.11, < 3.12.0a1" diff --git a/src/app.css b/src/app.css index 7a8bf59b01..53700abc6c 100644 --- a/src/app.css +++ b/src/app.css @@ -56,7 +56,7 @@ li p { ::-webkit-scrollbar-thumb { --tw-border-opacity: 1; - background-color: rgba(217, 217, 227, 0.8); + background-color: rgba(236, 236, 236, 0.8); border-color: rgba(255, 255, 255, var(--tw-border-opacity)); border-radius: 9999px; border-width: 1px; @@ -64,7 +64,7 @@ li p { /* Dark theme scrollbar styles */ .dark ::-webkit-scrollbar-thumb { - background-color: rgba(69, 69, 74, 0.8); /* Darker color for dark theme */ + background-color: rgba(33, 33, 33, 0.8); /* Darker color for dark theme */ border-color: rgba(0, 0, 0, var(--tw-border-opacity)); } diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index ff89fdf43e..13ad8fdba1 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -267,7 +267,7 @@ export const getAllUserChats = async (token: string) => { return res; }; -export const getAllChatTags = async (token: string) => { +export const getAllTags = async (token: string) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all/tags`, { @@ -579,6 +579,41 @@ export const shareChatById = async (token: string, id: string) => { return res; }; +export const updateChatFolderIdById = async (token: string, id: string, folderId?: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/folder`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + folder_id: folderId + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const archiveChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts new file mode 100644 index 0000000000..f1a1f5b483 --- /dev/null +++ b/src/lib/apis/folders/index.ts @@ -0,0 +1,269 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const createNewFolder = async (token: string, name: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + name: name + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getFolders = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, { + 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.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getFolderById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, { + 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.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateFolderNameById = async (token: string, id: string, name: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + name: name + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateFolderIsExpandedById = async ( + token: string, + id: string, + isExpanded: boolean +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/expanded`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + is_expanded: isExpanded + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateFolderParentIdById = async (token: string, id: string, parentId?: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/parent`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + parent_id: parentId + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type FolderItems = { + chat_ids: string[]; + file_ids: string[]; +}; + +export const updateFolderItemsById = async (token: string, id: string, items: FolderItems) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/items`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + items: items + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteFolderById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, { + method: 'DELETE', + 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.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 1a5bd00bee..abde51e4f4 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -651,8 +651,8 @@ class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right" bind:value={textSplitter} > - - + + diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index dc4fcdf554..50b8f1a720 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -184,7 +184,13 @@ const onDragOver = (e) => { e.preventDefault(); - dragged = true; + + // Check if a file is being dragged. + if (e.dataTransfer?.types?.includes('Files')) { + dragged = true; + } else { + dragged = false; + } }; const onDragLeave = () => { @@ -200,8 +206,6 @@ if (inputFiles && inputFiles.length > 0) { console.log(inputFiles); inputFilesHandler(inputFiles); - } else { - toast.error($i18n.t(`File not found.`)); } } diff --git a/src/lib/components/chat/MessageInput/VoiceRecording.svelte b/src/lib/components/chat/MessageInput/VoiceRecording.svelte index f98fc2cb18..9812669968 100644 --- a/src/lib/components/chat/MessageInput/VoiceRecording.svelte +++ b/src/lib/components/chat/MessageInput/VoiceRecording.svelte @@ -269,6 +269,13 @@ await mediaRecorder.stop(); } clearInterval(durationCounter); + + if (stream) { + const tracks = stream.getTracks(); + tracks.forEach((track) => track.stop()); + } + + stream = null; }; diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte index 6bb8cfee2f..68fdf737d4 100644 --- a/src/lib/components/chat/Messages/RateComment.svelte +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -2,6 +2,7 @@ import { toast } from 'svelte-sonner'; import { createEventDispatcher, onMount, getContext } from 'svelte'; + import { config } from '$lib/stores'; const i18n = getContext('i18n'); @@ -50,15 +51,15 @@ loadReasons(); }); - const submitHandler = () => { - console.log('submitHandler'); + const saveHandler = () => { + console.log('saveHandler'); if (!selectedReason) { toast.error($i18n.t('Please select a reason')); return; } - dispatch('submit', { + dispatch('save', { reason: selectedReason, comment: comment }); @@ -69,7 +70,7 @@
@@ -97,7 +98,7 @@
{#each reasons as reason}
-
+
+ {#if $config?.features.enable_community_sharing} + + {/if} +
diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index ad49697de6..9676835e49 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -1103,7 +1103,7 @@ { + on:save={(e) => { dispatch('save', { ...message, annotation: { diff --git a/src/lib/components/chat/Tags.svelte b/src/lib/components/chat/Tags.svelte index 7c5a0c0c1a..f71ff0af99 100644 --- a/src/lib/components/chat/Tags.svelte +++ b/src/lib/components/chat/Tags.svelte @@ -2,7 +2,7 @@ import { addTagById, deleteTagById, - getAllChatTags, + getAllTags, getChatList, getChatListByTagName, getTagsById, @@ -37,7 +37,10 @@ tags: tags }); - _tags.set(await getAllChatTags(localStorage.token)); + await _tags.set(await getAllTags(localStorage.token)); + dispatch('add', { + name: tagName + }); }; const deleteTag = async (tagName) => { @@ -47,7 +50,7 @@ tags: tags }); - await _tags.set(await getAllChatTags(localStorage.token)); + await _tags.set(await getAllTags(localStorage.token)); dispatch('delete', { name: tagName }); diff --git a/src/lib/components/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index 47f4ae44bf..c15512de74 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -1,4 +1,9 @@
{#if title !== null} - +
{:else} - +
+ +
+
{/if} -
-
+ {#if open && !hide} +
diff --git a/src/lib/components/common/DragGhost.svelte b/src/lib/components/common/DragGhost.svelte new file mode 100644 index 0000000000..7169d72f06 --- /dev/null +++ b/src/lib/components/common/DragGhost.svelte @@ -0,0 +1,30 @@ + + + + + +
+
+ +
+
diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index e8e1eb8b5a..b01bceaceb 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -22,7 +22,7 @@ + import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte'; + + const i18n = getContext('i18n'); + const dispatch = createEventDispatcher(); + + import ChevronDown from '../icons/ChevronDown.svelte'; + import ChevronRight from '../icons/ChevronRight.svelte'; + import Collapsible from './Collapsible.svelte'; + + export let open = true; + + export let id = ''; + export let name = ''; + export let collapsible = true; + + export let className = ''; + + let folderElement; + + let draggedOver = false; + + const onDragOver = (e) => { + e.preventDefault(); + draggedOver = true; + }; + + const onDrop = (e) => { + e.preventDefault(); + + if (folderElement.contains(e.target)) { + console.log('Dropped on the Button'); + + try { + // get data from the drag event + const dataTransfer = e.dataTransfer.getData('text/plain'); + const data = JSON.parse(dataTransfer); + console.log(data); + dispatch('drop', data); + } catch (error) { + console.error(error); + } + + draggedOver = false; + } + }; + + const onDragLeave = (e) => { + e.preventDefault(); + draggedOver = false; + }; + + onMount(() => { + folderElement.addEventListener('dragover', onDragOver); + folderElement.addEventListener('drop', onDrop); + folderElement.addEventListener('dragleave', onDragLeave); + }); + + onDestroy(() => { + folderElement.addEventListener('dragover', onDragOver); + folderElement.removeEventListener('drop', onDrop); + folderElement.removeEventListener('dragleave', onDragLeave); + }); + + +
+ {#if draggedOver} +
+ {/if} + + {#if collapsible} + { + dispatch('change', e.detail); + }} + > + +
+ +
+ +
+ +
+
+ {:else} + + {/if} +
diff --git a/src/lib/components/icons/Document.svelte b/src/lib/components/icons/Document.svelte new file mode 100644 index 0000000000..9ae719725b --- /dev/null +++ b/src/lib/components/icons/Document.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index ed7107c6fd..bb224309a7 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -14,28 +14,23 @@ pinnedChats, scrollPaginationEnabled, currentChatPage, - temporaryChatEnabled, - showArtifacts, - showOverview, - showControls + temporaryChatEnabled } from '$lib/stores'; import { onMount, getContext, tick, onDestroy } from 'svelte'; const i18n = getContext('i18n'); - import { updateUserSettings } from '$lib/apis/users'; import { deleteChatById, getChatList, - getChatById, - getChatListByTagName, - updateChatById, - getAllChatTags, - archiveChatById, - cloneChatById, + getAllTags, getChatListBySearchText, createNewChat, - getPinnedChatList + getPinnedChatList, + toggleChatPinnedStatusById, + getChatPinnedStatusById, + getChatById, + updateChatFolderIdById } from '$lib/apis/chats'; import { WEBUI_BASE_URL } from '$lib/constants'; @@ -45,9 +40,13 @@ import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Spinner from '../common/Spinner.svelte'; import Loader from '../common/Loader.svelte'; - import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte'; - import { select } from 'd3-selection'; + import SearchInput from './Sidebar/SearchInput.svelte'; + import Folder from '../common/Folder.svelte'; + import Plus from '../icons/Plus.svelte'; + import Tooltip from '../common/Tooltip.svelte'; + import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders'; + import Folders from './Sidebar/Folders.svelte'; const BREAKPOINT = 768; @@ -64,12 +63,82 @@ let selectedTagName = null; + let showPinnedChat = true; + // Pagination variables let chatListLoading = false; let allChatsLoaded = false; + let folders = {}; + + const initFolders = async () => { + const folderList = await getFolders(localStorage.token).catch((error) => { + toast.error(error); + return []; + }); + + folders = {}; + + // First pass: Initialize all folder entries + for (const folder of folderList) { + // Ensure folder is added to folders with its data + folders[folder.id] = { ...(folders[folder.id] || {}), ...folder }; + } + + // Second pass: Tie child folders to their parents + for (const folder of folderList) { + if (folder.parent_id) { + // Ensure the parent folder is initialized if it doesn't exist + if (!folders[folder.parent_id]) { + folders[folder.parent_id] = {}; // Create a placeholder if not already present + } + + // Initialize childrenIds array if it doesn't exist and add the current folder id + folders[folder.parent_id].childrenIds = folders[folder.parent_id].childrenIds + ? [...folders[folder.parent_id].childrenIds, folder.id] + : [folder.id]; + + // Sort the children by updated_at field + folders[folder.parent_id].childrenIds.sort((a, b) => { + return folders[b].updated_at - folders[a].updated_at; + }); + } + } + }; + + const createFolder = async (name = 'Untitled') => { + if (name === '') { + toast.error($i18n.t('Folder name cannot be empty.')); + return; + } + + const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null); + if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) { + // If a folder with the same name already exists, append a number to the name + let i = 1; + while ( + rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase()) + ) { + i++; + } + + name = `${name} ${i}`; + } + + const res = await createNewFolder(localStorage.token, name).catch((error) => { + toast.error(error); + return null; + }); + + if (res) { + await initFolders(); + } + }; + const initChatList = async () => { // Reset pagination variables + tags.set(await getAllTags(localStorage.token)); + currentChatPage.set(1); allChatsLoaded = false; await chats.set(await getChatList(localStorage.token, $currentChatPage)); @@ -93,7 +162,7 @@ // once the bottom of the list has been reached (no results) there is no need to continue querying allChatsLoaded = newChatList.length === 0; - await chats.set([...$chats, ...newChatList]); + await chats.set([...($chats ? $chats : []), ...newChatList]); chatListLoading = false; }; @@ -116,6 +185,10 @@ searchDebounceTimeout = setTimeout(async () => { currentChatPage.set(1); await chats.set(await getChatListBySearchText(localStorage.token, search)); + + if ($chats.length === 0) { + tags.set(await getAllTags(localStorage.token)); + } }, 1000); } }; @@ -127,6 +200,8 @@ }); if (res) { + tags.set(await getAllTags(localStorage.token)); + if ($chatId === id) { await chatId.set(''); await tick(); @@ -136,7 +211,6 @@ allChatsLoaded = false; currentChatPage.set(1); await chats.set(await getChatList(localStorage.token, $currentChatPage)); - await pinnedChats.set(await getPinnedChatList(localStorage.token)); } }; @@ -171,14 +245,11 @@ const tagEventHandler = async (type, tagName, chatId) => { console.log(type, tagName, chatId); if (type === 'delete') { - if (selectedTagName === tagName) { - if ($tags.map((t) => t.name).includes(tagName)) { - await chats.set(await getChatListByTagName(localStorage.token, tagName)); - } else { - selectedTagName = null; - await initChatList(); - } - } + currentChatPage.set(1); + await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage)); + } else if (type === 'add') { + currentChatPage.set(1); + await chats.set(await getChatListBySearchText(localStorage.token, search, $currentChatPage)); } }; @@ -186,7 +257,13 @@ const onDragOver = (e) => { e.preventDefault(); - dragged = true; + + // Check if a file is being dragged. + if (e.dataTransfer?.types?.includes('Files')) { + dragged = true; + } else { + dragged = false; + } }; const onDragLeave = () => { @@ -195,19 +272,19 @@ const onDrop = async (e) => { e.preventDefault(); - console.log(e); + console.log(e); // Log the drop event + // Perform file drop check and handle it accordingly if (e.dataTransfer?.files) { const inputFiles = Array.from(e.dataTransfer?.files); + if (inputFiles && inputFiles.length > 0) { - console.log(inputFiles); - inputFilesHandler(inputFiles); - } else { - toast.error($i18n.t(`File not found.`)); + console.log(inputFiles); // Log the dropped files + inputFilesHandler(inputFiles); // Handle the dropped files } } - dragged = false; + dragged = false; // Reset dragged status after drop }; let touchstart; @@ -256,6 +333,8 @@ }; onMount(async () => { + showPinnedChat = localStorage?.showPinnedChat ? localStorage.showPinnedChat === 'true' : true; + mobile.subscribe((e) => { if ($showSidebar && e) { showSidebar.set(false); @@ -271,6 +350,7 @@ localStorage.sidebar = value; }); + await initFolders(); await pinnedChats.set(await getPinnedChatList(localStorage.token)); await initChatList(); @@ -311,7 +391,8 @@ { - await chats.set(await getChatList(localStorage.token)); + await pinnedChats.set(await getPinnedChatList(localStorage.token)); + await initChatList(); }} /> @@ -342,8 +423,8 @@ bind:this={navElement} id="sidebar" class="h-screen max-h-[100dvh] min-h-screen select-none {$showSidebar - ? 'md:relative w-[260px]' - : '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0 + ? 'md:relative w-[260px] max-w-[260px]' + : '-translate-x-[260px] w-[0px]'} bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-200 text-sm transition fixed z-50 top-0 left-0 overflow-x-hidden " data-state={$showSidebar} > @@ -360,14 +441,14 @@
{/if}
+ + +
+
{/if} -
- + {/if} - {#if $tags.length > 0} -
- - {#each $tags as tag} - - {/each} -
- {/if} + if (type === 'chat') { + const chat = await getChatById(localStorage.token, id); - {#if !search && $pinnedChats.length > 0} -
-
-
- {$i18n.t('Pinned')} -
+ if (chat) { + console.log(chat); + if (chat.folder_id) { + const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch( + (error) => { + toast.error(error); + return null; + } + ); - {#each $pinnedChats as chat, idx} - { - selectedChatId = chat.id; - }} - on:unselect={() => { - selectedChatId = null; - }} - on:delete={(e) => { - if ((e?.detail ?? '') === 'shift') { - deleteChatHandler(chat.id); - } else { - deleteChat = chat; - showDeleteConfirm = true; + if (res) { + initChatList(); + await initFolders(); } - }} - on:tag={(e) => { - const { type, name } = e.detail; - tagEventHandler(type, name, chat.id); - }} - /> - {/each} -
-
- {/if} + } -
- {#if $chats} - {#each $chats as chat, idx} - {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)} -
- {$i18n.t(chat.time_range)} - -
- {/if} +
+ {/if} - { - selectedChatId = chat.id; - }} - on:unselect={() => { - selectedChatId = null; - }} - on:delete={(e) => { - if ((e?.detail ?? '') === 'shift') { - deleteChatHandler(chat.id); - } else { - deleteChat = chat; - showDeleteConfirm = true; - } - }} - on:tag={(e) => { - const { type, name } = e.detail; - tagEventHandler(type, name, chat.id); - }} - /> - {/each} + { + selectedChatId = chat.id; + }} + on:unselect={() => { + selectedChatId = null; + }} + on:delete={(e) => { + if ((e?.detail ?? '') === 'shift') { + deleteChatHandler(chat.id); + } else { + deleteChat = chat; + showDeleteConfirm = true; + } + }} + on:change={async () => { + await pinnedChats.set(await getPinnedChatList(localStorage.token)); + initChatList(); + }} + on:tag={(e) => { + const { type, name } = e.detail; + tagEventHandler(type, name, chat.id); + }} + /> + {/each} - {#if $scrollPaginationEnabled && !allChatsLoaded} - { - if (!chatListLoading) { - loadMoreChats(); - } - }} - > + {#if $scrollPaginationEnabled && !allChatsLoaded} + { + if (!chatListLoading) { + loadMoreChats(); + } + }} + > +
+ +
Loading...
+
+
+ {/if} + {:else}
Loading...
-
- {/if} - {:else} -
- -
Loading...
+ {/if}
- {/if} +
-
- - +
{#if $user !== undefined} @@ -193,16 +273,7 @@ chatTitle = ''; }} > - - - +
@@ -212,7 +283,7 @@ - {#if chat.id === $chatId} + {#if id === $chatId} + + + +
+ +
+ {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.chats ?? []).length > 0} +
+ {#if folders[folderId]?.childrenIds} + {@const children = folders[folderId]?.childrenIds + .map((id) => folders[id]) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: 'base' + }) + )} + + {#each children as childFolder (`${folderId}-${childFolder.id}`)} + { + dispatch('update', e.detail); + }} + /> + {/each} + {/if} + + {#if folders[folderId].items?.chats} + {#each folders[folderId].items.chats as chat (chat.id)} + + {/each} + {/if} +
+ {/if} +
+ +
diff --git a/src/lib/components/layout/Sidebar/SearchInput.svelte b/src/lib/components/layout/Sidebar/SearchInput.svelte new file mode 100644 index 0000000000..5e8213308c --- /dev/null +++ b/src/lib/components/layout/Sidebar/SearchInput.svelte @@ -0,0 +1,208 @@ + + +
+ + + {#if focused && (filteredOptions.length > 0 || filteredTags.length > 0)} + +
{ + selectedIdx = null; + }} + on:mouseleave={() => { + selectedIdx = 0; + }} + > +
+ {#if filteredTags.length > 0} +
Tags
+ +
+ {#each filteredTags as tag, tagIdx} + + {/each} +
+ {:else if filteredOptions.length > 0} +
Search options
+ +
+ {#each filteredOptions as option, optionIdx} + + {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/workspace/Functions.svelte b/src/lib/components/workspace/Functions.svelte index 2618b25b34..990b06428d 100644 --- a/src/lib/components/workspace/Functions.svelte +++ b/src/lib/components/workspace/Functions.svelte @@ -47,6 +47,14 @@ let showDeleteConfirm = false; + let filteredItems = []; + $: filteredItems = $functions.filter( + (f) => + query === '' || + f.name.toLowerCase().includes(query.toLowerCase()) || + f.id.toLowerCase().includes(query.toLowerCase()) + ); + const shareHandler = async (func) => { const item = await getFunctionById(localStorage.token, func.id).catch((error) => { toast.error(error); @@ -174,17 +182,7 @@ -
-
-
- {$i18n.t('Functions')} -
- {$functions.length} -
-
-
- -
+
-
+ +
+
+
+ {$i18n.t('Functions')} +
+ {filteredItems.length} +
+
+
- {#each $functions.filter((f) => query === '' || f.name - .toLowerCase() - .includes(query.toLowerCase()) || f.id.toLowerCase().includes(query.toLowerCase())) as func} + {#each filteredItems as func}
diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index 6ed4864e84..7053c845a2 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -72,17 +72,7 @@ }} /> -
-
-
- {$i18n.t('Knowledge')} -
- {$knowledge.length} -
-
-
- -
+
-
+
+
+
+ {$i18n.t('Knowledge')} +
+ {filteredItems.length} +
+
+
-
+
{#each filteredItems as item} - -
-
- -
-
-
-