瀏覽代碼

feat: folder ui

Timothy J. Baek 6 月之前
父節點
當前提交
a942c30ca8

+ 2 - 0
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"])

+ 25 - 0
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
 
 
 ####################
@@ -512,6 +514,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)

+ 68 - 2
backend/open_webui/apps/webui/models/folders.py

@@ -22,7 +22,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
 class FolderItems(BaseModel):
     chat_ids: Optional[list[str]] = None
     file_ids: Optional[list[str]] = None
-    folder_ids: Optional[list[str]] = None
 
     model_config = ConfigDict(extra="allow")
 
@@ -52,6 +51,21 @@ class FolderModel(BaseModel):
     model_config = ConfigDict(from_attributes=True)
 
 
+####################
+# Forms
+####################
+
+
+class FolderForm(BaseModel):
+    name: str
+    model_config = ConfigDict(extra="allow")
+
+
+class FolderItemsUpdateForm(BaseModel):
+    items: FolderItems
+    model_config = ConfigDict(extra="allow")
+
+
 class FolderTable:
     def insert_new_folder(self, name: str, user_id: str) -> Optional[FolderModel]:
         with get_db() as db:
@@ -96,7 +110,59 @@ class FolderTable:
                 for folder in db.query(Folder).filter_by(user_id=user_id).all()
             ]
 
-    def update_folder_by_name_and_user_id(
+    def get_folders_by_parent_id_and_user_id(self, parent_id: str, user_id: str):
+        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()
+                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_name_and_user_id(
+        self, name: str, user_id: str, new_name: str
+    ) -> Optional[FolderModel]:
+        try:
+            id = name.lower()
+            new_id = new_name.lower()
+            with get_db() as db:
+                # Check if new folder name already exists
+                folder = db.query(Folder).filter_by(id=new_id, user_id=user_id).first()
+                if folder:
+                    return None
+
+                folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
+                folder.id = new_id
+                folder.name = new_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_items_by_name_and_user_id(
         self, name: str, user_id: str, items: FolderItems
     ) -> Optional[FolderModel]:
         try:

+ 197 - 0
backend/open_webui/apps/webui/routers/folders.py

@@ -0,0 +1,197 @@
+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,
+    FolderItemsUpdateForm,
+    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 folders
+
+
+############################
+# Create Folder
+############################
+
+
+@router.post("/")
+def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
+    folder = Folders.get_folder_by_name_and_user_id(form_data.name, user.id)
+    if folder:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
+        )
+
+    try:
+        folder = Folders.insert_new_folder(form_data.name, user.id)
+        return folder
+    except Exception as e:
+        log.exception(e)
+        log.error(f"Error creating folder: {form_data.name}")
+        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_name_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)
+):
+    new_id = form_data.name.lower()
+    folder = Folders.get_folder_by_name_and_user_id(new_id, user.id)
+    if folder:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
+        )
+
+    folder = Folders.get_folder_by_name_and_user_id(id, user.id)
+    if folder:
+        try:
+            folder = Folders.update_folder_name_by_name_and_user_id(
+                id, user.id, form_data.name
+            )
+
+            # Update children folders parent_id
+            children_folders = Folders.get_folders_by_parent_id_and_user_id(id, user.id)
+            for child in children_folders:
+                Folders.update_folder_parent_id_by_id_and_user_id(
+                    child.id, user.id, folder.id
+                )
+
+            # Update children items parent_id
+            chats = Chats.get_chats_by_folder_id_and_user_id(id, user.id)
+            for chat in chats:
+                Chats.update_chat_folder_id_by_id_and_user_id(
+                    chat.id, user.id, folder.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 Items By Id
+############################
+
+
+@router.post("/{id}/update/items")
+async def update_folder_items_by_id(
+    id: str, form_data: FolderItemsUpdateForm, user=Depends(get_verified_user)
+):
+    folder = Folders.get_folder_by_name_and_user_id(id, user.id)
+    if folder:
+        try:
+            folder = Folders.update_folder_by_name_and_user_id(
+                id, user.id, form_data.items
+            )
+            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_name_and_user_id(id, user.id)
+    if folder:
+        try:
+            result = Folders.delete_folder_by_name_and_user_id(id, user.id)
+            return result
+        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,
+        )

+ 8 - 1
backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py

@@ -34,9 +34,16 @@ def upgrade():
             server_default=sa.func.now(),
             onupdate=sa.func.now(),
         ),
-        sa.PrimaryKeyConstraint("id", "user_id")
+        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")

+ 198 - 0
src/lib/apis/folders/index.ts

@@ -0,0 +1,198 @@
+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;
+};
+
+type FolderItems = {
+	chat_ids: string[];
+	file_ids: string[];
+	folder_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;
+};

+ 1 - 1
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"
 >
-	<div class=" absolute text-white z-[99999]" style="top: {y}px; left: {x}px;">
+	<div class=" absolute text-white z-[99999]" style="top: {y + 10}px; left: {x + 10}px;">
 		<slot></slot>
 	</div>
 </div>

+ 21 - 15
src/lib/components/common/Folder.svelte

@@ -14,13 +14,15 @@
 	export let name = '';
 	export let collapsible = true;
 
+	export let className = '';
+
 	let folderElement;
 
-	let dragged = false;
+	let draggedOver = false;
 
 	const onDragOver = (e) => {
 		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);
-
-			dragged = false;
+			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();
-		dragged = false;
+		draggedOver = false;
 	};
 
 	onMount(() => {
@@ -57,10 +63,10 @@
 	});
 </script>
 
-<div bind:this={folderElement} class="relative">
-	{#if dragged}
+<div bind:this={folderElement} class="relative {className}">
+	{#if draggedOver}
 		<div
-			class="absolute top-0 left-0 w-full h-full rounded-sm bg-gray-200 bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
+			class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(258,88%,66%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
 		></div>
 	{/if}
 
@@ -74,7 +80,7 @@
 			}}
 		>
 			<!-- svelte-ignore a11y-no-static-element-interactions -->
-			<div class="mx-2 w-full">
+			<div class="w-full">
 				<button
 					class="w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
 				>
@@ -92,7 +98,7 @@
 				</button>
 			</div>
 
-			<div slot="content" class=" pl-2">
+			<div slot="content" class="w-full">
 				<slot></slot>
 			</div>
 		</Collapsible>

+ 19 - 0
src/lib/components/icons/Document.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
+	/>
+</svg>

+ 100 - 19
src/lib/components/layout/Sidebar.svelte

@@ -43,6 +43,8 @@
 	import Folder from '../common/Folder.svelte';
 	import Plus from '../icons/Plus.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
+	import { createNewFolder, getFolders } from '$lib/apis/folders';
+	import Folders from './Sidebar/Folders.svelte';
 
 	const BREAKPOINT = 768;
 
@@ -65,6 +67,55 @@
 	let chatListLoading = false;
 	let allChatsLoaded = false;
 
+	let folders = {};
+
+	const initFolders = async () => {
+		const folderList = await getFolders(localStorage.token).catch((error) => {
+			toast.error(error);
+			return [];
+		});
+
+		for (const folder of folderList) {
+			folders[folder.id] = { ...(folders[folder.id] ? folders[folder.id] : {}), ...folder };
+
+			if (folders[folder.id].parent_id) {
+				folders[folders[folder.id].parent_id].childrenIds = folders[folders[folder.id].parent_id]
+					.childrenIds
+					? [...folders[folders[folder.id].parent_id].childrenIds, folder.id]
+					: [folder.id];
+
+				folders[folders[folder.id].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;
+		}
+
+		if (name.toLowerCase() in folders) {
+			// If a folder with the same name already exists, append a number to the name
+			let i = 1;
+			while (name.toLowerCase() + ` ${i}` in folders) {
+				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));
@@ -280,6 +331,7 @@
 			localStorage.sidebar = value;
 		});
 
+		await initFolders();
 		await pinnedChats.set(await getPinnedChatList(localStorage.token));
 		await initChatList();
 
@@ -491,7 +543,12 @@
 
 			<div class="absolute z-40 right-4 top-1">
 				<Tooltip content={$i18n.t('New folder')}>
-					<button class="p-1 rounded-lg hover:bg-white/5 transition">
+					<button
+						class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
+						on:click={() => {
+							createFolder();
+						}}
+					>
 						<Plus />
 					</button>
 				</Tooltip>
@@ -514,33 +571,40 @@
 			{/if}
 
 			{#if !search && $pinnedChats.length > 0}
-				<div class=" flex flex-col space-y-1">
+				<div class="flex flex-col space-y-1 rounded-xl">
 					<Folder
+						className="px-2"
 						bind:open={showPinnedChat}
 						on:change={(e) => {
 							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 status = await getChatPinnedStatusById(localStorage.token, id);
 
-							if (!status) {
-								const res = await toggleChatPinnedStatusById(localStorage.token, id);
+								if (!status) {
+									const res = await toggleChatPinnedStatusById(localStorage.token, id);
 
-								if (res) {
-									await pinnedChats.set(await getPinnedChatList(localStorage.token));
-									initChatList();
+									if (res) {
+										await pinnedChats.set(await getPinnedChatList(localStorage.token));
+										initChatList();
+									}
 								}
 							}
 						}}
 						name={$i18n.t('Pinned')}
 					>
-						<div class="pl-2 mt-0.5 flex flex-col overflow-y-auto scrollbar-hidden">
+						<div
+							class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
+						>
 							{#each $pinnedChats as chat, idx}
 								<ChatItem
-									{chat}
+									className=""
+									id={chat.id}
+									title={chat.title}
 									{shiftKey}
 									selected={selectedChatId === chat.id}
 									on:select={() => {
@@ -557,6 +621,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);
@@ -568,20 +636,28 @@
 				</div>
 			{/if}
 
+			{#if folders}
+				<div class=" flex flex-col">
+					<Folders {folders} />
+				</div>
+			{/if}
+
 			<div class="flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
 				<Folder
 					collapsible={false}
 					on:drop={async (e) => {
-						const { id } = e.detail;
+						const { type, id } = e.detail;
 
-						const status = await getChatPinnedStatusById(localStorage.token, id);
+						if (type === 'chat') {
+							const status = await getChatPinnedStatusById(localStorage.token, id);
 
-						if (status) {
-							const res = await toggleChatPinnedStatusById(localStorage.token, id);
+							if (status) {
+								const res = await toggleChatPinnedStatusById(localStorage.token, id);
 
-							if (res) {
-								await pinnedChats.set(await getPinnedChatList(localStorage.token));
-								initChatList();
+								if (res) {
+									await pinnedChats.set(await getPinnedChatList(localStorage.token));
+									initChatList();
+								}
 							}
 						}
 					}}
@@ -619,7 +695,8 @@
 								{/if}
 
 								<ChatItem
-									{chat}
+									id={chat.id}
+									title={chat.title}
 									{shiftKey}
 									selected={selectedChatId === chat.id}
 									on:select={() => {
@@ -636,6 +713,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);

+ 45 - 50
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 = 'pr-2';
+
+	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;
 
@@ -108,11 +115,12 @@
 		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
 	};
 
@@ -123,7 +131,7 @@
 
 	const onDragEnd = (event) => {
 		itemElement.style.opacity = '1'; // Reset visual cue after drag
-		drag = false;
+		dragged = false;
 	};
 
 	onMount(() => {
@@ -146,24 +154,26 @@
 	});
 </script>
 
-<ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
+<ShareChatModal bind:show={showShareChatModal} chatId={id} />
 
-{#if drag && x && y}
+{#if dragged && x && y}
 	<DragGhost {x} {y}>
-		<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-44">
-			<div>
+		<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-40">
+			<div class="flex items-center gap-1">
+				<Document className="size-4" strokeWidth="2" />
 				<div class=" text-xs text-white line-clamp-1">
-					{chat.title}
+					{title}
 				</div>
 			</div>
 		</div>
 	</DragGhost>
 {/if}
 
-<div bind:this={itemElement} class=" w-full pr-2 relative group" draggable="true">
+<div bind:this={itemElement} class=" w-full {className} relative group" draggable="true">
 	{#if confirmEdit}
 		<div
-			class=" w-full flex justify-between rounded-xl px-2.5 py-2 {chat.id === $chatId || confirmEdit
+			class=" w-full flex justify-between rounded-lg px-[11px] py-[7px] {id === $chatId ||
+			confirmEdit
 				? 'bg-gray-200 dark:bg-gray-900'
 				: selected
 					? 'bg-gray-100 dark:bg-gray-950'
@@ -177,12 +187,13 @@
 		</div>
 	{:else}
 		<a
-			class=" w-full flex justify-between rounded-lg px-2.5 py-2 {chat.id === $chatId || confirmEdit
+			class=" w-full flex justify-between rounded-lg px-[11px] py-[7px] {id === $chatId ||
+			confirmEdit
 				? 'bg-gray-200 dark:bg-gray-900'
 				: selected
 					? 'bg-gray-100 dark:bg-gray-950'
 					: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'}  whitespace-nowrap text-ellipsis"
-			href="/c/{chat.id}"
+			href="/c/{id}"
 			on:click={() => {
 				dispatch('select');
 
@@ -191,7 +202,7 @@
 				}
 			}}
 			on:dblclick={() => {
-				chatTitle = chat.title;
+				chatTitle = title;
 				confirmEdit = true;
 			}}
 			on:mouseenter={(e) => {
@@ -205,7 +216,7 @@
 		>
 			<div class=" flex self-center flex-1 w-full">
 				<div class=" text-left self-center overflow-hidden w-full h-[20px]">
-					{chat.title}
+					{title}
 				</div>
 			</div>
 		</a>
@@ -214,12 +225,14 @@
 	<!-- svelte-ignore a11y-no-static-element-interactions -->
 	<div
 		class="
-        {chat.id === $chatId || confirmEdit
+        {id === $chatId || confirmEdit
 			? 'from-gray-200 dark:from-gray-900'
 			: selected
 				? 'from-gray-100 dark:from-gray-950'
 				: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
-            absolute right-[10px] top-[6px] py-1 pr-2 pl-5 bg-gradient-to-l from-80%
+            absolute {className === 'pr-2'
+			? 'right-[8px]'
+			: 'right-0'}  top-[5px] py-1 pr-0.5 mr-2 pl-5 bg-gradient-to-l from-80%
 
               to-transparent"
 		on:mouseenter={(e) => {
@@ -230,28 +243,19 @@
 		}}
 	>
 		{#if confirmEdit}
-			<div class="flex self-center space-x-1.5 z-10">
+			<div
+				class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
+			>
 				<Tooltip content={$i18n.t('Confirm')}>
 					<button
 						class=" self-center dark:hover:text-white transition"
 						on:click={() => {
-							editChatTitle(chat.id, chatTitle);
+							editChatTitle(id, chatTitle);
 							confirmEdit = false;
 							chatTitle = '';
 						}}
 					>
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
-								clip-rule="evenodd"
-							/>
-						</svg>
+						<Check className=" size-3.5" strokeWidth="2.5" />
 					</button>
 				</Tooltip>
 
@@ -263,16 +267,7 @@
 							chatTitle = '';
 						}}
 					>
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-							/>
-						</svg>
+						<XMark strokeWidth="2.5" />
 					</button>
 				</Tooltip>
 			</div>
@@ -282,7 +277,7 @@
 					<button
 						class=" self-center dark:hover:text-white transition"
 						on:click={() => {
-							archiveChatHandler(chat.id);
+							archiveChatHandler(id);
 						}}
 						type="button"
 					>
@@ -305,18 +300,18 @@
 		{:else}
 			<div class="flex self-center space-x-1 z-10">
 				<ChatMenu
-					chatId={chat.id}
+					chatId={id}
 					cloneChatHandler={() => {
-						cloneChatHandler(chat.id);
+						cloneChatHandler(id);
 					}}
 					shareHandler={() => {
 						showShareChatModal = true;
 					}}
 					archiveChatHandler={() => {
-						archiveChatHandler(chat.id);
+						archiveChatHandler(id);
 					}}
 					renameHandler={() => {
-						chatTitle = chat.title;
+						chatTitle = title;
 
 						confirmEdit = true;
 					}}
@@ -327,7 +322,7 @@
 						dispatch('unselect');
 					}}
 					on:change={async () => {
-						await pinnedChats.set(await getPinnedChatList(localStorage.token));
+						dispatch('change');
 					}}
 					on:tag={(e) => {
 						dispatch('tag', e.detail);
@@ -353,7 +348,7 @@
 					</button>
 				</ChatMenu>
 
-				{#if chat.id === $chatId}
+				{#if id === $chatId}
 					<!-- Shortcut support using "delete-chat-button" id -->
 					<button
 						id="delete-chat-button"

+ 14 - 0
src/lib/components/layout/Sidebar/Folders.svelte

@@ -0,0 +1,14 @@
+<script lang="ts">
+	import RecursiveFolder from './RecursiveFolder.svelte';
+	export let folders = {};
+
+	let folderList = [];
+	// Get the list of folders that have no parent, sorted by name alphabetically
+	$: folderList = Object.keys(folders)
+		.filter((key) => folders[key].parent_id === null)
+		.sort((a, b) => folders[a].name.localeCompare(folders[b].name));
+</script>
+
+{#each folderList as folderId (folderId)}
+	<RecursiveFolder className="px-2" {folders} {folderId} />
+{/each}

+ 220 - 0
src/lib/components/layout/Sidebar/RecursiveFolder.svelte

@@ -0,0 +1,220 @@
+<script>
+	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 '../../common/Collapsible.svelte';
+	import DragGhost from '$lib/components/common/DragGhost.svelte';
+
+	import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
+
+	export let open = true;
+
+	export let folders;
+	export let folderId;
+
+	export let className;
+
+	let folderElement;
+
+	let edit = false;
+
+	let draggedOver = false;
+	let dragged = false;
+
+	let name = '';
+
+	const onDragOver = (e) => {
+		e.preventDefault();
+		e.stopPropagation();
+		draggedOver = true;
+	};
+
+	const onDrop = (e) => {
+		e.preventDefault();
+		e.stopPropagation();
+
+		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;
+	};
+
+	const dragImage = new Image();
+	dragImage.src =
+		'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
+
+	let x;
+	let y;
+
+	const onDragStart = (event) => {
+		event.dataTransfer.setDragImage(dragImage, 0, 0);
+
+		// Set the data to be transferred
+		event.dataTransfer.setData(
+			'text/plain',
+			JSON.stringify({
+				type: 'folder',
+				id: folderId
+			})
+		);
+
+		dragged = true;
+		folderElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
+	};
+
+	const onDrag = (event) => {
+		x = event.clientX;
+		y = event.clientY;
+	};
+
+	const onDragEnd = (event) => {
+		folderElement.style.opacity = '1'; // Reset visual cue after drag
+		dragged = false;
+	};
+
+	onMount(() => {
+		if (folderElement) {
+			folderElement.addEventListener('dragover', onDragOver);
+			folderElement.addEventListener('drop', onDrop);
+			folderElement.addEventListener('dragleave', onDragLeave);
+
+			// Event listener for when dragging starts
+			folderElement.addEventListener('dragstart', onDragStart);
+			// Event listener for when dragging occurs (optional)
+			folderElement.addEventListener('drag', onDrag);
+			// Event listener for when dragging ends
+			folderElement.addEventListener('dragend', onDragEnd);
+		}
+	});
+
+	onDestroy(() => {
+		if (folderElement) {
+			folderElement.addEventListener('dragover', onDragOver);
+			folderElement.removeEventListener('drop', onDrop);
+			folderElement.removeEventListener('dragleave', onDragLeave);
+
+			folderElement.removeEventListener('dragstart', onDragStart);
+			folderElement.removeEventListener('drag', onDrag);
+			folderElement.removeEventListener('dragend', onDragEnd);
+		}
+	});
+</script>
+
+{#if dragged && x && y}
+	<DragGhost {x} {y}>
+		<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-40">
+			<div class="flex items-center gap-1">
+				<FolderOpen className="size-3.5" strokeWidth="2" />
+				<div class=" text-xs text-white line-clamp-1">
+					{folders[folderId].name}
+				</div>
+			</div>
+		</div>
+	</DragGhost>
+{/if}
+
+<div bind:this={folderElement} class="relative {className}" draggable="true">
+	{#if draggedOver}
+		<div
+			class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(258,88%,66%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
+		></div>
+	{/if}
+
+	<Collapsible
+		bind:open
+		className="w-full"
+		buttonClassName="w-full"
+		on:change={(e) => {
+			dispatch('open', e.detail);
+		}}
+	>
+		<!-- svelte-ignore a11y-no-static-element-interactions -->
+		<div class="w-full">
+			<button
+				class="w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition"
+				on:dblclick={() => {
+					name = folders[folderId].name;
+					edit = true;
+
+					// focus on the input
+					setTimeout(() => {
+						const input = document.getElementById(`folder-${folderId}-input`);
+						input.focus();
+					}, 0);
+				}}
+			>
+				<div class="text-gray-300 dark:text-gray-600">
+					{#if open}
+						<ChevronDown className=" size-3" strokeWidth="2.5" />
+					{:else}
+						<ChevronRight className=" size-3" strokeWidth="2.5" />
+					{/if}
+				</div>
+
+				<div class="translate-y-[0.5px] flex-1 justify-start text-start">
+					{#if edit}
+						<input
+							id="folder-{folderId}-input"
+							type="text"
+							bind:value={folders[folderId].name}
+							on:input={(e) => {
+								folders[folderId].name = e.target.value;
+							}}
+							on:blur={() => {
+								edit = false;
+							}}
+							on:keydown={(e) => {
+								if (e.key === 'Enter') {
+									edit = false;
+								}
+							}}
+							class="w-full h-full bg-transparent text-gray-500 dark:text-gray-500 outline-none"
+						/>
+					{:else}
+						{folders[folderId].name}
+					{/if}
+				</div>
+			</button>
+		</div>
+
+		<div slot="content" class="w-full">
+			{#if folders[folderId].childrenIds || folders[folderId].items?.chat_ids}
+				<div
+					class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
+				>
+					{#if folders[folderId]?.childrenIds}
+						{#each folders[folderId]?.childrenIds as folderId (folderId)}
+							<svelte:self {folders} {folderId} />
+						{/each}
+					{/if}
+
+					{#if folders[folderId].items?.chat_ids}
+						{#each folder.items.chat_ids as chatId (chatId)}
+							{chatId}
+						{/each}
+					{/if}
+				</div>
+			{/if}
+		</div>
+	</Collapsible>
+</div>