浏览代码

feat: folders as projects

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
Timothy Jaeryang Baek 2 月之前
父节点
当前提交
7607c53bd5

+ 2 - 2
backend/open_webui/models/folders.py

@@ -212,13 +212,13 @@ class FolderTable:
                     .first()
                 )
 
-                if existing_folder:
+                if existing_folder and existing_folder.id != id:
                     return None
 
                 folder.name = form_data.get("name", folder.name)
                 if "data" in form_data:
                     folder.data = {
-                        **folder.data,
+                        **(folder.data or {}),
                         **form_data["data"],
                     }
 

+ 1 - 1
backend/open_webui/routers/folders.py

@@ -120,7 +120,7 @@ async def update_folder_name_by_id(
         existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
             folder.parent_id, user.id, form_data.name
         )
-        if existing_folder:
+        if existing_folder and existing_folder.id != id:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),

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

@@ -0,0 +1,136 @@
+<script lang="ts">
+	import { getContext, createEventDispatcher, onMount } from 'svelte';
+
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import Modal from '$lib/components/common/Modal.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
+
+	import { toast } from 'svelte-sonner';
+	import { page } from '$app/stores';
+	import { goto } from '$app/navigation';
+	import Textarea from '$lib/components/common/Textarea.svelte';
+	import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
+	const i18n = getContext('i18n');
+
+	export let show = false;
+	export let onSubmit: Function = (e) => {};
+
+	export let folder = null;
+
+	let name = '';
+	let data = {
+		system_prompt: '',
+		files: []
+	};
+
+	let loading = false;
+
+	const submitHandler = async () => {
+		loading = true;
+		await onSubmit({
+			name,
+			data
+		});
+		show = false;
+		loading = false;
+	};
+
+	const init = () => {
+		name = folder.name;
+		data = folder.data || {
+			system_prompt: '',
+			files: []
+		};
+	};
+
+	$: if (folder) {
+		init();
+	}
+</script>
+
+<Modal size="md" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
+			<div class=" text-lg font-medium self-center">
+				{$i18n.t('Edit Folder')}
+			</div>
+			<button
+				class="self-center"
+				on:click={() => {
+					show = false;
+				}}
+			>
+				<XMark className={'size-5'} />
+			</button>
+		</div>
+
+		<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
+			<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
+				<form
+					class="flex flex-col w-full"
+					on:submit|preventDefault={() => {
+						submitHandler();
+					}}
+				>
+					<div class="flex flex-col w-full mt-1">
+						<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Folder Name')}</div>
+
+						<div class="flex-1">
+							<input
+								class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+								type="text"
+								bind:value={name}
+								placeholder={$i18n.t('Enter folder name')}
+								autocomplete="off"
+							/>
+						</div>
+					</div>
+
+					<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
+
+					<div class="my-1">
+						<div class="mb-2 text-xs text-gray-500">{$i18n.t('System Prompt')}</div>
+						<div>
+							<Textarea
+								className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
+								placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
+								rows={4}
+								bind:value={data.system_prompt}
+							/>
+						</div>
+					</div>
+
+					<div class="my-2">
+						<Knowledge bind:selectedItems={data.files}>
+							<div slot="label">
+								<div class="flex w-full justify-between">
+									<div class=" mb-2 text-xs text-gray-500">
+										{$i18n.t('Knowledge')}
+									</div>
+								</div>
+							</div>
+						</Knowledge>
+					</div>
+
+					<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
+						<button
+							class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
+								? ' cursor-not-allowed'
+								: ''}"
+							type="submit"
+							disabled={loading}
+						>
+							{$i18n.t('Save')}
+
+							{#if loading}
+								<div class="ml-2 self-center">
+									<Spinner />
+								</div>
+							{/if}
+						</button>
+					</div>
+				</form>
+			</div>
+		</div>
+	</div>
+</Modal>

+ 37 - 27
src/lib/components/layout/Sidebar/RecursiveFolder.svelte

@@ -8,30 +8,35 @@
 	import fileSaver from 'file-saver';
 	const { saveAs } = fileSaver;
 
-	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 { toast } from 'svelte-sonner';
+
+	import { selectedFolder } from '$lib/stores';
 
-	import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
-	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
 	import {
 		deleteFolderById,
 		updateFolderIsExpandedById,
 		updateFolderById,
 		updateFolderParentIdById
 	} from '$lib/apis/folders';
-	import { toast } from 'svelte-sonner';
 	import {
 		getChatById,
 		getChatsByFolderId,
 		importChat,
 		updateChatFolderIdById
 	} from '$lib/apis/chats';
+
+	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';
+	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
+
 	import ChatItem from './ChatItem.svelte';
 	import FolderMenu from './Folders/FolderMenu.svelte';
 	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
-	import { selectedFolder } from '$lib/stores';
+	import EditFolderModal from './Folders/EditFolderModal.svelte';
 
 	export let open = false;
 
@@ -39,14 +44,13 @@
 	export let folderId;
 	export let shiftKey = false;
 
-	export let onCreateChat = (e) => {};
-
 	export let className = '';
 
 	export let parentDragged = false;
 
 	let folderElement;
 
+	let showEditFolderModal = false;
 	let edit = false;
 
 	let draggedOver = false;
@@ -235,7 +239,7 @@
 			delete folders[folderId].new;
 
 			await tick();
-			editHandler();
+			renameHandler();
 		}
 	});
 
@@ -265,23 +269,21 @@
 		}
 	};
 
-	const nameUpdateHandler = async () => {
+	const updateHandler = async ({ name, data }) => {
 		if (name === '') {
 			toast.error($i18n.t('Folder name cannot be empty.'));
 			return;
 		}
 
-		if (name === folders[folderId].name) {
-			edit = false;
-			return;
-		}
-
 		const currentName = folders[folderId].name;
 
 		name = name.trim();
 		folders[folderId].name = name;
 
-		const res = await updateFolderById(localStorage.token, folderId, { name }).catch((error) => {
+		const res = await updateFolderById(localStorage.token, folderId, {
+			name,
+			...(data ? { data } : {})
+		}).catch((error) => {
 			toast.error(`${error}`);
 
 			folders[folderId].name = currentName;
@@ -290,7 +292,12 @@
 
 		if (res) {
 			folders[folderId].name = name;
-			toast.success($i18n.t('Folder name updated successfully'));
+			if (data) {
+				folders[folderId].data = data;
+			}
+
+			// toast.success($i18n.t('Folder name updated successfully'));
+			toast.success($i18n.t('Folder updated successfully'));
 
 			if ($selectedFolder?.id === folderId) {
 				selectedFolder.set(folders[folderId]);
@@ -320,7 +327,7 @@
 
 	$: isExpandedUpdateDebounceHandler(open);
 
-	const editHandler = async () => {
+	const renameHandler = async () => {
 		console.log('Edit');
 		await tick();
 		name = folders[folderId].name;
@@ -368,6 +375,12 @@
 	</div>
 </DeleteConfirmDialog>
 
+<EditFolderModal
+	bind:show={showEditFolderModal}
+	folder={folders[folderId]}
+	onSubmit={updateHandler}
+/>
+
 {#if dragged && x && y}
 	<DragGhost {x} {y}>
 		<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
@@ -407,7 +420,7 @@
 					? 'bg-gray-100 dark:bg-gray-900'
 					: ''}"
 				on:dblclick={() => {
-					editHandler();
+					renameHandler();
 				}}
 				on:click={(e) => {
 					selectedFolder.set(folders[folderId]);
@@ -431,7 +444,7 @@
 								e.target.select();
 							}}
 							on:blur={() => {
-								nameUpdateHandler();
+								updateHandler({ name });
 								edit = false;
 							}}
 							on:click={(e) => {
@@ -444,7 +457,7 @@
 							}}
 							on:keydown={(e) => {
 								if (e.key === 'Enter') {
-									nameUpdateHandler();
+									updateHandler({ name });
 									edit = false;
 								}
 							}}
@@ -464,10 +477,7 @@
 				>
 					<FolderMenu
 						onEdit={() => {
-							// Requires a timeout to prevent the click event from closing the dropdown
-							setTimeout(() => {
-								editHandler();
-							}, 200);
+							showEditFolderModal = true;
 						}}
 						onDelete={() => {
 							showDeleteConfirm = true;

+ 48 - 27
src/lib/components/workspace/Models/Knowledge.svelte

@@ -1,24 +1,42 @@
 <script lang="ts">
-	import { getContext } from 'svelte';
+	import { getContext, onMount } from 'svelte';
+	import { knowledge } from '$lib/stores';
+
 	import Selector from './Knowledge/Selector.svelte';
 	import FileItem from '$lib/components/common/FileItem.svelte';
+	import { getKnowledgeBases } from '$lib/apis/knowledge';
 
 	export let selectedItems = [];
 	const i18n = getContext('i18n');
+
+	let loaded = false;
+
+	onMount(async () => {
+		if (!$knowledge) {
+			knowledge.set(await getKnowledgeBases(localStorage.token));
+		}
+		loaded = true;
+	});
 </script>
 
 <div>
-	<div class="flex w-full justify-between mb-1">
-		<div class=" self-center text-sm font-semibold">{$i18n.t('Knowledge')}</div>
-	</div>
+	<slot name="label">
+		<div class="mb-2">
+			<div class="flex w-full justify-between mb-1">
+				<div class=" self-center text-sm font-semibold">
+					{$i18n.t('Knowledge')}
+				</div>
+			</div>
 
-	<div class=" text-xs dark:text-gray-500">
-		{$i18n.t('To attach knowledge base here, add them to the "Knowledge" workspace first.')}
-	</div>
+			<div class=" text-xs dark:text-gray-500">
+				{$i18n.t('To attach knowledge base here, add them to the "Knowledge" workspace first.')}
+			</div>
+		</div>
+	</slot>
 
 	<div class="flex flex-col">
 		{#if selectedItems?.length > 0}
-			<div class=" flex flex-wrap items-center gap-2 mt-2">
+			<div class=" flex flex-wrap items-center gap-2 mb-2.5">
 				{#each selectedItems as file, fileIdx}
 					<FileItem
 						{file}
@@ -38,27 +56,30 @@
 			</div>
 		{/if}
 
-		<div class="flex flex-wrap text-sm font-medium gap-1.5 mt-2">
-			<Selector
-				on:select={(e) => {
-					const item = e.detail;
+		{#if loaded}
+			<div class="flex flex-wrap text-sm font-medium gap-1.5">
+				<Selector
+					knowledgeItems={$knowledge || []}
+					on:select={(e) => {
+						const item = e.detail;
 
-					if (!selectedItems.find((k) => k.id === item.id)) {
-						selectedItems = [
-							...selectedItems,
-							{
-								...item
-							}
-						];
-					}
-				}}
-			>
-				<button
-					class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-850 rounded-3xl"
-					type="button">{$i18n.t('Select Knowledge')}</button
+						if (!selectedItems.find((k) => k.id === item.id)) {
+							selectedItems = [
+								...selectedItems,
+								{
+									...item
+								}
+							];
+						}
+					}}
 				>
-			</Selector>
-		</div>
+					<button
+						class=" px-3.5 py-1.5 font-medium hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-850 rounded-3xl"
+						type="button">{$i18n.t('Select Knowledge')}</button
+					>
+				</Selector>
+			</div>
+		{/if}
 		<!-- {knowledge} -->
 	</div>
 </div>

+ 8 - 6
src/lib/components/workspace/Models/Knowledge/Selector.svelte

@@ -15,6 +15,8 @@
 
 	export let onClose: Function = () => {};
 
+	export let knowledgeItems = [];
+
 	let query = '';
 
 	let items = [];
@@ -51,7 +53,7 @@
 			};
 		});
 
-		let legacy_documents = $knowledge
+		let legacy_documents = knowledgeItems
 			.filter((item) => item?.meta?.document)
 			.map((item) => ({
 				...item,
@@ -86,16 +88,16 @@
 					]
 				: [];
 
-		let collections = $knowledge
+		let collections = knowledgeItems
 			.filter((item) => !item?.meta?.document)
 			.map((item) => ({
 				...item,
 				type: 'collection'
 			}));
 		let collection_files =
-			$knowledge.length > 0
+			knowledgeItems.length > 0
 				? [
-						...$knowledge
+						...knowledgeItems
 							.reduce((a, item) => {
 								return [
 									...new Set([
@@ -141,7 +143,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-96 rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
+			class="w-full max-w-96 rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-[99999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg"
 			sideOffset={8}
 			side="bottom"
 			align="start"
@@ -162,7 +164,7 @@
 
 			<div class="max-h-56 overflow-y-scroll">
 				{#if filteredItems.length === 0}
-					<div class="text-center text-sm text-gray-500 dark:text-gray-400">
+					<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
 						{$i18n.t('No knowledge found')}
 					</div>
 				{:else}