diff --git a/backend/open_webui/apps/webui/main.py b/backend/open_webui/apps/webui/main.py index 1d12d708e..94e42f4a8 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 12bdd1c38..84f8a74d1 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 #################### @@ -82,6 +84,7 @@ class ChatResponse(BaseModel): archived: bool pinned: Optional[bool] = False meta: dict = {} + folder_id: Optional[str] = None class ChatTitleIdResponse(BaseModel): @@ -254,7 +257,7 @@ 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) @@ -276,7 +279,7 @@ 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: @@ -512,6 +515,29 @@ class ChatTable: # 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) 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 000000000..91aa0175e --- /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/chats.py b/backend/open_webui/apps/webui/routers/chats.py index 2e3afcdfe..9c404a9a7 100644 --- a/backend/open_webui/apps/webui/routers/chats.py +++ b/backend/open_webui/apps/webui/routers/chats.py @@ -491,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 000000000..08f07b8c6 --- /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/constants.py b/backend/open_webui/constants.py index 704cdd074..4e2ef008b 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/c69f45358db4_add_folder_table.py b/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py new file mode 100644 index 000000000..83e0dc28e --- /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/src/app.css b/src/app.css index 7a8bf59b0..53700abc6 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 6056f6dbf..13ad8fdba 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -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 000000000..f1a1f5b48 --- /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/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index 6a9d1ff19..6c9f1644a 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -14,11 +14,15 @@ export let className = ''; export let buttonClassName = 'w-fit'; export let title = null; + + export let disabled = false;
{#if title !== null} - +
{:else} - + {/if} - {#if open} + {#if open && !disabled}
diff --git a/src/lib/components/common/DragGhost.svelte b/src/lib/components/common/DragGhost.svelte index 6d97a4517..7169d72f0 100644 --- a/src/lib/components/common/DragGhost.svelte +++ b/src/lib/components/common/DragGhost.svelte @@ -24,7 +24,7 @@ bind:this={popupElement} class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none" > -
+
diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index e8e1eb8b5..b01bceace 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -22,7 +22,7 @@ { e.preventDefault(); - dragged = true; + draggedOver = true; }; const onDrop = (e) => { @@ -29,19 +31,23 @@ if (folderElement.contains(e.target)) { console.log('Dropped on the Button'); - // get data from the drag event - const dataTransfer = e.dataTransfer.getData('text/plain'); - const data = JSON.parse(dataTransfer); - console.log(data); - dispatch('drop', data); + 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); + } - dragged = false; + draggedOver = false; } }; const onDragLeave = (e) => { e.preventDefault(); - dragged = false; + draggedOver = false; }; onMount(() => { @@ -57,10 +63,10 @@ }); -
- {#if dragged} +
+ {#if draggedOver}
{/if} @@ -74,7 +80,7 @@ }} > -
+
-
+
diff --git a/src/lib/components/icons/Document.svelte b/src/lib/components/icons/Document.svelte new file mode 100644 index 000000000..9ae719725 --- /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 1376539f5..c19d71ca1 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -28,7 +28,9 @@ createNewChat, getPinnedChatList, toggleChatPinnedStatusById, - getChatPinnedStatusById + getChatPinnedStatusById, + getChatById, + updateChatFolderIdById } from '$lib/apis/chats'; import { WEBUI_BASE_URL } from '$lib/constants'; @@ -38,15 +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 ChevronDown from '../icons/ChevronDown.svelte'; - import ChevronUp from '../icons/ChevronUp.svelte'; - import ChevronRight from '../icons/ChevronRight.svelte'; - import Collapsible from '../common/Collapsible.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; @@ -69,6 +69,72 @@ 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)); @@ -284,6 +350,7 @@ localStorage.sidebar = value; }); + await initFolders(); await pinnedChats.set(await getPinnedChatList(localStorage.token)); await initChatList(); @@ -381,7 +448,7 @@
{ @@ -425,7 +492,7 @@ + +
+ 0} -
+
{ localStorage.setItem('showPinnedChat', e.detail); console.log(e.detail); }} on:drop={async (e) => { - const { id } = e.detail; + const { type, id } = e.detail; - const status = await getChatPinnedStatusById(localStorage.token, id); + if (type === 'chat') { + const chat = await getChatById(localStorage.token, id); - if (!status) { - const res = await toggleChatPinnedStatusById(localStorage.token, id); + 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; + }); - if (res) { - await pinnedChats.set(await getPinnedChatList(localStorage.token)); - initChatList(); + if (res) { + initChatList(); + await initFolders(); + } + } + + if (!chat.pinned) { + const res = await toggleChatPinnedStatusById(localStorage.token, id); + + if (res) { + await pinnedChats.set(await getPinnedChatList(localStorage.token)); + initChatList(); + await initFolders(); + } + } } } }} name={$i18n.t('Pinned')} > -
+
{#each $pinnedChats as chat, idx} { @@ -553,6 +660,10 @@ 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); @@ -564,25 +675,72 @@
{/if} -
+
+ {#if !search && folders} + { + initChatList(); + await initFolders(); + }} + /> + {/if} + { - const { id } = e.detail; + const { type, id } = e.detail; - const status = await getChatPinnedStatusById(localStorage.token, id); + if (type === 'chat') { + const chat = await getChatById(localStorage.token, id); - if (status) { - const res = await toggleChatPinnedStatusById(localStorage.token, id); + 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; + } + ); + + if (res) { + initChatList(); + await initFolders(); + } + } + + if (chat.pinned) { + const res = await toggleChatPinnedStatusById(localStorage.token, id); + + if (res) { + await pinnedChats.set(await getPinnedChatList(localStorage.token)); + initChatList(); + await initFolders(); + } + } + } + } else if (type === 'folder') { + if (folders[id].parent_id === null) { + return; + } + + const res = await updateFolderParentIdById(localStorage.token, id, null).catch( + (error) => { + toast.error(error); + return null; + } + ); if (res) { - await pinnedChats.set(await getPinnedChatList(localStorage.token)); - initChatList(); + await initFolders(); } } }} > -
+
{#if $chats} {#each $chats as chat, idx} {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)} @@ -615,7 +773,9 @@ {/if} { @@ -632,6 +792,10 @@ 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); diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 4dbcb9331..eb19ba5b4 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -33,8 +33,15 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte'; import DragGhost from '$lib/components/common/DragGhost.svelte'; + import Check from '$lib/components/icons/Check.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; + import Document from '$lib/components/icons/Document.svelte'; + + export let className = ''; + + export let id; + export let title; - export let chat; export let selected = false; export let shiftKey = false; @@ -43,7 +50,7 @@ let showShareChatModal = false; let confirmEdit = false; - let chatTitle = chat.title; + let chatTitle = title; const editChatTitle = async (id, title) => { if (title === '') { @@ -93,7 +100,7 @@ let itemElement; - let drag = false; + let dragged = false; let x = 0; let y = 0; @@ -102,28 +109,35 @@ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; const onDragStart = (event) => { + event.stopPropagation(); + event.dataTransfer.setDragImage(dragImage, 0, 0); // Set the data to be transferred event.dataTransfer.setData( 'text/plain', JSON.stringify({ - id: chat.id + type: 'chat', + id: id }) ); - drag = true; + dragged = true; itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged }; const onDrag = (event) => { + event.stopPropagation(); + x = event.clientX; y = event.clientY; }; const onDragEnd = (event) => { + event.stopPropagation(); + itemElement.style.opacity = '1'; // Reset visual cue after drag - drag = false; + dragged = false; }; onMount(() => { @@ -146,24 +160,26 @@ }); - + -{#if drag && x && y} +{#if dragged && x && y} -
-
+
+
+
- {chat.title} + {title}
{/if} -
+
{#if confirmEdit}
{:else} { dispatch('select'); @@ -191,7 +208,7 @@ } }} on:dblclick={() => { - chatTitle = chat.title; + chatTitle = title; confirmEdit = true; }} on:mouseenter={(e) => { @@ -205,7 +222,7 @@ >
- {chat.title} + {title}
@@ -214,12 +231,14 @@
{ @@ -230,28 +249,19 @@ }} > {#if confirmEdit} -
+
@@ -263,16 +273,7 @@ chatTitle = ''; }} > - - - +
@@ -282,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} +
+ +