Browse Source

feat: chat folder drag and drop support

Timothy J. Baek 6 months ago
parent
commit
d8b513023c

+ 3 - 2
backend/open_webui/apps/webui/models/chats.py

@@ -84,6 +84,7 @@ class ChatResponse(BaseModel):
     archived: bool
     pinned: Optional[bool] = False
     meta: dict = {}
+    folder_id: Optional[str] = None
 
 
 class ChatTitleIdResponse(BaseModel):
@@ -256,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).filter_by(parent_id=None)
+            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)
 
@@ -278,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:

+ 1 - 33
backend/open_webui/apps/webui/models/folders.py

@@ -19,13 +19,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
 ####################
 
 
-class FolderItems(BaseModel):
-    chat_ids: Optional[list[str]] = None
-    file_ids: Optional[list[str]] = None
-
-    model_config = ConfigDict(extra="allow")
-
-
 class Folder(Base):
     __tablename__ = "folder"
     id = Column(Text, primary_key=True)
@@ -44,7 +37,7 @@ class FolderModel(BaseModel):
     parent_id: Optional[str] = None
     user_id: str
     name: str
-    items: Optional[FolderItems] = None
+    items: Optional[dict] = None
     meta: Optional[dict] = None
     is_expanded: bool = False
     created_at: int
@@ -63,11 +56,6 @@ class FolderForm(BaseModel):
     model_config = ConfigDict(extra="allow")
 
 
-class FolderItemsUpdateForm(BaseModel):
-    items: FolderItems
-    model_config = ConfigDict(extra="allow")
-
-
 class FolderTable:
     def insert_new_folder(
         self, user_id: str, name: str, parent_id: Optional[str] = None
@@ -222,26 +210,6 @@ class FolderTable:
             log.error(f"update_folder: {e}")
             return
 
-    def update_folder_items_by_id_and_user_id(
-        self, id: str, user_id: str, items: FolderItems
-    ) -> 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.items = items.model_dump()
-                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:

+ 25 - 0
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
 ############################

+ 15 - 32
backend/open_webui/apps/webui/routers/folders.py

@@ -10,7 +10,6 @@ import mimetypes
 
 from open_webui.apps.webui.models.folders import (
     FolderForm,
-    FolderItemsUpdateForm,
     FolderModel,
     Folders,
 )
@@ -42,7 +41,21 @@ router = APIRouter()
 @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
+
+    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
+    ]
 
 
 ############################
@@ -209,36 +222,6 @@ async def update_folder_is_expanded_by_id(
         )
 
 
-############################
-# 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_id_and_user_id(id, user.id)
-    if folder:
-        try:
-            folder = Folders.update_folder_items_by_id_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
 ############################

+ 35 - 0
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;
 

+ 54 - 17
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';
 
@@ -110,13 +112,12 @@
 			return;
 		}
 
-		if (Object.values(folders).find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
+		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 (
-				Object.values(folders).find(
-					(folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase()
-				)
+				rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
 			) {
 				i++;
 			}
@@ -601,14 +602,33 @@
 							const { type, id } = e.detail;
 
 							if (type === 'chat') {
-								const status = await getChatPinnedStatusById(localStorage.token, id);
+								const chat = await getChatById(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 initFolders();
+											initChatList();
+										}
+									}
 
-								if (!status) {
-									const res = await toggleChatPinnedStatusById(localStorage.token, id);
+									if (chat.pinned) {
+										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();
+										}
 									}
 								}
 							}
@@ -672,14 +692,31 @@
 						const { type, id } = e.detail;
 
 						if (type === 'chat') {
-							const status = await getChatPinnedStatusById(localStorage.token, id);
+							const chat = await getChatById(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 initFolders();
+										initChatList();
+									}
+								}
 
-							if (status) {
-								const res = await toggleChatPinnedStatusById(localStorage.token, id);
+								if (chat.pinned) {
+									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();
+									}
 								}
 							}
 						} else if (type === 'folder') {

+ 6 - 0
src/lib/components/layout/Sidebar/ChatItem.svelte

@@ -109,6 +109,8 @@
 		'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
 
 	const onDragStart = (event) => {
+		event.stopPropagation();
+
 		event.dataTransfer.setDragImage(dragImage, 0, 0);
 
 		// Set the data to be transferred
@@ -125,11 +127,15 @@
 	};
 
 	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
 		dragged = false;
 	};

+ 34 - 9
src/lib/components/layout/Sidebar/RecursiveFolder.svelte

@@ -17,6 +17,8 @@
 		updateFolderParentIdById
 	} from '$lib/apis/folders';
 	import { toast } from 'svelte-sonner';
+	import { updateChatFolderIdById } from '$lib/apis/chats';
+	import ChatItem from './ChatItem.svelte';
 
 	export let open = true;
 
@@ -80,7 +82,16 @@
 					}
 				} else if (type === 'chat') {
 					// Move the chat
-					console.log('Move the chat');
+					const res = await updateChatFolderIdById(localStorage.token, id, folderId).catch(
+						(error) => {
+							toast.error(error);
+							return null;
+						}
+					);
+
+					if (res) {
+						dispatch('update');
+					}
 				}
 			} catch (error) {
 				console.error(error);
@@ -272,8 +283,8 @@
 							type="text"
 							bind:value={name}
 							on:blur={() => {
-								edit = false;
 								nameUpdateHandler();
+								edit = false;
 							}}
 							on:click={(e) => {
 								// Prevent accidental collapse toggling when clicking inside input
@@ -283,6 +294,11 @@
 								// Prevent accidental collapse toggling when clicking inside input
 								e.stopPropagation();
 							}}
+							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}
@@ -304,15 +320,24 @@
 		</div>
 
 		<div slot="content" class="w-full">
-			{#if folders[folderId].childrenIds || folders[folderId].items?.chat_ids}
+			{#if folders[folderId].childrenIds || folders[folderId].items?.chats}
 				<div
-					class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
+					class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
 				>
 					{#if folders[folderId]?.childrenIds}
-						{#each folders[folderId]?.childrenIds as childId (`${folderId}-${childId}`)}
+						{@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}`)}
 							<svelte:self
 								{folders}
-								folderId={childId}
+								folderId={childFolder.id}
 								parentDragged={dragged}
 								on:update={(e) => {
 									dispatch('update', e.detail);
@@ -321,9 +346,9 @@
 						{/each}
 					{/if}
 
-					{#if folders[folderId].items?.chat_ids}
-						{#each folder.items.chat_ids as chatId (chatId)}
-							{chatId}
+					{#if folders[folderId].items?.chats}
+						{#each folders[folderId].items.chats as chat (chat.id)}
+							<ChatItem id={chat.id} title={chat.title} />
 						{/each}
 					{/if}
 				</div>